diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index a9cf34993..fba34d490 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -35,11 +35,11 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: - version: 9 + version: 9.15.9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version-file: .nvmrc cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - name: Install frontend dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8c5dcfbf..839614743 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,12 +60,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: - version: 9 + version: 9.15.9 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version-file: .nvmrc cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index ab3305ab6..2ec5f73c0 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -37,11 +37,11 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@v6 with: - version: 9 + version: 9.15.9 - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version-file: .nvmrc cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml - name: Install dependencies diff --git a/Makefile b/Makefile index d00d0c4f5..e88dda9d2 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ .PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan FRONTEND_CRITICAL_VITEST := \ - src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ - src/views/auth/__tests__/WechatCallbackView.spec.ts \ - src/views/user/__tests__/PaymentView.spec.ts \ - src/views/user/__tests__/PaymentResultView.spec.ts \ - src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \ + src/components/__tests__/ApiKeyCreate.spec.ts \ + src/components/__tests__/Dashboard.spec.ts \ + src/components/__tests__/LoginForm.spec.ts \ + src/views/auth/__tests__/OidcCallbackView.spec.ts \ + src/views/user/__tests__/UsageView.spec.ts \ + src/views/admin/__tests__/UsersView.spec.ts \ src/views/admin/__tests__/SettingsView.spec.ts # 一键编译前后端 diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index d9a3f4b70..acaa4e20e 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -56,7 +56,7 @@ func (User) Fields() []ent.Field { field.String("username"). MaxLen(100). Default(""), - field.String("notes"). + field.String("notes"). SchemaType(map[string]string{dialect.Postgres: "text"}). Default(""), diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 2081cbe6f..cca6c6c45 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -427,14 +427,6 @@ func cloneOAuthMetadata(values map[string]any) map[string]any { return cloned } -func mergeOAuthMetadata(base map[string]any, overlay map[string]any) map[string]any { - merged := cloneOAuthMetadata(base) - for key, value := range overlay { - merged[key] = value - } - return merged -} - func normalizeAdoptedOAuthDisplayName(value string) string { value = strings.TrimSpace(value) if len([]rune(value)) > 100 { diff --git a/backend/internal/handler/auth_oauth_test_helpers_test.go b/backend/internal/handler/auth_oauth_test_helpers_test.go index 5022ffe8a..e42277610 100644 --- a/backend/internal/handler/auth_oauth_test_helpers_test.go +++ b/backend/internal/handler/auth_oauth_test_helpers_test.go @@ -2,28 +2,11 @@ package handler import ( "net/http" - "net/http/httptest" - "net/url" "testing" "github.com/stretchr/testify/require" ) -func buildEncodedOAuthBindUserCookie(t *testing.T, userID int64, secret string) string { - t.Helper() - value, err := buildOAuthBindUserCookieValue(userID, secret) - require.NoError(t, err) - return value -} - -func encodedCookie(name, value string) *http.Cookie { - return &http.Cookie{ - Name: name, - Value: encodeCookieValue(value), - Path: "/", - } -} - func findCookie(cookies []*http.Cookie, name string) *http.Cookie { for _, cookie := range cookies { if cookie.Name == name { @@ -33,39 +16,9 @@ func findCookie(cookies []*http.Cookie, name string) *http.Cookie { return nil } -func requireCookieCleared(t *testing.T, recorder *httptest.ResponseRecorder, name string) { - t.Helper() - cookie := findCookie(recorder.Result().Cookies(), name) - require.NotNil(t, cookie) - require.Equal(t, -1, cookie.MaxAge) -} - func decodeCookieValueForTest(t *testing.T, value string) string { t.Helper() decoded, err := decodeCookieValue(value) require.NoError(t, err) return decoded } - -func assertOAuthRedirectError(t *testing.T, location string, errorCode string, errorMessage string) { - t.Helper() - values := parseOAuthRedirectFragment(t, location) - require.Equal(t, errorCode, values.Get("error")) - require.Equal(t, errorMessage, values.Get("error_message")) -} - -func parseOAuthRedirectFragment(t *testing.T, location string) url.Values { - t.Helper() - require.NotEmpty(t, location) - - parsed, err := url.Parse(location) - require.NoError(t, err) - - rawValues := parsed.RawQuery - if rawValues == "" { - rawValues = parsed.Fragment - } - values, err := url.ParseQuery(rawValues) - require.NoError(t, err) - return values -} diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 792a8c10a..af4985c35 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -2,6 +2,7 @@ package handler import ( "context" + "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" @@ -672,10 +673,7 @@ func (h *AuthHandler) CompleteOIDCOAuthAccountCreation(c *gin.Context) { respondPendingOAuthBindingApplyError(c, err) return } - decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{ - AdoptDisplayName: req.AdoptDisplayName, - AdoptAvatar: req.AdoptAvatar, - }) + decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest(req)) if err != nil { response.ErrorFrom(c, err) return @@ -1113,13 +1111,21 @@ func (k oidcJWK) publicKey() (any, error) { return &rsa.PublicKey{N: n, E: e}, nil case "EC": var curve elliptic.Curve + var ecdhCurve ecdh.Curve + var coordinateSize int switch strings.TrimSpace(k.Crv) { case "P-256": curve = elliptic.P256() + ecdhCurve = ecdh.P256() + coordinateSize = 32 case "P-384": curve = elliptic.P384() + ecdhCurve = ecdh.P384() + coordinateSize = 48 case "P-521": curve = elliptic.P521() + ecdhCurve = ecdh.P521() + coordinateSize = 66 default: return nil, fmt.Errorf("unsupported ec curve: %s", k.Crv) } @@ -1131,8 +1137,12 @@ func (k oidcJWK) publicKey() (any, error) { if err != nil { return nil, fmt.Errorf("decode ec y: %w", err) } - if !curve.IsOnCurve(x, y) { - return nil, errors.New("ec point is not on curve") + point, err := encodeOIDCECDHPoint(x, y, coordinateSize) + if err != nil { + return nil, err + } + if _, err := ecdhCurve.NewPublicKey(point); err != nil { + return nil, fmt.Errorf("invalid ec public key: %w", err) } return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil default: @@ -1151,6 +1161,24 @@ func decodeBase64URLBigInt(raw string) (*big.Int, error) { return new(big.Int).SetBytes(buf), nil } +func encodeOIDCECDHPoint(x, y *big.Int, coordinateSize int) ([]byte, error) { + if x == nil || y == nil { + return nil, errors.New("missing ec coordinate") + } + if x.Sign() < 0 || y.Sign() < 0 { + return nil, errors.New("negative ec coordinate") + } + if len(x.Bytes()) > coordinateSize || len(y.Bytes()) > coordinateSize { + return nil, errors.New("ec coordinate is too large") + } + + point := make([]byte, 1+coordinateSize*2) + point[0] = 4 + x.FillBytes(point[1 : 1+coordinateSize]) + y.FillBytes(point[1+coordinateSize:]) + return point, nil +} + func containsString(values []string, target string) bool { target = strings.TrimSpace(target) for _, v := range values { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index f03e0bdce..f0570b58e 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -737,6 +737,13 @@ func ChatCompletionsChunkToResponsesEvents( copyCall.ID = generateItemID() } copyCall.Type = "function" + // Arguments are accumulated by the shared block below so the + // emitted delta and the stored value stay in sync. Some upstreams + // (e.g. GLM/Zhipu) pack id+name+arguments into the first tool_call + // chunk; without this reset the first chunk's arguments would be + // counted twice (once from this copy, once from the += below), + // producing a doubled, invalid JSON like {"a":1}{"a":1}. + copyCall.Function.Arguments = "" state.ToolCalls[idx] = ©Call stored = ©Call itemID := generateItemID() diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go index b71d1ba78..baa4e4b2a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go @@ -179,6 +179,41 @@ func TestStream_ToolCallLifecycleComplete(t *testing.T) { require.True(t, sawItemDone, "function_call output_item.done missing") } +// TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape +// where a single tool_call delta chunk carries id+name+arguments together. +// Earlier code copied the whole tool_call (including arguments) into state and +// then accumulated the same chunk's arguments again, producing a doubled, +// invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing +// ("trailing characters"). +func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`, + `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`, + `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + }) + + var argsDelta strings.Builder + var sawArgsDone, sawItemDone bool + for _, e := range events { + switch e.Type { + case "response.function_call_arguments.delta": + _, _ = argsDelta.WriteString(e.Delta) + case "response.function_call_arguments.done": + sawArgsDone = true + require.Equal(t, `{"cmd":"ls"}`, e.Arguments) + case "response.output_item.done": + if e.Item != nil && e.Item.Type == "function_call" { + sawItemDone = true + require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments) + } + } + } + require.True(t, sawArgsDone, "function_call_arguments.done missing") + require.True(t, sawItemDone, "function_call output_item.done missing") + // Accumulated deltas must equal the final arguments exactly (no duplication). + require.Equal(t, `{"cmd":"ls"}`, argsDelta.String()) +} + // TestStream_SSEWireComplete drives the full stream through SSE encoding and // asserts the function_call events carry complete fields on the wire. func TestStream_SSEWireComplete(t *testing.T) { diff --git a/backend/internal/repository/github_release_service_test.go b/backend/internal/repository/github_release_service_test.go index d375a1930..7948f80c0 100644 --- a/backend/internal/repository/github_release_service_test.go +++ b/backend/internal/repository/github_release_service_test.go @@ -29,6 +29,7 @@ type testTransport struct { func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Rewrite the URL to point to our test server testURL := t.testServerURL + req.URL.Path + // #nosec G704 -- testServerURL is created by httptest.Server in this test. newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body) if err != nil { return nil, err diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index c8841ee3a..5ecce49b5 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -37,14 +37,6 @@ const nonTransactionalMigrationSuffix = "_notx.sql" const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_dedup_key_index_notx.sql" const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key" -type migrationChecksumCompatibilityRule struct { - fileChecksum string - acceptedDBChecksum map[string]struct{} - acceptedChecksums map[string]struct{} -} - -var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibilityRule{} - // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 // // 该函数可以在每次应用启动时安全调用: @@ -136,10 +128,6 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error { if rowErr == nil { // 迁移已应用,验证校验和是否匹配 if existing != checksum { - // 兼容特定历史误改场景(仅白名单规则),其余仍保持严格不可变约束。 - if isMigrationChecksumCompatible(name, existing, checksum) { - continue - } // 校验和不匹配意味着迁移文件在应用后被修改,这是危险的。 // 正确的做法是创建新的迁移文件来进行变更。 return fmt.Errorf( @@ -257,35 +245,6 @@ func indexIsInvalid(ctx context.Context, db *sql.DB, indexName string) (bool, er return invalid, err } -func checksumSet(values ...string) map[string]struct{} { - out := make(map[string]struct{}, len(values)) - for _, value := range values { - out[value] = struct{}{} - } - return out -} - -func newMigrationChecksumCompatibilityRule(fileChecksum string, acceptedDBChecksums ...string) migrationChecksumCompatibilityRule { - return migrationChecksumCompatibilityRule{ - fileChecksum: fileChecksum, - acceptedDBChecksum: checksumSet(acceptedDBChecksums...), - acceptedChecksums: checksumSet(append([]string{fileChecksum}, acceptedDBChecksums...)...), - } -} - -func isMigrationChecksumCompatible(name, dbChecksum, fileChecksum string) bool { - rule, ok := migrationChecksumCompatibilityRules[name] - if !ok { - return false - } - _, dbOK := rule.acceptedChecksums[dbChecksum] - if !dbOK { - return false - } - _, fileOK := rule.acceptedChecksums[fileChecksum] - return fileOK -} - func validateMigrationExecutionMode(name, content string) (bool, error) { normalizedName := strings.ToLower(strings.TrimSpace(name)) upperContent := strings.ToUpper(content) diff --git a/backend/internal/repository/migrations_runner_checksum_test.go b/backend/internal/repository/migrations_runner_checksum_test.go deleted file mode 100644 index 1f32836fc..000000000 --- a/backend/internal/repository/migrations_runner_checksum_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package repository - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIsMigrationChecksumCompatible_InternalBaselineHasNoLegacyExceptions(t *testing.T) { - require.Empty(t, migrationChecksumCompatibilityRules) - require.False(t, isMigrationChecksumCompatible( - "001_internal_core_baseline.sql", - "db-checksum", - "file-checksum", - )) -} diff --git a/backend/internal/repository/simple_mode_default_groups_integration_test.go b/backend/internal/repository/simple_mode_default_groups_integration_test.go index 90417169a..0894bc9c7 100644 --- a/backend/internal/repository/simple_mode_default_groups_integration_test.go +++ b/backend/internal/repository/simple_mode_default_groups_integration_test.go @@ -43,18 +43,25 @@ func TestEnsureSimpleModeDefaultGroups_IgnoresSoftDeletedGroups(t *testing.T) { seedCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - // Create and then soft-delete an anthropic default group. - g, err := client.Group.Create(). - SetName(service.PlatformAnthropic + "-default"). - SetPlatform(service.PlatformAnthropic). - SetStatus(service.StatusActive). - SetRateMultiplier(1.0). - SetIsExclusive(false). - Save(seedCtx) + // Seed baseline may already provide this default group. Soft-delete the + // active default first, or create one if the test database starts empty. + deleted, err := client.Group.Delete(). + Where(group.NameEQ(service.PlatformAnthropic+"-default"), group.DeletedAtIsNil()). + Exec(seedCtx) require.NoError(t, err) + if deleted == 0 { + g, err := client.Group.Create(). + SetName(service.PlatformAnthropic + "-default"). + SetPlatform(service.PlatformAnthropic). + SetStatus(service.StatusActive). + SetRateMultiplier(1.0). + SetIsExclusive(false). + Save(seedCtx) + require.NoError(t, err) - _, err = client.Group.Delete().Where(group.IDEQ(g.ID)).Exec(seedCtx) - require.NoError(t, err) + _, err = client.Group.Delete().Where(group.IDEQ(g.ID)).Exec(seedCtx) + require.NoError(t, err) + } require.NoError(t, ensureSimpleModeDefaultGroups(seedCtx, client)) diff --git a/backend/internal/repository/usage_cleanup_repo.go b/backend/internal/repository/usage_cleanup_repo.go index 737914060..d80e37cc7 100644 --- a/backend/internal/repository/usage_cleanup_repo.go +++ b/backend/internal/repository/usage_cleanup_repo.go @@ -366,11 +366,9 @@ func buildUsageCleanupWhere(filters service.UsageCleanupFilters) (string, []any) condition, conditionArgs := buildRequestTypeFilterCondition(idx, *filters.RequestType) conditions = append(conditions, condition) args = append(args, conditionArgs...) - idx += len(conditionArgs) } else if filters.Stream != nil { conditions = append(conditions, fmt.Sprintf("stream = $%d", idx)) args = append(args, *filters.Stream) - idx++ } return strings.Join(conditions, " AND "), args } diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 635685ee6..8f94f54ae 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -377,7 +377,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -407,7 +408,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -816,7 +817,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -894,7 +896,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -943,7 +946,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1032,7 +1036,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1107,7 +1112,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1156,7 +1162,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1213,7 +1220,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared total_cost, actual_cost, rate_multiplier, - account_rate_multiplier, request_type, + account_rate_multiplier, + request_type, stream, openai_ws_mode, duration_ms, @@ -1243,7 +1251,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50 + $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1321,7 +1329,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { log.TotalCost, log.ActualCost, rateMultiplier, - log.AccountRateMultiplier, requestType, + log.AccountRateMultiplier, + requestType, log.Stream, log.OpenAIWSMode, duration, @@ -3489,6 +3498,8 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(actual_cost), 0) as total_actual_cost, COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost, @@ -3519,6 +3530,8 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us &stats.TotalInputTokens, &stats.TotalOutputTokens, &stats.TotalCacheTokens, + &stats.TotalCacheCreationTokens, + &stats.TotalCacheReadTokens, &stats.TotalCost, &stats.TotalActualCost, &totalAccountCost, diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index ed3050d89..34221b482 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -1369,17 +1369,17 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters() { endTime := base.Add(48 * time.Hour) // Test with user filter - trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, 0, 0, 0, "", nil, nil, nil) + trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, 0, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters user filter") s.Require().Len(trend, 2) // Test with apiKey filter - trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", 0, apiKey.ID, 0, 0, "", nil, nil, nil) + trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", 0, apiKey.ID, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters apiKey filter") s.Require().Len(trend, 2) // Test with both filters - trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, apiKey.ID, 0, 0, "", nil, nil, nil) + trend, err = s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "day", user.ID, apiKey.ID, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters both filters") s.Require().Len(trend, 2) } @@ -1396,7 +1396,7 @@ func (s *UsageLogRepoSuite) TestGetUsageTrendWithFilters_HourlyGranularity() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(3 * time.Hour) - trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "hour", user.ID, 0, 0, 0, "", nil, nil, nil) + trend, err := s.repo.GetUsageTrendWithFilters(s.ctx, startTime, endTime, "hour", user.ID, 0, 0, 0, "", nil, nil) s.Require().NoError(err, "GetUsageTrendWithFilters hourly") s.Require().Len(trend, 2) } @@ -1442,17 +1442,17 @@ func (s *UsageLogRepoSuite) TestGetModelStatsWithFilters() { endTime := base.Add(2 * time.Hour) // Test with user filter - stats, err := s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, user.ID, 0, 0, 0, nil, nil, nil) + stats, err := s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, user.ID, 0, 0, 0, nil, nil) s.Require().NoError(err, "GetModelStatsWithFilters user filter") s.Require().Len(stats, 2) // Test with apiKey filter - stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, apiKey.ID, 0, 0, nil, nil, nil) + stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, apiKey.ID, 0, 0, nil, nil) s.Require().NoError(err, "GetModelStatsWithFilters apiKey filter") s.Require().Len(stats, 2) // Test with account filter - stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, 0, account.ID, 0, nil, nil, nil) + stats, err = s.repo.GetModelStatsWithFilters(s.ctx, startTime, endTime, 0, 0, account.ID, 0, nil, nil) s.Require().NoError(err, "GetModelStatsWithFilters account filter") s.Require().Len(stats, 2) } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 793dee217..65e2b03ea 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -389,11 +389,13 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T) "total_input_tokens", "total_output_tokens", "total_cache_tokens", + "total_cache_creation_tokens", + "total_cache_read_tokens", "total_cost", "total_actual_cost", "total_account_cost", "avg_duration_ms", - }).AddRow(int64(1), int64(2), int64(3), int64(4), 1.2, 1.0, 1.2, 20.0)) + }).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) @@ -481,9 +483,10 @@ func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testin mock.ExpectQuery("FROM usage_logs"). WillReturnRows(sqlmock.NewRows([]string{ "total_requests", "total_input_tokens", "total_output_tokens", - "total_cache_tokens", "total_cost", "total_actual_cost", + "total_cache_tokens", "total_cache_creation_tokens", "total_cache_read_tokens", + "total_cost", "total_actual_cost", "total_account_cost", "avg_duration_ms", - }).AddRow(int64(50), int64(1000), int64(2000), int64(100), 15.0, 12.5, 11.0, 100.0)) + }).AddRow(int64(50), int64(1000), int64(2000), int64(100), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0)) mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\)"). WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\)"). @@ -581,7 +584,7 @@ func (s usageLogScannerStub) Scan(dest ...any) error { } for i := range dest { dv := reflect.ValueOf(dest[i]) - if dv.Kind() != reflect.Ptr { + if dv.Kind() != reflect.Pointer { return fmt.Errorf("dest[%d] is not pointer", i) } dv.Elem().Set(reflect.ValueOf(s.values[i])) diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go index 09ac2aee1..44052d07c 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -29,6 +29,7 @@ func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { _, err := repo.Create(ctx, &service.UsageLog{ UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID, Model: "claude-3", InputTokens: 2, OutputTokens: 3, + CacheCreationTokens: 4, CacheReadTokens: 5, TotalCost: 0.5, ActualCost: 0.4, CreatedAt: now, InboundEndpoint: &inboundEndpoint, UpstreamEndpoint: &upstreamEndpoint, }) @@ -44,6 +45,9 @@ func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { require.Equal(t, int64(3), stats.TotalRequests) require.Equal(t, int64(6), stats.TotalInputTokens) require.Equal(t, int64(9), stats.TotalOutputTokens) + require.Equal(t, int64(27), stats.TotalCacheTokens) + require.Equal(t, int64(12), stats.TotalCacheCreationTokens) + require.Equal(t, int64(15), stats.TotalCacheReadTokens) require.InDelta(t, 1.2, stats.TotalActualCost, 1e-9) require.NotEmpty(t, stats.Endpoints) require.NotEmpty(t, stats.UpstreamEndpoints) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index ad30dc3f0..b86bb0e4a 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -928,11 +928,6 @@ func userSignupSourceOrDefault(signupSource string) string { } } -// marshalExtraEmails serializes notify email entries to JSON for storage. -func marshalExtraEmails(entries []service.NotifyEmailEntry) string { - return service.MarshalNotifyEmails(entries) -} - // UpdateTotpSecret 更新用户的 TOTP 加密密钥 func (r *userRepository) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { client := clientFromContext(ctx, r.client) diff --git a/backend/internal/repository/user_repo_sort_integration_test.go b/backend/internal/repository/user_repo_sort_integration_test.go index 05f10641f..9125a767a 100644 --- a/backend/internal/repository/user_repo_sort_integration_test.go +++ b/backend/internal/repository/user_repo_sort_integration_test.go @@ -18,11 +18,12 @@ func (s *UserRepoSuite) mustInsertUsageLog(userID int64, createdAt time.Time) { _, err := integrationDB.ExecContext( s.ctx, - `INSERT INTO usage_logs (user_id, api_key_id, account_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at) - VALUES ($1, $2, $3, 'gpt-test', 1, 1, 0.01, 0.01, $4)`, + `INSERT INTO usage_logs (user_id, api_key_id, account_id, request_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at) + VALUES ($1, $2, $3, $4, 'gpt-test', 1, 1, 0.01, 0.01, $5)`, userID, apiKey.ID, account.ID, + "user-sort-"+createdAt.UTC().Format(time.RFC3339Nano), createdAt.UTC(), ) s.Require().NoError(err) diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index 701630be3..657a4a829 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -331,8 +331,7 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Contains(t, enhanced, "'nonce-existing'") }) - } - +} func TestAddToDirective(t *testing.T) { t.Run("adds_to_existing_directive", func(t *testing.T) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 94b4344be..a15c2f639 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -68,6 +68,9 @@ func RegisterAdminRoutes( // 错误透传规则管理 registerErrorPassthroughRoutes(admin, h) + // 风控管理 + registerContentModerationRoutes(admin, h) + // TLS 指纹模板管理 registerTLSFingerprintProfileRoutes(admin, h) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 7bdda2e50..aa4cab22d 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -90,6 +90,10 @@ const ( antigravityFallbackSecondsEnv = "GATEWAY_ANTIGRAVITY_FALLBACK_COOLDOWN_SECONDS" ) +const antigravityProjectIDFallbackCredentialKey = "antigravity_project_id" + +var errAntigravityProjectIDRequired = errors.New("该 standard-tier Antigravity 账号需配置 project_id") + // AntigravityAccountSwitchError 账号切换信号 // 当账号限流时间超过阈值时,通知上层切换账号 type AntigravityAccountSwitchError struct { @@ -1029,6 +1033,22 @@ func (s *AntigravityGatewayService) getMappedModel(account *Account, requestedMo return mapAntigravityModel(account, requestedModel) } +func resolveAntigravityProjectID(account *Account) (string, error) { + if account == nil { + return "", errAntigravityProjectIDRequired + } + if projectID := strings.TrimSpace(account.GetCredential("project_id")); projectID != "" { + return projectID, nil + } + if projectID := strings.TrimSpace(account.GetCredential(antigravityProjectIDFallbackCredentialKey)); projectID != "" { + return projectID, nil + } + if projectID := strings.TrimSpace(account.GetExtraString(antigravityProjectIDFallbackCredentialKey)); projectID != "" { + return projectID, nil + } + return "", errAntigravityProjectIDRequired +} + // applyThinkingModelSuffix 根据 thinking 配置调整模型名 // 当映射结果是 claude-sonnet-4-5 且请求开启了 thinking 时,改为 claude-sonnet-4-5-thinking func applyThinkingModelSuffix(mappedModel string, thinkingEnabled bool) string { @@ -1068,8 +1088,10 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account return nil, fmt.Errorf("获取 access_token 失败: %w", err) } - // 获取 project_id(部分账户类型可能没有) - projectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + return nil, err + } // 模型映射 mappedModel := s.getMappedModel(account, modelID) @@ -1326,6 +1348,10 @@ func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model strin if err := json.Unmarshal(originalBody, &request); err != nil { return nil, fmt.Errorf("解析请求体失败: %w", err) } + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return nil, errAntigravityProjectIDRequired + } wrapped := map[string]any{ "project": projectID, @@ -1403,8 +1429,11 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } } - // 获取 project_id(部分账户类型可能没有) - projectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + _ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } // 代理 URL proxyURL := "" @@ -2171,8 +2200,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co } } - // 获取 project_id(部分账户类型可能没有) - projectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, err := resolveAntigravityProjectID(account) + if err != nil { + _ = s.writeGoogleError(c, http.StatusBadRequest, err.Error()) + return nil, err + } // 代理 URL proxyURL := "" diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 0fac7a1eb..02c2d85e3 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -185,6 +185,21 @@ func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, a return s.Do(req, proxyURL, accountID, concurrency) } +type recordingInternal500CounterCache struct { + incrementCalls []int64 + resetCalls []int64 +} + +func (c *recordingInternal500CounterCache) IncrementInternal500Count(_ context.Context, accountID int64) (int64, error) { + c.incrementCalls = append(c.incrementCalls, accountID) + return int64(len(c.incrementCalls)), nil +} + +func (c *recordingInternal500CounterCache) ResetInternal500Count(_ context.Context, accountID int64) error { + c.resetCalls = append(c.resetCalls, accountID) + return nil +} + type antigravitySettingRepoStub struct{} func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -215,6 +230,157 @@ func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) err panic("unexpected Delete call") } +func TestResolveAntigravityProjectID(t *testing.T) { + tests := []struct { + name string + account *Account + want string + wantErr bool + }{ + { + name: "uses onboard project_id first", + account: &Account{Credentials: map[string]any{ + "project_id": " onboard-project ", + antigravityProjectIDFallbackCredentialKey: " configured-project ", + }}, + want: "onboard-project", + }, + { + name: "uses configured credentials fallback", + account: &Account{Credentials: map[string]any{ + antigravityProjectIDFallbackCredentialKey: " configured-project ", + }}, + want: "configured-project", + }, + { + name: "uses configured extra fallback", + account: &Account{Extra: map[string]any{ + antigravityProjectIDFallbackCredentialKey: " extra-project ", + }}, + want: "extra-project", + }, + { + name: "missing project", + account: &Account{Credentials: map[string]any{}}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveAntigravityProjectID(tc.account) + if tc.wantErr { + require.ErrorIs(t, err, errAntigravityProjectIDRequired) + require.Empty(t, got) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestAntigravityGatewayService_ForwardGemini_UsesConfiguredProjectFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + + body, err := json.Marshal(map[string]any{ + "contents": []map[string]any{ + {"role": "user", "parts": []map[string]any{{"text": "hello"}}}, + }, + }) + require.NoError(t, err) + c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body)) + + upstreamBody := []byte("data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1}}}\n\n") + upstream := &queuedHTTPUpstreamStub{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(bytes.NewReader(upstreamBody)), + }, + }, + } + svc := &AntigravityGatewayService{ + settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + + account := &Account{ + ID: 101, + Name: "acc-configured-project", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "token", + antigravityProjectIDFallbackCredentialKey: "configured-project", + "model_mapping": map[string]any{ + "gemini-2.5-flash": "gemini-2.5-flash", + }, + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requestBodies, 1) + + var wrapped map[string]any + require.NoError(t, json.Unmarshal(upstream.requestBodies[0], &wrapped)) + require.Equal(t, "configured-project", wrapped["project"]) +} + +func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + + body, err := json.Marshal(map[string]any{ + "contents": []map[string]any{ + {"role": "user", "parts": []map[string]any{{"text": "hello"}}}, + }, + }) + require.NoError(t, err) + c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(body)) + + upstream := &queuedHTTPUpstreamStub{} + internal500Cache := &recordingInternal500CounterCache{} + svc := &AntigravityGatewayService{ + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + internal500Cache: internal500Cache, + } + + account := &Account{ + ID: 102, + Name: "acc-missing-project", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "token", + "model_mapping": map[string]any{ + "gemini-2.5-flash": "gemini-2.5-flash", + }, + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, body, false) + require.Nil(t, result) + require.ErrorIs(t, err, errAntigravityProjectIDRequired) + require.Equal(t, http.StatusBadRequest, writer.Code) + require.Empty(t, upstream.requestBodies) + require.Empty(t, internal500Cache.incrementCalls) + require.Contains(t, writer.Body.String(), "project_id") + require.NotContains(t, writer.Body.String(), `"project":""`) +} + func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) { gin.SetMode(gin.TestMode) writer := httptest.NewRecorder() @@ -255,6 +421,7 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) { Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, } @@ -313,6 +480,7 @@ func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *tes Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -369,6 +537,7 @@ func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover( Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -423,6 +592,7 @@ func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *tes Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -478,6 +648,7 @@ func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling( Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", }, Extra: map[string]any{ modelRateLimitsKey: map[string]any{ @@ -623,6 +794,7 @@ func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) { Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ "claude-sonnet-4-5": mappedModel, }, @@ -676,6 +848,7 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ "gemini-2.5-flash": mappedModel, }, @@ -747,6 +920,7 @@ func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignatur Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ originalModel: mappedModel, }, @@ -805,6 +979,7 @@ func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailove Concurrency: 1, Credentials: map[string]any{ "access_token": "token", + "project_id": "proj", "model_mapping": map[string]any{ originalModel: mappedModel, }, diff --git a/backend/internal/service/auth_email_oauth_auto_test.go b/backend/internal/service/auth_email_oauth_auto_test.go index 9f414644d..65e5d0834 100644 --- a/backend/internal/service/auth_email_oauth_auto_test.go +++ b/backend/internal/service/auth_email_oauth_auto_test.go @@ -49,7 +49,7 @@ func TestEmailOAuthAuto_SnapshotsPlatformQuotaDefaults(t *testing.T) { svc := newEmailOAuthAutoAuthService( userRepo, map[string]string{ - SettingKey SettingKeyDefaultPlatformQuotas: `{"gemini": {"monthly": 100.0}}`, + SettingKeyDefaultPlatformQuotas: `{"gemini": {"monthly": 100.0}}`, }, quotaRepo, ) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index b7a7fc8de..668b44c1f 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -499,7 +499,7 @@ func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, s } if !existed { - if err := client.AuthIdentity.Create(). + if err = client.AuthIdentity.Create(). SetUserID(user.ID). SetProviderType("email"). SetProviderKey("email"). @@ -538,16 +538,6 @@ func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, s return identity, !existed } -func inferLegacySignupSource(email string) string { - normalized := strings.ToLower(strings.TrimSpace(email)) - switch { - case strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain): - return "oidc" - default: - return "email" - } -} - func (s *AuthService) validateAccountCreationEmailPolicy(ctx context.Context, email string) error { if s.settingService == nil { return nil diff --git a/backend/internal/service/auth_service_platform_quota_test.go b/backend/internal/service/auth_service_platform_quota_test.go index 9f1ebf5f6..b20bf8fcf 100644 --- a/backend/internal/service/auth_service_platform_quota_test.go +++ b/backend/internal/service/auth_service_platform_quota_test.go @@ -118,7 +118,7 @@ func TestSnapshotPlatformQuotaDefaults_NilRepoIsNoop(t *testing.T) { func TestResolveSignupGrantPlan_GlobalQuotaLoadedBeforeAuthSource(t *testing.T) { // 全局 quota JSON key(新格式) settings := map[string]string{ - SettingKey SettingKeyDefaultPlatformQuotas: `{ + SettingKeyDefaultPlatformQuotas: `{ "anthropic": {"daily": 10, "weekly": 50, "monthly": 200}, "openai": {"daily": 5, "weekly": 25, "monthly": 100}, "gemini": {"daily": 5, "weekly": 25, "monthly": 100}, @@ -143,7 +143,6 @@ func TestResolveSignupGrantPlan_GlobalQuotaLoadedBeforeAuthSource(t *testing.T) // !enabled 早退路径仍携带全局 quota(GetDefaultPlatformQuotas 在 ResolveAuthSourceGrantSettings 之前)。 func TestResolveSignupGrantPlan_DisabledAuthSourceStillCarriesGlobalQuota(t *testing.T) { settings := map[string]string{ - SettingKey // auth source 不配置(=> !enabled 路径) SettingKeyDefaultPlatformQuotas: `{"anthropic": {"daily": 10, "weekly": 50, "monthly": 200}}`, } svc := newAuthService(nil, settings, nil, nil) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index de7f34930..62f8dc112 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -10430,18 +10430,21 @@ func (s *GatewayService) initDebugGatewayBodyFile(path string) { } // 如果 path 指向一个已存在的目录,自动追加默认文件名 + // #nosec G703 -- path comes from operator-controlled debug configuration. if info, err := os.Stat(path); err == nil && info.IsDir() { path = filepath.Join(path, debugGatewayBodyDefaultFilename) } // 确保父目录存在 if dir := filepath.Dir(path); dir != "." { + // #nosec G703 -- dir is derived from operator-controlled debug configuration. if err := os.MkdirAll(dir, 0755); err != nil { slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err) return } } + // #nosec G703 -- path comes from operator-controlled debug configuration. f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { slog.Error("failed to open gateway debug log file", "path", path, "error", err) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a1051afe5..50135dd0c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,6 +61,19 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + restrictionResult := s.detectCodexClientRestriction(c, account) + logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) + if restrictionResult.Enabled && !restrictionResult.Matched { + MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "forbidden_error", + "message": "This account only allows Codex official clients", + }, + }) + return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed") + } + // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { @@ -172,7 +185,12 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) } - codexResult := applyCodexOAuthTransform(reqBody, false, false) + codexResult := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + SkipDefaultInstructions: !isResponsesShape, + }) + if !isResponsesShape { + ensureCodexOAuthInstructionsField(reqBody) + } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } @@ -241,18 +259,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") - return nil, fmt.Errorf("upstream request failed: %s", safeErr) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index d03c7b663..eef980128 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -171,18 +171,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) if err != nil { - safeErr := sanitizeUpstreamErrorMessage(err.Error()) - setOpsUpstreamError(c, 0, safeErr, "") - appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ - Platform: account.Platform, - AccountID: account.ID, - AccountName: account.Name, - UpstreamStatusCode: 0, - Kind: "request_error", - Message: safeErr, - }) - writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") - return nil, fmt.Errorf("upstream request failed: %s", safeErr) + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } defer func() { _ = resp.Body.Close() }() diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 071308a57..b9933e757 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -181,6 +181,47 @@ func TestForwardAsChatCompletions_APIKeyPropagatesPromptCacheKeyInResponsesBody( require.Equal(t, generateSessionUUID(isolateOpenAISessionID(99, "cache-key-123")), upstream.lastReq.Header.Get("session_id")) } +func TestForwardAsChatCompletions_OAuthDoesNotInjectDefaultInstructions(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_no_default_instructions"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 3, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4") + require.Error(t, err) + require.Nil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) + require.Equal(t, "", gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.NotContains(t, string(upstream.lastBody), "Communicate with the user by streaming thinking") +} + func TestForwardAsChatCompletions_ClientDisconnectDrainsUpstreamUsage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0d4cadab9..2b1b26173 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1136,6 +1136,21 @@ func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg strin } func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { + if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + return false + } + + hasOpenAIServerOverloadedCode := func(payload []byte) bool { + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + } + return code == "server_is_overloaded" || code == "slow_down" + } + + if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { + return true + } if upstreamStatusCode != http.StatusBadRequest { return false } diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 10d586540..e245e89c0 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -228,6 +228,18 @@ func TestIsOpenAITransientProcessingError(t *testing.T) { []byte(`{"error":{"message":"Selected model is at capacity. Please try a different model.","type":"invalid_request_error"}}`), )) + require.True(t, isOpenAITransientProcessingError( + http.StatusBadRequest, + "", + []byte(`{"error":{"code":"server_is_overloaded","message":"Please retry later.","type":"invalid_request_error"}}`), + )) + + require.True(t, isOpenAITransientProcessingError( + http.StatusServiceUnavailable, + "", + []byte(`{"error":{"code":"slow_down","message":"Please retry later."}}`), + )) + require.True(t, isOpenAITransientProcessingError( http.StatusBadRequest, "", diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 51c528cb8..66cfddea6 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1330,6 +1330,44 @@ func TestOpenAIStreamingResponseFailedBeforeOutputCapacityErrorReturnsFailover(t require.Empty(t, rec.Body.String()) } +func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1"}}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_1","error":{"code":"server_is_overloaded","message":"Please retry later."}}}`, + "", + }, "\n"))), + Header: http.Header{"X-Request-Id": []string{"rid-overloaded-failed"}}, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "model", "model") + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Contains(t, string(failoverErr.ResponseBody), "Please retry later") + require.False(t, c.Writer.Written()) + require.Empty(t, rec.Body.String()) +} + func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{ diff --git a/backend/internal/service/openai_images_incomplete_test.go b/backend/internal/service/openai_images_incomplete_test.go index 60e441286..6a8ba5622 100644 --- a/backend/internal/service/openai_images_incomplete_test.go +++ b/backend/internal/service/openai_images_incomplete_test.go @@ -121,3 +121,50 @@ func TestImagesOAuthNonStreaming_CompletedNoImageTriggersSameAccountRetry(t *tes t.Fatal("soft-failure should prefer same-account retry (probabilistic upstream failure)") } } + +// 内容审核拒绝(模型未出图但输出文字拒绝)应返回 400 content_policy 错误且不重试, +// 而非可重试的 UpstreamFailoverError。 +func TestImagesOAuthNonStreaming_ContentRefusalReturns400NoRetry(t *testing.T) { + // 上游:completed 无图,但模型输出了文字拒绝(内容审核场景)。 + upstreamSSE := "event: response.created\n" + + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\",\"status\":\"in_progress\",\"model\":\"gpt-5.4-mini\",\"output\":[]}}\n\n" + + "event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"抱歉,这个请求因涉及违规内容被安全系统判定为不适合生成。\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"model\":\"gpt-5.4-mini\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"抱歉,这个请求因涉及违规内容被安全系统判定为不适合生成。\"}]}],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(upstreamSSE))} + + svc := &OpenAIGatewayService{} + _, _, _, err := svc.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, "b64_json", "gpt-image-2") + + if err == nil { + t.Fatal("content refusal should return an error") + } + // 应是不可重试的内容策略错误(400),而非 UpstreamFailoverError。 + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) { + t.Fatalf("content refusal must NOT be a retryable failover error, got %v", failoverErr) + } + var imgErr *OpenAIImagesUpstreamError + if !errors.As(err, &imgErr) { + t.Fatalf("expected *OpenAIImagesUpstreamError, got %T: %v", err, err) + } + if imgErr.StatusCode != http.StatusBadRequest { + t.Fatalf("content refusal should be 400, got %d", imgErr.StatusCode) + } + if !strings.Contains(imgErr.Message, "安全系统") && !strings.Contains(imgErr.Message, "违规") { + t.Fatalf("refusal message should carry model's reason, got %q", imgErr.Message) + } +} + +// extractOpenAIImagesModelRefusal:真空响应(无文字)返回空串。 +func TestExtractModelRefusal_EmptyWhenNoText(t *testing.T) { + body := "data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"tool_usage\":{\"image_gen\":{\"output_tokens\":0}}}}\n\n" + if r := extractOpenAIImagesModelRefusal([]byte(body)); r != "" { + t.Fatalf("empty response should yield no refusal, got %q", r) + } +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index c98e8008c..226cbf8e8 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -607,6 +607,62 @@ func openAIImagesUpstreamErrorFromSSEPayload(payload []byte) *OpenAIImagesUpstre } } +// extractOpenAIImagesModelRefusal 从上游 SSE 响应体提取「模型未出图、改用文字拒绝」 +// 的拒绝文本(内容审核场景)。 +// +// 上游 response.completed 无图时,模型常以 output_text / message 形式输出拒绝说明 +// (如“被安全系统判定为不适合生成”)。这类失败是内容策略拦截,重试/换账号均无效, +// 应把该文本作为内容策略错误透传给客户端。返回空串表示无文字输出(真空响应)。 +func extractOpenAIImagesModelRefusal(body []byte) string { + var b strings.Builder + collect := func(s string) { + if s = strings.TrimSpace(s); s != "" { + if b.Len() > 0 { + _ = b.WriteByte(' ') + } + _, _ = b.WriteString(s) + } + } + forEachOpenAISSEDataPayload(string(body), func(payload []byte) { + if !gjson.ValidBytes(payload) { + return + } + switch gjson.GetBytes(payload, "type").String() { + case "response.output_text.delta": + // 流式文本增量。 + collect(gjson.GetBytes(payload, "delta").String()) + case "response.completed", "response.output_item.done": + // 终态里的 message/output_text。 + gjson.GetBytes(payload, "response.output").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + collect(part.Get("text").String()) + } + return true + }) + } + return true + }) + if item := gjson.GetBytes(payload, "item"); item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + collect(part.Get("text").String()) + } + return true + }) + } + } + }) + refusal := strings.TrimSpace(b.String()) + // 截断过长文本,避免把整段模型输出塞进错误响应。 + const maxRefusal = 600 + if len(refusal) > maxRefusal { + refusal = refusal[:maxRefusal] + } + return refusal +} + // summarizeOpenAIImagesNoOutputBody 从上游 SSE 响应体提取诊断摘要,用于软失败时 // 记录到 ops 日志(上游无图、无标准错误的场景)。提取最终事件类型、response.status、 // incomplete_details.reason,并附 body 截断片段,便于事后定位上游到底返回了什么。 @@ -1037,14 +1093,31 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( } return OpenAIUsage{}, 0, nil, upstreamErr } - // 软失败兜底:上游既无图、又无任何可识别的 error/failed/incomplete 事件 - // (实测:上游偶发把请求路由到 gpt-5.x-mini,返回 response.completed 但 output 为空、 - // image_gen 工具未执行)。这是上游的概率性失败——同账号有时成功有时失败。 - // 处理:① 记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于排查; - // ② 返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 - // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图), - // 用尽后由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率 - // 又不无谓消耗其他账号配额。 + // 软失败兜底:上游无图。先区分两种情形(实测真因,见下): + // + // (A) 内容审核拒绝:模型未出图,但输出了文字拒绝(response.completed 里带 + // output_text / message,内容如“被安全系统判定为不适合生成”)。这是用户 + // prompt 触发 OpenAI 内容策略,模型主动拒绝改用文字回应。**换账号/重试均无效** + // (内容层拦截,与账号/承载模型无关),应把拒绝理由作为 400 透传给客户端, + // 避免无谓地重试 + 消耗其它账号配额,且让客户端拿到可读的拒绝原因。 + // (B) 真空响应:既无图也无任何文字输出(罕见,如偶发路由到 gpt-5.x-mini、 + // image_gen 工具未执行)。这是上游的概率性失败,此时才按可重试处理。 + if refusal := extractOpenAIImagesModelRefusal(body); refusal != "" { + refusalErr := &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadRequest, + ErrorType: "image_generation_user_error", + Code: "content_policy_violation", + Message: sanitizeUpstreamErrorMessage(refusal), + } + setOpsUpstreamError(c, http.StatusBadRequest, refusalErr.clientMessage(), summarizeOpenAIImagesNoOutputBody(body)) + writeOpenAIImagesUpstreamErrorResponse(c, refusalErr) + return OpenAIUsage{}, 0, nil, refusalErr + } + // (B) 真空响应:记录上游诊断摘要到 ops(last_event/status/model/body 片段)便于 + // 排查,并返回 UpstreamFailoverError 触发重试。因实测为「同账号概率性失败」,优先 + // RetryableOnSameAccount 同账号快速重试(默认 3 次,大概率某次正常出图),用尽后 + // 由 handler 自然换账号 failover(switchCount 上限保护),既提高成功率又不无谓 + // 消耗其它账号配额。 setOpsUpstreamError(c, http.StatusBadGateway, "upstream did not return image output", summarizeOpenAIImagesNoOutputBody(body)) return OpenAIUsage{}, 0, nil, &UpstreamFailoverError{ StatusCode: http.StatusBadGateway, diff --git a/backend/internal/service/openai_upstream_transport_error_handle_test.go b/backend/internal/service/openai_upstream_transport_error_handle_test.go index 35bd56a19..3ea8b4318 100644 --- a/backend/internal/service/openai_upstream_transport_error_handle_test.go +++ b/backend/internal/service/openai_upstream_transport_error_handle_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -37,6 +39,21 @@ func newOpenAITransportErrTestContext() (*gin.Context, *httptest.ResponseRecorde return c, rec } +type failingOpenAIHTTPUpstream struct { + err error + calls int +} + +func (u *failingOpenAIHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.calls++ + return nil, u.err +} + +func (u *failingOpenAIHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + u.calls++ + return nil, u.err +} + // A durable proxy/credential failure must (a) temporarily unschedule the account // so it stops being hammered, and (b) return a failover error so the handler // switches to a healthy account instead of writing a hard 502 itself. @@ -159,3 +176,37 @@ func TestHandleOpenAIUpstreamTransportError_DeadlineExceeded_StillFailsOver(t *t var fo *UpstreamFailoverError require.True(t, errors.As(err, &fo), "context.DeadlineExceeded must still return *UpstreamFailoverError") } + +func TestForwardAsRawChatCompletions_TransportErrorFailsOver(t *testing.T) { + repo := &openaiTransportAccountRepoStub{} + upstream := &failingOpenAIHTTPUpstream{ + err: errors.New(`Post "https://opencode.ai/zen/v1/chat/completions": EOF`), + } + svc := &OpenAIGatewayService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + } + account := &Account{ + ID: 81, + Name: "oc-20053", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://opencode.ai/zen/v1"}, + } + c, rec := newOpenAITransportErrTestContext() + body := []byte(`{"model":"deepseek-v4-flash-free","messages":[{"role":"user","content":"hello"}]}`) + + _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + + require.Equal(t, 1, upstream.calls) + var fo *UpstreamFailoverError + require.True(t, errors.As(err, &fo), "transport error must trigger account failover") + require.Equal(t, http.StatusBadGateway, fo.StatusCode) + require.Empty(t, repo.tempUnschedCalls, "plain EOF is transient: fail over but do not evict") + require.Equal(t, 0, rec.Body.Len(), "service must not write a hard 502 before handler can fail over") +} diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index d4c5b0f47..ac2198ca7 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -92,18 +92,18 @@ type OpsAlertRuntimeSettings struct { // OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation). type OpsAdvancedSettings struct { - DataRetention OpsDataRetentionSettings `json:"data_retention"` - Aggregation OpsAggregationSettings `json:"aggregation"` - OpenAIAccountQuotaAutoPause OpsOpenAIAccountQuotaAutoPauseSettings `json:"openai_account_quota_auto_pause"` - IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"` - IgnoreContextCanceled bool `json:"ignore_context_canceled"` - IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"` - IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"` - IgnoreUpstreamCreditErrors bool `json:"ignore_upstream_credit_errors"` - DisplayOpenAITokenStats bool `json:"display_openai_token_stats"` - DisplayAlertEvents bool `json:"display_alert_events"` - AutoRefreshEnabled bool `json:"auto_refresh_enabled"` - AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` + DataRetention OpsDataRetentionSettings `json:"data_retention"` + Aggregation OpsAggregationSettings `json:"aggregation"` + OpenAIAccountQuotaAutoPause OpsOpenAIAccountQuotaAutoPauseSettings `json:"openai_account_quota_auto_pause"` + IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"` + IgnoreContextCanceled bool `json:"ignore_context_canceled"` + IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"` + IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"` + IgnoreUpstreamCreditErrors bool `json:"ignore_upstream_credit_errors"` + DisplayOpenAITokenStats bool `json:"display_openai_token_stats"` + DisplayAlertEvents bool `json:"display_alert_events"` + AutoRefreshEnabled bool `json:"auto_refresh_enabled"` + AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` } type OpsOpenAIAccountQuotaAutoPauseSettings struct { diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index bd0c30df4..ad6abfa7e 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -479,6 +479,7 @@ func (s *PricingService) useFallbackPricing() error { } pricingFile := s.getPricingFilePath() + // #nosec G703 -- pricingFile is derived from operator-controlled service configuration. if err := os.WriteFile(pricingFile, data, 0644); err != nil { logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err) } diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 5d43ce46c..fa47e0f90 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -102,7 +102,7 @@ func TestSettingService_UpdateSettings_AccountCreationEmailSuffixWhitelist_Inval AccountCreationEmailSuffixWhitelist: []string{"@invalid_domain"}, }) require.Error(t, err) - require.Equal(t, "INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err)) + require.Equal(t, "INVALID_ACCOUNT_CREATION_EMAIL_SUFFIX_WHITELIST", infraerrors.Reason(err)) } func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a9be8a84d..a900e02d7 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -1,16 +1,5 @@ package service -import "strings" - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - type SystemSettings struct { EmailVerifyEnabled bool AccountCreationEmailSuffixWhitelist []string diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index 13bdae326..96f5f010d 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -432,14 +432,15 @@ func isNonRetryableRefreshError(err error) bool { } msg := strings.ToLower(err.Error()) nonRetryable := []string{ - "invalid_grant", // refresh_token 已失效 - "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 - "app_session_terminated", // refresh_token team 账号工作区被删除 - "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 - "invalid_client", // 客户端配置错误 - "unauthorized_client", // 客户端未授权 - "access_denied", // 访问被拒绝 - "missing_project_id", // 缺少 project_id + "invalid_grant", // refresh_token 已失效 + "invalid_refresh_token", // refresh_token 无效, team 账号工作区被删除会出现 + "app_session_terminated", // refresh_token team 账号工作区被删除 + "refresh_token_reused", // OpenAI refresh_token 已被使用,必须重新授权 + "refresh_token_invalidated", // OpenAI session ended; refresh token invalidated + "invalid_client", // 客户端配置错误 + "unauthorized_client", // 客户端未授权 + "access_denied", // 访问被拒绝 + "missing_project_id", // 缺少 project_id "no refresh token available", } for _, needle := range nonRetryable { diff --git a/backend/migrations/001_internal_core_baseline.sql b/backend/migrations/001_internal_core_baseline.sql index cde962b4e..5e3bfbb22 100644 --- a/backend/migrations/001_internal_core_baseline.sql +++ b/backend/migrations/001_internal_core_baseline.sql @@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS groups ( claude_code_only BOOLEAN NOT NULL DEFAULT FALSE, fallback_group_id BIGINT, fallback_group_id_on_invalid_request BIGINT, - model_routing JSONB NOT NULL DEFAULT '{}'::jsonb, + model_routing JSONB DEFAULT '{}'::jsonb, model_routing_enabled BOOLEAN NOT NULL DEFAULT FALSE, mcp_xml_inject BOOLEAN NOT NULL DEFAULT TRUE, supported_model_scopes JSONB NOT NULL DEFAULT '["claude","gemini_text","gemini_image"]'::jsonb, @@ -77,6 +77,21 @@ CREATE INDEX IF NOT EXISTS groups_platform ON groups (platform); CREATE INDEX IF NOT EXISTS groups_is_exclusive ON groups (is_exclusive); CREATE INDEX IF NOT EXISTS groups_sort_order ON groups (sort_order); CREATE INDEX IF NOT EXISTS groups_deleted_at ON groups (deleted_at); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'anthropic-default', 'Auto-created default group', 'anthropic', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'anthropic-default' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'openai-default', 'Auto-created default group', 'openai', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'openai-default' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'gemini-default', 'Auto-created default group', 'gemini', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'gemini-default' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'antigravity-default-1', 'Auto-created default group', 'antigravity', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'antigravity-default-1' AND deleted_at IS NULL); +INSERT INTO groups (name, description, platform, rate_multiplier, is_exclusive, status) +SELECT 'antigravity-default-2', 'Auto-created default group', 'antigravity', 1, FALSE, 'active' +WHERE NOT EXISTS (SELECT 1 FROM groups WHERE name = 'antigravity-default-2' AND deleted_at IS NULL); CREATE TABLE IF NOT EXISTS proxies ( id BIGSERIAL PRIMARY KEY, @@ -170,8 +185,8 @@ CREATE TABLE IF NOT EXISTS api_keys ( group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', last_used_at TIMESTAMPTZ, - ip_whitelist JSONB NOT NULL DEFAULT '[]'::jsonb, - ip_blacklist JSONB NOT NULL DEFAULT '[]'::jsonb, + ip_whitelist JSONB DEFAULT '[]'::jsonb, + ip_blacklist JSONB DEFAULT '[]'::jsonb, quota NUMERIC(20,8) NOT NULL DEFAULT 0, quota_used NUMERIC(20,8) NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ, @@ -261,7 +276,7 @@ CREATE INDEX IF NOT EXISTS pending_auth_sessions_completion_code_hash ON pending CREATE TABLE IF NOT EXISTS identity_adoption_decisions ( id BIGSERIAL PRIMARY KEY, pending_auth_session_id BIGINT NOT NULL UNIQUE REFERENCES pending_auth_sessions(id) ON DELETE CASCADE, - identity_id BIGINT NOT NULL REFERENCES auth_identities(id) ON DELETE CASCADE, + identity_id BIGINT REFERENCES auth_identities(id) ON DELETE CASCADE, adopt_display_name BOOLEAN NOT NULL DEFAULT FALSE, adopt_avatar BOOLEAN NOT NULL DEFAULT FALSE, decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -332,14 +347,23 @@ CREATE INDEX IF NOT EXISTS user_attribute_values_value ON user_attribute_values CREATE TABLE IF NOT EXISTS user_platform_quotas ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - platform VARCHAR(50) NOT NULL, - used NUMERIC(20,8) NOT NULL DEFAULT 0, - limit_value NUMERIC(20,8) NOT NULL DEFAULT 0, - window_start TIMESTAMPTZ, + platform VARCHAR(32) NOT NULL, + daily_limit_usd NUMERIC(20,10), + weekly_limit_usd NUMERIC(20,10), + monthly_limit_usd NUMERIC(20,10), + daily_usage_usd NUMERIC(20,10) NOT NULL DEFAULT 0, + weekly_usage_usd NUMERIC(20,10) NOT NULL DEFAULT 0, + monthly_usage_usd NUMERIC(20,10) NOT NULL DEFAULT 0, + daily_window_start TIMESTAMPTZ, + weekly_window_start TIMESTAMPTZ, + monthly_window_start TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, platform) + deleted_at TIMESTAMPTZ ); +CREATE UNIQUE INDEX IF NOT EXISTS user_platform_quotas_user_id_platform_active_key + ON user_platform_quotas (user_id, platform) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS user_platform_quotas_user_id ON user_platform_quotas (user_id); CREATE INDEX IF NOT EXISTS user_platform_quotas_platform ON user_platform_quotas (platform); CREATE TABLE IF NOT EXISTS user_group_rate_multipliers ( @@ -358,7 +382,7 @@ CREATE TABLE IF NOT EXISTS usage_logs ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, api_key_id BIGINT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - request_id VARCHAR(255) NOT NULL, + request_id VARCHAR(255), model VARCHAR(100) NOT NULL, requested_model VARCHAR(100), upstream_model VARCHAR(100), @@ -396,7 +420,7 @@ CREATE TABLE IF NOT EXISTS usage_logs ( image_input_size VARCHAR(32), image_output_size VARCHAR(32), image_size_source VARCHAR(16), - image_size_breakdown JSONB NOT NULL DEFAULT '{}'::jsonb, + image_size_breakdown JSONB DEFAULT '{}'::jsonb, service_tier TEXT, reasoning_effort TEXT, inbound_endpoint TEXT, @@ -415,7 +439,7 @@ CREATE INDEX IF NOT EXISTS usage_logs_request_id ON usage_logs (request_id); CREATE INDEX IF NOT EXISTS usage_logs_user_created_at ON usage_logs (user_id, created_at); CREATE INDEX IF NOT EXISTS usage_logs_api_key_created_at ON usage_logs (api_key_id, created_at); CREATE INDEX IF NOT EXISTS usage_logs_group_created_at ON usage_logs (group_id, created_at) WHERE group_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_usage_logs_request_api_key ON usage_logs (request_id, api_key_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_logs_request_api_key ON usage_logs (request_id, api_key_id); CREATE INDEX IF NOT EXISTS idx_usage_logs_upstream_model ON usage_logs (upstream_model); CREATE INDEX IF NOT EXISTS idx_usage_logs_requested_model ON usage_logs (requested_model); CREATE INDEX IF NOT EXISTS idx_usage_logs_inbound_endpoint ON usage_logs (inbound_endpoint); @@ -453,7 +477,7 @@ CREATE TABLE IF NOT EXISTS scheduler_outbox ( CREATE INDEX IF NOT EXISTS idx_scheduler_outbox_created_at ON scheduler_outbox (created_at); CREATE UNIQUE INDEX IF NOT EXISTS idx_scheduler_outbox_pending_dedup_key ON scheduler_outbox (dedup_key) - WHERE dedup_key IS NOT NULL AND delivered_at IS NULL; + WHERE dedup_key IS NOT NULL; CREATE TABLE IF NOT EXISTS channels ( id BIGSERIAL PRIMARY KEY, @@ -713,10 +737,12 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( client_ip INET, platform VARCHAR(32), model VARCHAR(100), + requested_model VARCHAR(100), + upstream_model VARCHAR(100), request_path VARCHAR(256), inbound_endpoint TEXT, upstream_endpoint TEXT, - request_type SMALLINT NOT NULL DEFAULT 0, + request_type SMALLINT, stream BOOLEAN NOT NULL DEFAULT FALSE, openai_ws_mode BOOLEAN NOT NULL DEFAULT FALSE, user_agent TEXT, @@ -734,6 +760,7 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( upstream_status_code INT, upstream_error_message TEXT, upstream_error_detail TEXT, + upstream_errors JSONB, provider_error_code VARCHAR(64), provider_error_type VARCHAR(64), network_error_type VARCHAR(50), @@ -753,6 +780,10 @@ CREATE TABLE IF NOT EXISTS ops_error_logs ( attempted_key_prefix VARCHAR(32), deleted_key_owner_user_id BIGINT, deleted_key_name VARCHAR(100), + api_key_prefix VARCHAR(32), + resolved BOOLEAN NOT NULL DEFAULT FALSE, + resolved_at TIMESTAMPTZ, + resolved_by_user_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_ops_error_logs_created_at ON ops_error_logs (created_at DESC); @@ -764,6 +795,7 @@ CREATE INDEX IF NOT EXISTS idx_ops_error_logs_phase_time ON ops_error_logs (erro CREATE INDEX IF NOT EXISTS idx_ops_error_logs_type_time ON ops_error_logs (error_type, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ops_error_logs_request_id ON ops_error_logs (request_id); CREATE INDEX IF NOT EXISTS idx_ops_error_logs_client_request_id ON ops_error_logs (client_request_id); +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_resolved_time ON ops_error_logs (resolved, created_at DESC); CREATE TABLE IF NOT EXISTS ops_system_metrics ( id BIGSERIAL PRIMARY KEY, @@ -981,19 +1013,21 @@ CREATE INDEX IF NOT EXISTS deletedapikeyaudit_user_id ON deleted_api_key_audits CREATE TABLE IF NOT EXISTS idempotency_records ( id BIGSERIAL PRIMARY KEY, - scope VARCHAR(64) NOT NULL, - key VARCHAR(255) NOT NULL, - request_hash VARCHAR(64) NOT NULL, - status VARCHAR(20) NOT NULL, + scope VARCHAR(128) NOT NULL, + idempotency_key_hash VARCHAR(64) NOT NULL, + request_fingerprint VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, response_status INT, response_body TEXT, + error_reason VARCHAR(128), locked_until TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (scope, key) + UNIQUE (scope, idempotency_key_hash) ); CREATE INDEX IF NOT EXISTS idempotency_records_expires_at ON idempotency_records (expires_at); +CREATE INDEX IF NOT EXISTS idempotency_records_status_locked_until ON idempotency_records (status, locked_until); CREATE TABLE IF NOT EXISTS error_passthrough_rules ( id BIGSERIAL PRIMARY KEY, @@ -1041,18 +1075,29 @@ CREATE INDEX IF NOT EXISTS usage_cleanup_tasks_created_at ON usage_cleanup_tasks CREATE TABLE IF NOT EXISTS scheduled_test_plans ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, + account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + model_id VARCHAR(200) NOT NULL, + cron_expression VARCHAR(100) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, - config JSONB NOT NULL DEFAULT '{}'::jsonb, + max_results INT NOT NULL DEFAULT 50, + auto_recover BOOLEAN NOT NULL DEFAULT FALSE, + last_run_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS scheduled_test_plans_account_id ON scheduled_test_plans (account_id); +CREATE INDEX IF NOT EXISTS scheduled_test_plans_due ON scheduled_test_plans (enabled, next_run_at); CREATE TABLE IF NOT EXISTS scheduled_test_results ( id BIGSERIAL PRIMARY KEY, plan_id BIGINT REFERENCES scheduled_test_plans(id) ON DELETE CASCADE, status VARCHAR(20) NOT NULL, - result JSONB NOT NULL DEFAULT '{}'::jsonb, + response_text TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + latency_ms BIGINT NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS scheduled_test_results_plan_created ON scheduled_test_results (plan_id, created_at DESC); diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index cfff36163..1d9a778db 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -774,6 +774,18 @@ +
+ + +

{{ t('admin.accounts.antigravityProjectIdHint') }}

+
+
@@ -3238,7 +3250,10 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' -import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' +import { + applyAntigravityProjectID, + applyInterceptWarmup +} from '@/components/account/credentialsBuilder' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' @@ -3440,6 +3455,7 @@ loadQuotaNotifyGlobal() const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream +const antigravityProjectId = ref('') const upstreamBaseUrl = ref('') // For upstream type: base URL const upstreamApiKey = ref('') // For upstream type: API key const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') @@ -3813,6 +3829,7 @@ watch( antigravityAccountType.value = 'oauth' } else { allowOverages.value = false + antigravityProjectId.value = '' antigravityWhitelistModels.value = [] antigravityModelMappings.value = [] antigravityModelRestrictionMode.value = 'mapping' @@ -4271,6 +4288,7 @@ const resetForm = () => { customBaseUrl.value = '' allowOverages.value = false antigravityAccountType.value = 'oauth' + antigravityProjectId.value = '' upstreamBaseUrl.value = '' upstreamApiKey.value = '' vertexServiceAccountJson.value = '' @@ -5133,6 +5151,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { } const credentials = antigravityOAuth.buildCredentials(tokenInfo) + applyAntigravityProjectID(credentials, antigravityProjectId.value, 'create') // Generate account name with index for batch const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name @@ -5249,6 +5268,7 @@ const handleAntigravityExchange = async (authCode: string) => { if (!tokenInfo) return const credentials = antigravityOAuth.buildCredentials(tokenInfo) + applyAntigravityProjectID(credentials, antigravityProjectId.value, 'create') applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create') // Antigravity 只使用映射模式 const antigravityModelMapping = buildModelMappingObject( diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8dc85d0eb..cabcc0316 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1000,6 +1000,21 @@
+
+ + +

{{ t('admin.accounts.antigravityProjectIdHint') }}

+
+
@@ -2398,7 +2413,10 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' -import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' +import { + applyAntigravityProjectID, + applyInterceptWarmup +} from '@/components/account/credentialsBuilder' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' @@ -2531,6 +2549,7 @@ const autoPause5hDisabled = ref(false) const autoPause7dDisabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages +const antigravityProjectId = ref('') const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const antigravityWhitelistModels = ref([]) const antigravityModelMappings = ref([]) @@ -2940,6 +2959,12 @@ const syncFormFromAccount = (newAccount: Account | null) => { editVertexProjectId.value = '' editVertexClientEmail.value = '' editVertexLocation.value = 'us-central1' + antigravityProjectId.value = + newAccount.platform === 'antigravity' && + newAccount.type === 'oauth' && + typeof credentials?.antigravity_project_id === 'string' + ? credentials.antigravity_project_id.trim() + : '' // Load mixed scheduling setting (only for antigravity accounts) mixedScheduling.value = false @@ -3930,6 +3955,9 @@ const handleSubmit = async () => { const currentCredentials = (updatePayload.credentials as Record) || ((props.account.credentials as Record) || {}) const newCredentials: Record = { ...currentCredentials } + if (props.account.type === 'oauth') { + applyAntigravityProjectID(newCredentials, antigravityProjectId.value, 'edit') + } // 移除旧字段 delete newCredentials.model_whitelist diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index f4865de9e..77c333fb2 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -167,6 +167,31 @@ function buildVertexAccount() { } as any } +function buildAntigravityAccount(projectId = 'configured-project') { + return { + id: 3, + name: 'Antigravity OAuth', + notes: '', + platform: 'antigravity', + type: 'oauth', + credentials: { + antigravity_project_id: projectId, + model_mapping: { + 'gemini-2.5-flash': 'gemini-2.5-flash' + } + }, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + rate_multiplier: 1, + status: 'active', + group_ids: [], + expires_at: null, + auto_pause_on_expired: false + } as any +} + function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { @@ -579,4 +604,43 @@ describe('EditAccountModal', () => { expect(updateAccountMock).not.toHaveBeenCalled() }) + + it('loads and submits Antigravity configured project fallback', async () => { + const account = buildAntigravityAccount('configured-project') + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const input = wrapper.get('[data-testid="antigravity-project-id-input"]') + expect(input.element.value).toBe('configured-project') + + await input.setValue(' updated-project ') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.antigravity_project_id).toBe( + 'updated-project' + ) + }) + + it('clears Antigravity configured project fallback when input is empty', async () => { + const account = buildAntigravityAccount('configured-project') + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const input = wrapper.get('[data-testid="antigravity-project-id-input"]') + + await input.setValue('') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty( + 'antigravity_project_id' + ) + }) }) diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index be2a8d521..665b1732e 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { applyInterceptWarmup } from '../credentialsBuilder' +import { + ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY, + applyAntigravityProjectID, + applyInterceptWarmup +} from '../credentialsBuilder' describe('applyInterceptWarmup', () => { it('create + enabled=true: should set intercept_warmup_requests to true', () => { @@ -44,3 +48,37 @@ describe('applyInterceptWarmup', () => { expect('intercept_warmup_requests' in creds).toBe(false) }) }) + +describe('applyAntigravityProjectID', () => { + it('create + project id: trims and stores configured project fallback', () => { + const creds: Record = { access_token: 'tok' } + applyAntigravityProjectID(creds, ' configured-project ', 'create') + expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project') + }) + + it('create + empty project id: should not add the field', () => { + const creds: Record = { access_token: 'tok' } + applyAntigravityProjectID(creds, ' ', 'create') + expect(ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY in creds).toBe(false) + }) + + it('edit + empty project id: deletes existing fallback', () => { + const creds: Record = { + access_token: 'tok', + [ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]: 'old-project' + } + applyAntigravityProjectID(creds, '', 'edit') + expect(ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY in creds).toBe(false) + }) + + it('does not affect onboard project_id or other credentials', () => { + const creds: Record = { + project_id: 'onboard-project', + model_mapping: { 'gemini-*': 'gemini-2.5-flash' } + } + applyAntigravityProjectID(creds, 'configured-project', 'edit') + expect(creds.project_id).toBe('onboard-project') + expect(creds.model_mapping).toEqual({ 'gemini-*': 'gemini-2.5-flash' }) + expect(creds[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY]).toBe('configured-project') + }) +}) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index b8008e8bf..f13897651 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -9,3 +9,18 @@ export function applyInterceptWarmup( delete credentials.intercept_warmup_requests } } + +export const ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY = 'antigravity_project_id' + +export function applyAntigravityProjectID( + credentials: Record, + projectId: string, + mode: 'create' | 'edit' +): void { + const trimmed = projectId.trim() + if (trimmed) { + credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY] = trimmed + } else if (mode === 'edit') { + delete credentials[ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY] + } +} diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index be12e8fc2..6123a38a1 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -442,19 +442,22 @@ function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] { path = 'Terminal' content = `export ANTHROPIC_BASE_URL="${baseUrl}" export ANTHROPIC_AUTH_TOKEN="${apiKey}" -export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export CLAUDE_CODE_ATTRIBUTION_HEADER=0` break case 'cmd': path = 'Command Prompt' content = `set ANTHROPIC_BASE_URL=${baseUrl} set ANTHROPIC_AUTH_TOKEN=${apiKey} -set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` +set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +set CLAUDE_CODE_ATTRIBUTION_HEADER=0` break case 'powershell': path = 'PowerShell' content = `$env:ANTHROPIC_BASE_URL="${baseUrl}" $env:ANTHROPIC_AUTH_TOKEN="${apiKey}" -$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` +$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +$env:CLAUDE_CODE_ATTRIBUTION_HEADER=0` break default: path = 'Terminal' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 20196d472..5829377af 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2532,6 +2532,10 @@ export default { // Home Page upstream: 'Upstream', upstreamDesc: 'Connect via Base URL + API Key' }, + antigravityProjectIdLabel: 'GCP Project ID (optional)', + antigravityProjectIdPlaceholder: 'your-gcp-project-id', + antigravityProjectIdHint: + 'Antigravity standard-tier accounts that do not receive an automatic project_id need a user-owned GCP project.', status: { active: 'Active', inactive: 'Inactive', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8270eba8a..83325b859 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2724,6 +2724,10 @@ export default { // Home Page api_key: 'API Key', cookie: 'Cookie' }, + antigravityProjectIdLabel: 'GCP Project ID(可选)', + antigravityProjectIdPlaceholder: 'your-gcp-project-id', + antigravityProjectIdHint: + 'standard-tier 且未自动返回 project_id 的 Antigravity 账号需要填写用户自带 GCP project。', status: { active: '正常', inactive: '停用', diff --git a/frontend/src/views/admin/ops/OpsDashboard.vue b/frontend/src/views/admin/ops/OpsDashboard.vue index 9c512db6f..8bb42f3b6 100644 --- a/frontend/src/views/admin/ops/OpsDashboard.vue +++ b/frontend/src/views/admin/ops/OpsDashboard.vue @@ -44,7 +44,7 @@
-
+
-
+