diff --git a/.downstream/upstream-base b/.downstream/upstream-base index 8f51ea947e07..994233ddf706 100644 --- a/.downstream/upstream-base +++ b/.downstream/upstream-base @@ -1 +1 @@ -v0.2.9 +v0.2.10 diff --git a/.downstream/upstream-review-v0.2.10.md b/.downstream/upstream-review-v0.2.10.md new file mode 100644 index 000000000000..61208497cf09 --- /dev/null +++ b/.downstream/upstream-review-v0.2.10.md @@ -0,0 +1,46 @@ +# Upstream v0.2.10 integration + +## Source + +- Downstream base: `a3bca48d645f3bcd49720c53e197a43a11282a5e` (`v0.2.9-codexrip.2`, `origin/main`). +- Upstream: stable tag `v0.2.10`, commit `2f3fed2fdb0787141294cec81487a5df30426f7f` (`upstream/main` at `a60a29549f488a854966aaec9541abbe006cac22`). +- Shared ancestor: `4c00df2e0183e2c70b7fa8ba45914205e36aad0c` (upstream `v0.2.9`), which is also the base of the previous integration. +- Upstream delta: 34 commits, 118 paths, +3759/-332. 64 paths overlap downstream changes; 17 of them conflicted textually. +- No upstream schema, migration or dependency changes. No new SQL migration is introduced by this integration. + +## Integration decisions + +Four high-impact areas were escalated to the owner before any code was written; the owner chose official semantics for all four. + +- **Risk-control user allowlist (upstream #7679).** Adopted verbatim: the allowlisted platform users keep full upstream evidence while being excluded from automatic local penalties; default is off and the list is empty. The fork's downstream visibility rules (administrators see complete, unmasked information) are unaffected because the upstream change adds fields rather than hiding any. +- **Claude Sonnet 5.5 support (upstream #7683).** Adopted verbatim, including the upstream effort catalog and the single-pass tool-name rewrite in `gateway_tool_rewrite.go`. Downstream effort accounting, exact date aliases, explicit `none` and downstream `max` semantics remain in place and compose with the upstream catalog. +- **Billing and streamed usage normalization (upstream #7380/#7684).** Adopted: billed figures follow the upstream normalization for streamed Anthropic usage and chat-stream usage forwarding. The one-off value shift this causes in existing figures is accepted by the owner's decision to follow official semantics. +- **Client tabs for Claude Code-only groups (upstream #7678).** Adopted: unsupported clients are hidden for those groups. The fork's OpenCode tab was preserved and the guard composed with it. + +## Deliberate downstream deviations kept + +- **Capacity stays manifest-derived.** Upstream re-introduced a hard-coded `limit: { context: 1000000, output: 128000 }` for `claude-opus-5-5` and `claude-sonnet-5-5` in the OpenCode config. The fork removed exactly those constants in `862e84bbb` (v0.2.9 capacity unification) so that an OpenCode limit is emitted only from a resolver-tagged manifest row (`custom`, `official`, `upstream`, `registry`) and stays unknown when no evidence exists. The upstream constants are not restored; the upstream spec assertions that required them were rewritten to the fork's contract. The model entries, adaptive thinking options and variant sets are unchanged. +- **The OpenCode tab keeps fetching the Codex model manifest for OpenAI groups.** Upstream #7680 stopped showing and fetching the catalog for OpenAI groups. The fork derives OpenCode capacity from that manifest, so the catalog panel and fetch remain available for the `opencode` tab only. For the Codex CLI tabs on OpenAI groups the upstream behavior is kept exactly: the config emits no `model_catalog_json` reference and no catalog panel is rendered. This preserves the upstream intent while keeping the fork's capacity source. +- The fork's scheduler reporting (`ReportOpenAIAccountScheduleResultForSelection`, selective proxy-failure reporting, retry-exhausted cooldown) and its composite-route target-platform checks are retained alongside the upstream composite route resolution added in this release. +- The Apple-style console theme, the restored admin visibility and the native in-host capabilities are untouched by this integration. + +## Conflict resolutions + +- `backend/cmd/server/wire_gen.go`: arguments follow the fork's provider signatures plus the new `compositeRouteResolver` and `claudeResetCreditService` providers. +- `backend/internal/handler/openai_gateway_handler.go` (10 hunks): kept the fork's scheduler reporting, selective failure reporting and refusal-recovery cyber input handling; adopted upstream composite route resolution, the log-only cyber gate, the synchronous session-block write before the asynchronous record, and the body-aware blocked-session lookup. `wsRouteModel` (upstream) and `wsRoutingModel`/`wsForwardModel` (fork) coexist: routing data used for scheduler calls is the resolved upstream model (`wsRoutingModel = wsForwardModel`, identical to the old value outside composite groups). The fork's older asynchronous session-block write was removed, so allowlisted (log-only) users never get transcript blocks, as upstream intends. +- Settings union: `CyberPolicyUserAllowlist` was added alongside the fork's existing refusal and alpha-search keys in `settings_view.go`, `setting_parse.go`, `dto/settings.go`, `domain_constants.go` and `setting_handler_update.go`. +- `AccountUsageCell.vue`: adopted upstream's single stable `ClaudeResetCreditsCell` instance that survives the usage response, while keeping the fork's empty-usage text and always-available Grok quota probe. +- `EditAccountModal.vue`, `ModelWhitelistSelector.spec.ts`, `UseKeyModal.vue`: the fork's capacity-sync props were merged with upstream's `model-mappings` prop. + +## Validation + +- `GOOS=linux GOARCH=amd64 go build ./...` passes. +- `pnpm run typecheck` passes. +- `pnpm run check:i18n` passes. +- 174 focused frontend specs pass, covering every conflicted frontend file plus the new upstream `ClaudeResetCreditsCell` spec. +- `go test -tags=unit ./internal/handler/` passes, including upstream's composite WebSocket alias and cyber allowlist tests. The shared WebSocket harness counts rejected-turn upstream frames with the fork's `frameCount`. +- Not run: production migration rehearsal, real model calls, deploy, or canary. Source integration does not claim a successful deployment or model-quality acceptance. + +## Release target + +- `v0.2.10-codexrip.1`, fixed downstream image digest, `deploy-preserve`, resource profile `preserve`, natural drain, only the Sub2API container recreated. Final source SHA, PR and check results, image digest and production outcome belong to the operational delivery evidence. diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 1866a362b7be..13dead7ebf1a 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.9 +0.2.10 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 344eac57723f..f962eb864bf0 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -311,14 +311,15 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache, configConfig) openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, settingService, openAIGatewayService, openCodeGoUsageService) + claudeResetCreditService := service.NewClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, settingService, openAIGatewayService, openCodeGoUsageService, claudeResetCreditService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, accountTrafficObserver, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, accountTrafficObserver, configConfig, coordinator, compositeRouteResolver) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index e82c1df1dc66..4833bae7d459 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -190,6 +190,8 @@ var DefaultBedrockModelMapping = map[string]string{ "claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0", "claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0", // Claude Sonnet + // Sonnet 5.5 is available on bedrock-runtime through Global inference only. + "claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5", "claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1", "claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6", "claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index de7d88295532..076688d6c0cd 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -109,9 +109,10 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() cases := map[string]string{ - "claude-fable-5-1": "anthropic.claude-fable-5-1", - "claude-fable-5": "anthropic.claude-fable-5", - "claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1", + "claude-fable-5-1": "anthropic.claude-fable-5-1", + "claude-fable-5": "anthropic.claude-fable-5", + "claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1", + "claude-sonnet-5-5": "global.anthropic.claude-sonnet-5-5", } for from, want := range cases { got, ok := DefaultBedrockModelMapping[from] diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index f34c010ad330..eced5b7ce704 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -50,6 +50,7 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler { // AccountHandler handles admin account management type AccountHandler struct { + claudeResetCredits claudeResetReader adminService service.AdminService oauthService *service.OAuthService openaiOAuthService *service.OpenAIOAuthService diff --git a/backend/internal/handler/admin/claude_reset_handler.go b/backend/internal/handler/admin/claude_reset_handler.go new file mode 100644 index 000000000000..8ec456444a21 --- /dev/null +++ b/backend/internal/handler/admin/claude_reset_handler.go @@ -0,0 +1,34 @@ +package admin + +import ( + "context" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "strconv" +) + +type claudeResetReader interface { + Query(context.Context, int64) (*service.ClaudeResetCredits, error) +} + +func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) { + h.claudeResetCredits = s +} +func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid account ID") + return + } + if h.claudeResetCredits == nil { + response.Error(c, 503, "Claude reset service unavailable") + return + } + status, err := h.claudeResetCredits.Query(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, status) +} diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 42d0538c9bb8..22c8e775b136 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -500,7 +500,11 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) { limit = 12 } - trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit) + metric := c.DefaultQuery("metric", "tokens") + if metric != "tokens" && metric != "actual_cost" { + metric = "tokens" + } + trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit, metric) if err != nil { response.Error(c, 500, "Failed to get user usage trend") return diff --git a/backend/internal/handler/admin/dashboard_handler_cache_test.go b/backend/internal/handler/admin/dashboard_handler_cache_test.go index ec8888497982..357d2edf10f8 100644 --- a/backend/internal/handler/admin/dashboard_handler_cache_test.go +++ b/backend/internal/handler/admin/dashboard_handler_cache_test.go @@ -45,6 +45,7 @@ func (r *dashboardUsageRepoCacheProbe) GetUserUsageTrend( startTime, endTime time.Time, granularity string, limit int, + metric string, ) ([]usagestats.UserUsageTrendPoint, error) { r.usersTrendCalls.Add(1) return []usagestats.UserUsageTrendPoint{{ @@ -115,4 +116,20 @@ func TestDashboardHandler_GetUserUsageTrend_UsesCache(t *testing.T) { require.Equal(t, http.StatusOK, rec2.Code) require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache")) require.Equal(t, int32(1), repo.usersTrendCalls.Load()) + + for _, tc := range []struct { + metric, cache string + calls int32 + }{ + {"actual_cost", "miss", 2}, + {"actual_cost", "hit", 2}, + {"tokens", "hit", 2}, + } { + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8&metric="+tc.metric, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, tc.cache, rec.Header().Get("X-Snapshot-Cache")) + require.Equal(t, tc.calls, repo.usersTrendCalls.Load()) + } } diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 61d38a66e99e..4a6fe4e5fe3c 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -53,6 +53,7 @@ type dashboardEntityTrendCacheKey struct { EndTime string `json:"end_time"` Granularity string `json:"granularity"` Limit int `json:"limit"` + Metric string `json:"metric,omitempty"` } func cacheStatusValue(hit bool) string { @@ -213,15 +214,16 @@ func (h *DashboardHandler) getAPIKeyUsageTrendCached(ctx context.Context, startT return trend, hit, err } -func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, bool, error) { +func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, bool, error) { key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{ StartTime: startTime.UTC().Format(time.RFC3339), EndTime: endTime.UTC().Format(time.RFC3339), Granularity: granularity, Limit: limit, + Metric: metric, }) entry, hit, err := dashboardUsersTrendCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit) + return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric) }) if err != nil { return nil, hit, err diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 1900205eaf6b..5e8b66ac6677 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -243,7 +243,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( } if includeUsersTrend { - usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit) + usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit, "tokens") if err != nil { return nil, errors.New("failed to get user usage trend") } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 911d0e8fafbb..c0aaca5b33d4 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -332,6 +332,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DefaultBalance: settings.DefaultBalance, RiskControlEnabled: settings.RiskControlEnabled, CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled, + CyberPolicyUserAllowlist: settings.CyberPolicyUserAllowlist, CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds, OpenAIRefusalRecoveryEnabled: settings.OpenAIRefusalRecoveryEnabled, OpenAICyberFailoverEnabled: settings.OpenAICyberFailoverEnabled, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index fb89e4db8aee..a3957afbcd8c 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -604,6 +604,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.RiskControlEnabled != after.RiskControlEnabled { changed = append(changed, "risk_control_enabled") } + if before.CyberPolicyUserAllowlist != after.CyberPolicyUserAllowlist { + changed = append(changed, "cyber_policy_user_allowlist") + } if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled { changed = append(changed, "cyber_session_block_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index d7fda63bb624..775e4f8a28d4 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -365,13 +365,13 @@ type UpdateSettingsRequest struct { // cyber 会话屏蔽开关 + TTL CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"` + CyberPolicyUserAllowlist *string `json:"cyber_policy_user_allowlist"` CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"` OpenAIRefusalRecoveryEnabled *bool `json:"openai_refusal_recovery_enabled"` OpenAICyberFailoverEnabled *bool `json:"openai_cyber_failover_enabled"` OpenAIRefusalRewriteEnabled *bool `json:"openai_refusal_rewrite_enabled"` OpenAIRefusalKeywords *[]string `json:"openai_refusal_keywords"` OpenAIRefusalReplacement *string `json:"openai_refusal_replacement"` - // OpenAI fast/flex policy (optional, only updated when provided) OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` @@ -1518,6 +1518,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + if req.CyberPolicyUserAllowlist != nil { + if _, err := service.ParseCyberPolicyUserAllowlist(*req.CyberPolicyUserAllowlist); err != nil { + response.BadRequest(c, err.Error()) + return + } + } + // cyber 会话屏蔽 TTL 校验:提供时必须 > 0 if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 { response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0") @@ -2023,6 +2030,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.RiskControlEnabled }(), + CyberPolicyUserAllowlist: func() string { + if req.CyberPolicyUserAllowlist != nil { + return *req.CyberPolicyUserAllowlist + } + return previousSettings.CyberPolicyUserAllowlist + }(), CyberSessionBlockEnabled: func() bool { if req.CyberSessionBlockEnabled != nil { return *req.CyberSessionBlockEnabled @@ -2462,6 +2475,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RiskControlEnabled: updatedSettings.RiskControlEnabled, CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, + CyberPolicyUserAllowlist: updatedSettings.CyberPolicyUserAllowlist, CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, OpenAIRefusalRecoveryEnabled: updatedSettings.OpenAIRefusalRecoveryEnabled, OpenAICyberFailoverEnabled: updatedSettings.OpenAICyberFailoverEnabled, @@ -2469,8 +2483,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpenAIRefusalKeywords: append([]string(nil), updatedSettings.OpenAIRefusalKeywords...), OpenAIRefusalReplacement: updatedSettings.OpenAIRefusalReplacement, AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds, - AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, - } + AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests} if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { slog.Error("openai_fast_policy_settings_get_failed", "error", err) } else if fastPolicy != nil { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index d47005549084..d3e15beee80c 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -336,13 +336,13 @@ type SystemSettings struct { // cyber 会话屏蔽开关 + TTL CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"` + CyberPolicyUserAllowlist string `json:"cyber_policy_user_allowlist"` CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"` OpenAIRefusalRecoveryEnabled bool `json:"openai_refusal_recovery_enabled"` OpenAICyberFailoverEnabled bool `json:"openai_cyber_failover_enabled"` OpenAIRefusalRewriteEnabled bool `json:"openai_refusal_rewrite_enabled"` OpenAIRefusalKeywords []string `json:"openai_refusal_keywords"` OpenAIRefusalReplacement string `json:"openai_refusal_replacement"` - // Affiliate (邀请返利) feature switch AffiliateEnabled bool `json:"affiliate_enabled"` diff --git a/backend/internal/handler/openai_cyber_allowlist.go b/backend/internal/handler/openai_cyber_allowlist.go new file mode 100644 index 000000000000..7112bff0ade8 --- /dev/null +++ b/backend/internal/handler/openai_cyber_allowlist.go @@ -0,0 +1,18 @@ +package handler + +import ( + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *OpenAIGatewayHandler) cyberPolicyLogOnly(c *gin.Context, apiKey *service.APIKey) bool { + return h != nil && c != nil && c.Request != nil && h.gatewayService.CyberPolicyLogOnly(c.Request.Context(), apiKey) +} + +// Both HTTP and WebSocket admission skip existing blocks for trusted users. +func (h *OpenAIGatewayHandler) findBlockedCyberSessionForAPIKey(c *gin.Context, apiKey *service.APIKey, body []byte) string { + if apiKey == nil || h.cyberPolicyLogOnly(c, apiKey) { + return "" + } + return findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, body) +} diff --git a/backend/internal/handler/openai_cyber_allowlist_test.go b/backend/internal/handler/openai_cyber_allowlist_test.go new file mode 100644 index 000000000000..22bb67458686 --- /dev/null +++ b/backend/internal/handler/openai_cyber_allowlist_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCyberAllowlistedUserBypassesExistingBlocksAndContinuesWebSocket(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + for _, response := range []string{ + `{"type":"response.failed","response":{"id":"resp_cyber","model":"gpt-5.1","error":{"code":"cyber_policy","message":"blocked by upstream"}}}`, + `{"type":"response.completed","response":{"id":"resp_ok","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`, + } { + if _, _, err := conn.Read(ctx); err != nil { + return + } + if err := conn.Write(ctx, coderws.MessageText, []byte(response)); err != nil { + return + } + } + // Let the downstream close so the completed frame can drain normally. + _, _, _ = conn.Read(ctx) + })) + defer upstream.Close() + harness := newOpenAIWSPassthroughHandlerHarness(t, upstream.URL, map[string]string{ + service.SettingKeyCyberPolicyUserAllowlist: "1751", + }) + payload := []byte(`{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"trusted-session","input":"test"}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(string(payload))) + explicitKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, c, payload) + store, ok := harness.gatewayCache.(service.CyberSessionBlockStore) + require.True(t, ok) + require.NoError(t, store.SetCyberSessionBlocked(context.Background(), "", []string{explicitKey}, time.Minute)) + for _, format := range []cyberSessionBlockFormat{cyberBlockFormatResponses, cyberBlockFormatChat, cyberBlockFormatAnthropic} { + require.False(t, harness.handler.rejectIfCyberSessionBlocked(c, harness.apiKey, payload, "gpt-5.1", format)) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, harness.clientConn.Write(ctx, coderws.MessageText, payload)) + _, event, err := harness.clientConn.Read(ctx) + require.NoError(t, err) + require.Equal(t, "cyber_policy", gjson.GetBytes(event, "response.error.code").String()) + require.Eventually(t, func() bool { + logs := harness.moderationRepo.logSnapshot() + return len(logs) == 1 && logs[0].Mode == service.ContentModerationModeCyberLogOnly + }, 3*time.Second, 10*time.Millisecond) + matched, err := store.FindCyberSessionBlocked(ctx, service.CyberSessionTranscriptBlockKeys(harness.apiKey.ID, payload)) + require.NoError(t, err) + require.Empty(t, matched, "the cyber response must not write new transcript blocks") + require.NoError(t, harness.clientConn.Write(ctx, coderws.MessageText, payload)) + _, event, err = harness.clientConn.Read(ctx) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + _ = harness.clientConn.CloseNow() + select { + case <-harness.handlerDone: + case <-ctx.Done(): + t.Fatal("handler did not exit") + } +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 911462aca198..f08eb56bd6ed 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -35,6 +35,7 @@ import ( // OpenAIGatewayHandler handles OpenAI API gateway requests type OpenAIGatewayHandler struct { + compositeResolver *service.CompositeRouteResolver gatewayService *service.OpenAIGatewayService nativeAnthropicGatewayService *service.GatewayService billingCacheService *service.BillingCacheService @@ -2627,7 +2628,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } // 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。 - // 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。 + // 必须在合成路由解析和上游模型映射之前执行。 // 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定, // 全部候选值逐一校验,任一未命中即拒绝。 if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" { @@ -2638,6 +2639,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } wsRoutingModel := reqModel ensureCompositeTargetPlatform(c, apiKey, reqModel) + // Keep the client model in the frame for admission, response identity and usage. + // Apply the resolved upstream model through MapRequestModel on every turn. + wsRouteModel := reqModel + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { + decision, resolveErr := h.compositeResolver.Resolve(c.Request.Context(), apiKey.Group.ID, reqModel, service.CompositeRouteEndpointResponses) + if resolveErr != nil { + reqLog.Error("openai.websocket_composite_route_failed", zap.Error(resolveErr)) + closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route") + return + } + if decision.Matched { + c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision)) + wsRouteModel = decision.UpstreamModel + } + } ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { platform, ok := service.ResolvedTargetPlatformFromContext(ctx) @@ -2672,10 +2688,11 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage()) return } - // F5a: 握手层会话屏蔽检查。WS 握手无 body,显式标识仅来自握手 header - // (session_id / conversation_id);无标识则放行,连接内仍有本地 flag 兜底。 - cyberBlockKey := service.CyberSessionBlockKey(apiKey.ID, c, nil) - if cyberBlockKey != "" && h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), cyberBlockKey) { + + // The first response.create frame is available here, so explicit IDs are + // checked directly and body-derived sessions use the coarse scope gate. + cyberBlockKey := h.findBlockedCyberSessionForAPIKey(c, apiKey, firstMessage) + if cyberBlockKey != "" { writeCyberSessionBlockedWSError(c.Request.Context(), wsConn) closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "session blocked by cyber-security policy") h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, reqModel, cyberBlockKey) @@ -2703,8 +2720,11 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) return mapping } - channelMappingWS := resolveWSChannelMapping(reqModel) - wsRoutingModel = openAIChannelForwardModel(channelMappingWS, reqModel) + channelMappingWS := resolveWSChannelMapping(wsRouteModel) + wsForwardModel := openAIChannelForwardModel(channelMappingWS, wsRouteModel) + // Composite aliases schedule on the resolved upstream model (upstream v0.2.10); + // every other group has wsRouteModel == reqModel, so this keeps the old value. + wsRoutingModel = wsForwardModel var currentUserRelease func() var currentAccountRelease func() @@ -2829,7 +2849,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return false } if failoverErr.ShouldReportAccountScheduleFailure() { - h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(wsRoutingModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(wsForwardModel), false, nil) } else { h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(selection) } @@ -3103,8 +3123,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与 // passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。 // BeforeTurn 中保留同一检查作为防御式兜底。 - if cyberBlockedThisConn { - return newOpenAIWSLocalTurnCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) + if cyberBlockedThisConn && !h.cyberPolicyLogOnly(c, apiKey) { + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } if turn == 1 { return nil @@ -3141,7 +3161,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if model == "" { model = reqModel } - mapping := resolveWSChannelMapping(model) + setOpsRequestContext(c, model, true) + routeModel := model + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { + // The account, target platform and route context are connection-scoped. + // A different public model needs a new connection and fresh resolution. + if model != reqModel { + return "", newOpenAIWSUnsupportedModelSwitchError(model) + } + routeModel = wsRouteModel + } + mapping := resolveWSChannelMapping(routeModel) mappedModelUnchanged := false if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel) @@ -3154,8 +3184,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, BeforeTurn: func(turn int) error { // turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。 - if cyberBlockedThisConn { - return newOpenAIWSLocalTurnCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) + if cyberBlockedThisConn && !h.cyberPolicyLogOnly(c, apiKey) { + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } // 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核 // 当前账号,越线即要求客户端重连重选(连接绑定单一上游账号, @@ -3245,10 +3275,27 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } else if len(cyberBlockBody) == 0 { turnCyberBlockInput = cyberBlockKey } + cyberMarked := service.GetOpsCyberPolicy(c) != nil + cyberLogOnly := h.cyberPolicyLogOnly(c, apiKey) h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, turnCyberBlockInput, turnUsageFields, requestPayloadHash) if !recoveryAttempt { cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState( - cyberBlockedThisConn, cyberBlockPendingAfterFailover, service.GetOpsCyberPolicy(c) != nil, turnErr, + cyberBlockedThisConn, cyberBlockPendingAfterFailover, cyberMarked && !cyberLogOnly, turnErr, + ) + } + if turnErr != nil { + if result == nil || result.ImageCount <= 0 { + return + } + // cyber 命中时该 turn 的用量已由 recordCyberPolicyIfMarked(forwardErrored=true) + // 按真实 token 记录,这里不再走下方 RecordUsage,避免对同一 turn 双写/双扣费。 + if cyberMarked { + return + } + reqLog.Warn("openai.websocket_partial_error_with_image_result", + zap.Int64("account_id", account.ID), + zap.Int("image_count", result.ImageCount), + zap.Error(turnErr), ) } if turnErr != nil { @@ -3332,7 +3379,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { ) } else { if shouldReportOpenAIWSProxyAccountFailure(err) { - h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(wsRoutingModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(wsForwardModel), false, nil) } reqLog.Warn("openai.websocket_proxy_closed", zap.Int64("account_id", account.ID), @@ -3404,7 +3451,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } if shouldReportOpenAIWSProxyAccountFailure(err) { - h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(wsRoutingModel), false, nil) + h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(wsForwardModel), false, nil) } closeStatus, closeReason := summarizeWSCloseErrorForLog(err) proxyFailedFields := []zap.Field{ @@ -4877,7 +4924,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe if enabled, _ := h.gatewayService.CyberSessionBlockRuntime(c.Request.Context()); !enabled { return false } - key := service.CyberSessionBlockKey(apiKey.ID, c, body) + key := h.findBlockedCyberSessionForAPIKey(c, apiKey, body) if key == "" { return false } @@ -4969,6 +5016,18 @@ func buildCyberSessionBlockWritePlan(apiKeyID int64, c *gin.Context, body []byte return plan } +func findBlockedCyberSessionKey(ctx context.Context, gatewayService *service.OpenAIGatewayService, apiKeyID int64, c *gin.Context, body []byte) string { + if gatewayService == nil { + return "" + } + clientIP, userAgent := "", "" + if c != nil { + clientIP = strings.TrimSpace(ip.GetClientIP(c)) + userAgent = c.GetHeader("User-Agent") + } + return gatewayService.FindCyberSessionBlockedForRequest(ctx, apiKeyID, c, body, clientIP, userAgent) +} + func cyberSessionScopeKey(apiKeyID int64, c *gin.Context) string { if c == nil { return "" @@ -5074,11 +5133,20 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey ClientIP: clientIPStr, CreatedAt: time.Now(), } + cyberLogOnly := h.cyberPolicyLogOnly(c, apiKey) + // 上游在落库前就同步写入会话屏蔽,避免进程退出丢失;这里复用上面已构建的 + // blockPlan(我们的入参是 any,无法直接沿用上游的 []byte 形态)。 + if gwSvc != nil && apiKey != nil && !cyberLogOnly && len(blockPlan.keys) > 0 { + blockCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + gwSvc.MarkCyberSessionBlocked(blockCtx, blockPlan.scopeKey, blockPlan.keys) + cancel() + } go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if cmSvc != nil { cmSvc.RecordCyberPolicyEvent(ctx, service.CyberPolicyRecordInput{ + LogOnly: cyberLogOnly, RequestID: requestID, UserID: userID, UserEmail: userEmail, @@ -5095,9 +5163,6 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey UpstreamOutTok: mark.UpstreamOutTok, }) } - if gwSvc != nil && len(blockPlan.keys) > 0 { - gwSvc.MarkCyberSessionBlocked(ctx, blockPlan.scopeKey, blockPlan.keys) - } if forwardErrored && gwSvc != nil { gwSvc.RecordCyberPolicyUsageLog(ctx, service.CyberPolicyUsageInput{ APIKey: apiKey, diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index bb6f51e39c01..082a2440198e 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -2092,7 +2092,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject type openAIResponsesWSUsageLogCase struct { simpleModeRejectAtRead int64 + compositeResolver *service.CompositeRouteResolver + accountPlatform string closeReason string + closeStatus coderws.StatusCode firstPayload string // midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它 // 回一个 response.completed,客户端按普通事件读取。 @@ -3348,6 +3351,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU upstreamErrCh := make(chan error, 1) var channelSvc *service.ChannelService upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.accountPlatform == service.PlatformGrok { + payload, err := io.ReadAll(r.Body) + if err != nil { + upstreamErrCh <- err + return + } + upstreamPayloadCh <- payload + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_grok_test\",\"model\":%q,\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n", gjson.GetBytes(payload, "model").String()) + return + } conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ CompressionMode: coderws.CompressionContextTakeover, }) @@ -3438,6 +3452,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU "openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough, }, } + if tc.accountPlatform != "" { + account.Platform = tc.accountPlatform + } if strings.TrimSpace(tc.ingressMode) != "" { account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode } @@ -3493,7 +3510,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU service.NewBillingService(cfg, nil), nil, billingCacheSvc, - nil, + &compositeWSHTTPUpstream{}, &service.DeferredService{}, nil, nil, @@ -3514,6 +3531,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } h := &OpenAIGatewayHandler{ cfg: cfg, + compositeResolver: tc.compositeResolver, gatewayService: gatewaySvc, billingCacheService: billingCacheSvc, apiKeyService: &service.APIKeyService{}, @@ -3567,6 +3585,12 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cancelWrite() require.NoError(t, err) + if tc.closeReason == "" { + tc.closeReason = "not available for this group" + } + if tc.closeStatus == 0 { + tc.closeStatus = coderws.StatusPolicyViolation + } if tc.firstFrameCloseExpected { readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) _, _, readErr := clientConn.Read(readCtx) @@ -3574,12 +3598,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU require.Error(t, readErr, "first frame should have been rejected with a close") var closeErr coderws.CloseError require.ErrorAs(t, readErr, &closeErr) - require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + status := tc.closeStatus + if status == 0 { + status = coderws.StatusPolicyViolation + } + require.Equal(t, status, closeErr.Code) reason := tc.closeReason if reason == "" { reason = "not available for this group" } require.Contains(t, closeErr.Reason, reason) + require.Empty(t, upstreamPayloadCh, "rejected first frame must not reach upstream") _ = clientConn.CloseNow() return openAIResponsesWSUsageLogResult{} } @@ -3621,12 +3650,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU require.Error(t, readErr, "second turn should have been rejected with a close") var closeErr coderws.CloseError require.ErrorAs(t, readErr, &closeErr) - require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + status := tc.closeStatus + if status == 0 { + status = coderws.StatusPolicyViolation + } + require.Equal(t, status, closeErr.Code) reason := tc.closeReason if reason == "" { reason = "not available for this group" } require.Contains(t, closeErr.Reason, reason) + require.Len(t, upstreamPayloadCh, frameCount-1, "rejected turn must not reach upstream") _ = clientConn.CloseNow() return openAIResponsesWSUsageLogResult{} } @@ -3655,6 +3689,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } } + if tc.accountPlatform == service.PlatformGrok { + upstreamErrCh <- nil + } select { case upstreamErr := <-upstreamErrCh: require.NoError(t, upstreamErr) diff --git a/backend/internal/handler/openai_gateway_ws_composite_test.go b/backend/internal/handler/openai_gateway_ws_composite_test.go new file mode 100644 index 000000000000..7c2d79bbaaa2 --- /dev/null +++ b/backend/internal/handler/openai_gateway_ws_composite_test.go @@ -0,0 +1,159 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type compositeWSRouteRepo struct { + service.CompositeModelRouteRepository + routes []service.CompositeModelRoute + err error +} + +func (r *compositeWSRouteRepo) ListByGroup(context.Context, int64, bool) ([]service.CompositeModelRoute, error) { + return r.routes, r.err +} + +type compositeWSHTTPUpstream struct{ service.HTTPUpstream } + +func (*compositeWSHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return http.DefaultClient.Do(req) +} + +func compositeWSResolver(platform, endpoint, upstream string) *service.CompositeRouteResolver { + return service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{ + GroupID: 4201, PublicModel: "public-alias", MatchType: service.CompositeRouteMatchExact, + TargetPlatform: platform, Endpoint: endpoint, UpstreamModel: upstream, Enabled: true, + }}}) +} +func compositeWSGroup(models ...string) *service.Group { + g := wsAllowlistGroup(len(models) > 0, models...) + g.Platform = service.PlatformComposite + return g +} + +func TestOpenAIResponsesWebSocket_CompositeAlias(t *testing.T) { + for _, platform := range []string{service.PlatformOpenAI, service.PlatformGrok} { + for _, endpoint := range []string{service.CompositeRouteEndpointResponses, service.CompositeRouteEndpointAny} { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} { + t.Run(platform+"/"+endpoint+"/"+mode, func(t *testing.T) { + upstream := "gpt-5.4" + if platform == service.PlatformGrok { + upstream = "grok-4.3" + } + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias","input":"hi"}`, + secondPayload: `{"type":"response.create","input":"again"}`, + group: compositeWSGroup("public-alias"), accountPlatform: platform, ingressMode: mode, + compositeResolver: compositeWSResolver(platform, endpoint, upstream), + }) + require.Len(t, got.upstreamPayloads, 2) + for i, payload := range got.upstreamPayloads { + require.Equal(t, upstream, gjson.GetBytes(payload, "model").String()) + require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[i], "response.model").String()) + require.Equal(t, "public-alias", got.logs[i].RequestedModel) + require.NotNil(t, got.logs[i].UpstreamModel) + require.Equal(t, upstream, *got.logs[i].UpstreamModel) + } + }) + } + } + } +} + +func TestOpenAIResponsesWebSocket_CompositeRouteRejections(t *testing.T) { + for _, tc := range []struct { + name, platform, endpoint, model, reason string + repoErr error + status coderws.StatusCode + }{ + {name: "disallowed platform", platform: service.PlatformAnthropic, endpoint: "responses", model: "public-alias", reason: "only supports OpenAI-compatible"}, + {name: "wrong endpoint", platform: service.PlatformOpenAI, endpoint: "messages", model: "public-alias", reason: "only supports OpenAI-compatible"}, + {name: "unknown alias", platform: service.PlatformOpenAI, endpoint: "responses", model: "unknown-alias", reason: "only supports OpenAI-compatible"}, + {name: "resolver error", model: "gpt-5.4", repoErr: errors.New("database unavailable"), reason: "Failed to resolve composite model route", status: coderws.StatusInternalError}, + } { + t.Run(tc.name, func(t *testing.T) { + resolver := compositeWSResolver(tc.platform, tc.endpoint, "gpt-5.4") + if tc.repoErr != nil { + resolver = service.NewCompositeRouteResolver(&compositeWSRouteRepo{err: tc.repoErr}) + } + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"` + tc.model + `"}`, group: compositeWSGroup(), compositeResolver: resolver, + firstFrameCloseExpected: true, closeReason: tc.reason, closeStatus: tc.status, + }) + }) + } +} + +func TestOpenAIResponsesWebSocket_CompositeAdmissionUsesPublicModel(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias"}`, group: compositeWSGroup("gpt-5.4"), + compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), firstFrameCloseExpected: true, + }) +} + +func TestOpenAIResponsesWebSocket_CompositeModelSwitchRequiresReconnect(t *testing.T) { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} { + for _, model := range []string{"gpt-5.4", "grok-4.3", "unknown-alias"} { + t.Run(mode+"/"+model, func(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias"}`, + secondPayload: `{"type":"response.create","model":"` + model + `"}`, group: compositeWSGroup(), ingressMode: mode, + compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), + secondTurnCloseExpected: true, closeReason: "model switch requires reconnect", + }) + }) + } + } +} + +func TestOpenAIResponsesWebSocket_CompositeChannelBilling(t *testing.T) { + for _, source := range []string{service.BillingModelSourceRequested, service.BillingModelSourceChannelMapped, service.BillingModelSourceUpstream} { + t.Run(source, func(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`, + secondPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`, + group: compositeWSGroup("gpt-5.6-sol"), + compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{PublicModel: "gpt-5.6-sol", MatchType: service.CompositeRouteMatchExact, TargetPlatform: service.PlatformOpenAI, Endpoint: service.CompositeRouteEndpointResponses, UpstreamModel: "route-target"}}}), + channelMapping: map[string]string{"route-target": "gpt-5.4"}, billingModelSource: source, + accountModelMapping: map[string]any{"gpt-5.4": "gpt-5.4"}, + }) + for i, log := range got.logs { + require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamPayloads[i], "model").String()) + require.Equal(t, "gpt-5.6-sol", log.RequestedModel) + require.Equal(t, "gpt-5.6-sol", log.Model) + if source == service.BillingModelSourceRequested { + require.InDelta(t, 40e-6, log.TotalCost, 1e-12) + } else { + require.InDelta(t, 20e-6, log.TotalCost, 1e-12) + } + } + }) + } +} + +func TestOpenAIResponsesWebSocket_CompositeDetectorFallback(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4"}`, group: compositeWSGroup(), + compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{}), + }) + require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamFirstPayload, "model").String()) +} + +func TestOpenAIResponsesWebSocket_CompositeSessionModelSwitchRequiresReconnect(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias"}`, + midPayload: `{"type":"session.update","session":{"model":"grok-4.3"}}`, + secondPayload: `{"type":"response.create"}`, group: compositeWSGroup(), + compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), + secondTurnCloseExpected: true, closeReason: "model switch requires reconnect", + }) +} diff --git a/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go b/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go index 49e17c3e758b..61ba54488a36 100644 --- a/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go +++ b/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go @@ -19,6 +19,7 @@ import ( ) type openAIWSPassthroughHandlerHarness struct { + handler *OpenAIGatewayHandler clientConn *coderws.Conn handlerDone <-chan struct{} moderationRepo *contentModerationHandlerTestRepo @@ -26,7 +27,7 @@ type openAIWSPassthroughHandlerHarness struct { apiKey *service.APIKey } -func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *openAIWSPassthroughHandlerHarness { +func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string, settings ...map[string]string) *openAIWSPassthroughHandlerHarness { t.Helper() gatewayCache := testutil.NewRedisGatewayCache(t) @@ -35,6 +36,11 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope service.SettingKeyCyberSessionBlockEnabled: "true", service.SettingKeyCyberSessionBlockTTLSeconds: "60", }} + for _, overrides := range settings { + for key, value := range overrides { + settingRepo.values[key] = value + } + } moderationRepo := &contentModerationHandlerTestRepo{} moderationSvc := service.NewContentModerationService(settingRepo, moderationRepo, nil, nil, nil, nil, nil, nil) settingSvc := service.NewSettingService(settingRepo, nil) @@ -90,6 +96,7 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope apiKey := &service.APIKey{ ID: 1851, + UserID: 1751, Name: "ws-cyber-key", Key: "sk-handler-cyber-test", GroupID: &groupID, @@ -116,6 +123,7 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope t.Cleanup(func() { _ = clientConn.CloseNow() }) return &openAIWSPassthroughHandlerHarness{ + handler: h, clientConn: clientConn, handlerDone: handlerDone, moderationRepo: moderationRepo, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index f41888adeb12..05e8996a05cc 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -56,6 +56,7 @@ func ProvideAdminHandlers( settingService *service.SettingService, codexTicketGateway *service.OpenAIGatewayService, opencodeGoUsage *service.OpenCodeGoUsageService, + claudeResetCredits *service.ClaudeResetCreditService, ) *AdminHandlers { accountHandler.SetCodexTicketGateway(codexTicketGateway) settingHandler.SetCodexTicketGateway(codexTicketGateway) @@ -63,6 +64,7 @@ func ProvideAdminHandlers( accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) accountHandler.SetAccountTrafficObserver(accountTrafficObserver) accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage) + accountHandler.SetClaudeResetCreditService(claudeResetCredits) return &AdminHandlers{ Dashboard: dashboardHandler, User: userHandler, @@ -145,12 +147,14 @@ func ProvideOpenAIGatewayHandler( trafficObserver *service.AccountTrafficObserver, cfg *config.Config, coordinator *securityaudit.Coordinator, + compositeResolver *service.CompositeRouteResolver, ) *OpenAIGatewayHandler { gatewayService.SetPluginManager(pluginManager) h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) h.SetNativeAnthropicGatewayService(nativeAnthropicGatewayService) h.SetAccountTrafficObserver(trafficObserver) + h.compositeResolver = compositeResolver h.securityAuditCoordinator = coordinator h.grokMediaEligibilityProber = grokQuotaService return h diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 3710b79e919e..9f61bcb80ee3 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1886,6 +1886,64 @@ func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) { require.Error(t, err) } +func TestSonnet55ResponsesThinkingAndSampling(t *testing.T) { + for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max", "none"} { + req := &ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}} + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err, effort) + require.Zero(t, out.Thinking.BudgetTokens) + if effort == "none" { + require.Equal(t, "between_tools", out.Thinking.Type) + require.Equal(t, "low", out.OutputConfig.Effort) + } else { + require.Equal(t, "adaptive", out.Thinking.Type) + if effort == "" { + effort = "high" + } + require.Equal(t, effort, out.OutputConfig.Effort) + } + } + for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} { + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)}) + require.ErrorContains(t, err, "forced tool_choice") + } + temperature := 0.7 + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature}) + require.ErrorContains(t, err, "temperature") + topP := 0.5 + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP}) + require.ErrorContains(t, err, "top_p") + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "minimal"}}) + require.ErrorContains(t, err, "reasoning effort") + + temperature, topP = 1, 0.99 + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature, TopP: &topP}) + require.NoError(t, err) +} + +func TestSonnet55SignedThinkingResponsesRoundTrip(t *testing.T) { + block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "signed-sonnet-block"} + response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-sonnet-5-5", Content: []AnthropicContentBlock{block, {Type: "text", Text: "progress"}, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}}) + require.Len(t, response.Output, 3) + require.NotEmpty(t, response.Output[0].EncryptedContent) + require.Equal(t, "message", response.Output[1].Type) + raw, err := json.Marshal(response.Output) + require.NoError(t, err) + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(raw, &items)) + items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[2].CallID, Output: "ok"}) + raw, err = json.Marshal(items) + require.NoError(t, err) + converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-sonnet-5-5", Input: raw}) + require.NoError(t, err) + require.Len(t, converted.Messages, 2) + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks)) + require.Equal(t, block, blocks[0]) + require.Equal(t, "text", blocks[1].Type) + require.Equal(t, "tool_use", blocks[2].Type) +} + func TestGPT6ChatSamplingAndCacheFields(t *testing.T) { temperature := 0.7 for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index f0712ecb7473..186ee3baa3bd 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -53,7 +53,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { for _, block := range resp.Content { switch block.Type { case "thinking", "redacted_thinking": - if claude.IsOpus55(resp.Model) && (block.Signature != "" || block.Data != "") { + if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && (block.Signature != "" || block.Data != "") { item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)} if block.Thinking != "" { item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}} @@ -72,7 +72,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { }) } case "text": - if claude.IsOpus55(resp.Model) && block.Text != "" { + if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && block.Text != "" { outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}}) continue } @@ -285,7 +285,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) { func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent { if evt.Message != nil { state.ResponseID = evt.Message.ID - state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) + state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model) if state.Model == "" { state.Model = evt.Message.Model } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 69928591d3c2..34d90518875a 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -14,7 +14,9 @@ import ( // enables Anthropic platform groups to accept OpenAI Responses API requests // by converting them to the native /v1/messages format before forwarding upstream. func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) { - system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, claude.IsOpus55(req.Model)) + isOpus55 := claude.IsOpus55(req.Model) + isSonnet55 := claude.IsSonnet55(req.Model) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55) if err != nil { return nil, err } @@ -54,9 +56,11 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro out.ToolChoice = tc } - // Opus 5.5 always uses adaptive thinking. Resolve the upstream model before - // conversion, because client aliases need not identify a Claude model. - if claude.IsOpus55(req.Model) { + // The 5.5 models reject manual thinking and forced tool use. Sonnet 5.5 + // additionally supports between_tools to disable up-front thinking. + // Resolve the upstream model before conversion: client aliases need not + // identify a Claude model. + if isOpus55 || isSonnet55 { var choice struct { Type string `json:"type"` } @@ -66,16 +70,36 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro } } if choice.Type == "any" || choice.Type == "tool" { - return nil, fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + return nil, fmt.Errorf("%s does not support forced tool_choice; use auto or none", req.Model) } effort := "medium" + if isSonnet55 { + effort = "high" + if req.Temperature != nil && *req.Temperature != 1 { + return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default temperature") + } + if req.TopP != nil && (*req.TopP < 0.99 || *req.TopP > 1) { + return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p") + } + } if req.Reasoning != nil && req.Reasoning.Effort != "" { effort = req.Reasoning.Effort } + if isSonnet55 && effort == "none" { + // OpenAI's no-reasoning request maps to Sonnet 5.5's lowest + // thinking mode. between_tools still preserves signed progress + // blocks produced during tool use. + out.Thinking = &AnthropicThinking{Type: "between_tools"} + if out.OutputConfig == nil { + out.OutputConfig = &AnthropicOutputConfig{} + } + out.OutputConfig.Effort = "low" + return out, nil + } switch effort { case "low", "medium", "high", "xhigh", "max": default: - return nil, fmt.Errorf("claude-opus-5-5 does not support reasoning effort %q; use low, medium, high, xhigh or max", effort) + return nil, fmt.Errorf("%s does not support reasoning effort %q; use low, medium, high, xhigh or max", req.Model, effort) } out.Thinking = &AnthropicThinking{Type: "adaptive"} out.OutputConfig = &AnthropicOutputConfig{Effort: effort} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b99935b2cb19..fb6433c964a8 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -42,7 +42,7 @@ type AnthropicOutputConfig struct { // AnthropicThinking configures extended thinking in the Anthropic API. type AnthropicThinking struct { - Type string `json:"type"` // "enabled" | "adaptive" | "disabled" + Type string `json:"type"` // "enabled" | "adaptive" | "disabled" | "between_tools" BudgetTokens int `json:"budget_tokens,omitempty"` // max thinking tokens } diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 9cdcfc16b80c..6c2aeebba3a2 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -190,6 +190,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Opus 5", CreatedAt: "2026-07-25T00:00:00Z", }, + { + ID: "claude-sonnet-5-5", + Type: "model", + DisplayName: "Claude Sonnet 5.5", + CreatedAt: "2026-09-28T00:00:00Z", + }, { ID: "claude-sonnet-5", Type: "model", diff --git a/backend/internal/pkg/claude/constants_model_test.go b/backend/internal/pkg/claude/constants_model_test.go index 941a72d4863d..4a58d540d8e6 100644 --- a/backend/internal/pkg/claude/constants_model_test.go +++ b/backend/internal/pkg/claude/constants_model_test.go @@ -30,3 +30,16 @@ func TestDefaultModelsContainsOpus55(t *testing.T) { } t.Fatal("claude-opus-5-5 missing") } + +func TestDefaultModelsContainsSonnet55(t *testing.T) { + t.Parallel() + + for _, model := range DefaultModels { + if model.ID == "claude-sonnet-5-5" { + require.Equal(t, "Claude Sonnet 5.5", model.DisplayName) + require.Equal(t, "2026-09-28T00:00:00Z", model.CreatedAt) + return + } + } + t.Fatal("claude-sonnet-5-5 missing from DefaultModels") +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index 760281aa2e67..a3039faac744 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -19,6 +19,7 @@ var effortFamilies = []struct { {family: "claude-mythos-5", levels: effortLowMediumHighXHighMax}, {family: "claude-fable-5", levels: effortLowMediumHighXHighMax}, {family: "claude-sonnet-4-6", levels: effortLowMediumHighMax}, + {family: "claude-sonnet-5-5", levels: effortLowMediumHighXHighMax}, {family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, @@ -45,19 +46,30 @@ func IsOpus55(model string) bool { return normalizeEffortModelID(model) == "claude-opus-5-5" } +// IsSonnet55 identifies the fixed Sonnet 5.5 ID after provider/local suffix normalization. +func IsSonnet55(model string) bool { + return normalizeEffortModelID(model) == "claude-sonnet-5-5" +} + func normalizeEffortModelID(model string) string { id := strings.ToLower(strings.TrimSpace(model)) id = strings.TrimPrefix(id, "models/") if slash := strings.IndexByte(id, '/'); slash >= 0 { id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/") } + for _, prefix := range []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."} { + id = strings.TrimPrefix(id, prefix) + } id = strings.TrimPrefix(id, "anthropic.") id = strings.TrimSuffix(id, "-thinking") - // OpenRouter uses a dotted minor version for this exact Opus 5.5 ID. - // Normalize it before effort, thinking, and billing family lookups. + // OpenRouter uses dotted minor versions for some models. Normalize them + // before effort, thinking, and billing family lookups. if id == "claude-opus-5.5" { id = "claude-opus-5-5" } + if id == "claude-sonnet-5.5" { + id = "claude-sonnet-5-5" + } if mapped, ok := ModelIDReverseOverrides[id]; ok { id = mapped } diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go index 9bcd5206ee4f..e8f8e2f15ad3 100644 --- a/backend/internal/pkg/claude/effort_catalog_test.go +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -17,6 +17,8 @@ func TestEffortLevelsForModel(t *testing.T) { {model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}}, {model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "us.anthropic.claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}}, {model: "claude-haiku-4-5-20251001", want: nil}, {model: "gpt-5.6", want: nil}, @@ -38,3 +40,20 @@ func TestIsOpus55OpenRouterExactAlias(t *testing.T) { require.False(t, IsOpus55(model), model) } } + +func TestIsSonnet55(t *testing.T) { + t.Parallel() + for _, model := range []string{ + "claude-sonnet-5-5", + "anthropic/claude-sonnet-5.5", + "anthropic.claude-sonnet-5-5", + "us.anthropic.claude-sonnet-5-5", + "us-gov.anthropic.claude-sonnet-5-5", + "global.anthropic.claude-sonnet-5-5-thinking", + } { + require.True(t, IsSonnet55(model), model) + } + for _, model := range []string{"claude-sonnet-5", "claude-sonnet-5-5-preview", "claude-opus-5-5"} { + require.False(t, IsSonnet55(model), model) + } +} diff --git a/backend/internal/repository/content_moderation_repo.go b/backend/internal/repository/content_moderation_repo.go index 32b8e9150f74..20142bfd5e67 100644 --- a/backend/internal/repository/content_moderation_repo.go +++ b/backend/internal/repository/content_moderation_repo.go @@ -197,6 +197,8 @@ func (r *contentModerationRepository) CountFlaggedByUserSince(ctx context.Contex return 0, nil } // SQL 中的 'cyber_policy' 字面量须与 service.ContentModerationActionCyberPolicy 保持一致。 + // 'cyber_log_only' matches service.ContentModerationModeCyberLogOnly; these + // events remain evidence but never become penalties after allowlist removal. var count int err := r.db.QueryRowContext(ctx, ` WITH last_auto_ban AS ( @@ -209,6 +211,8 @@ FROM content_moderation_logs WHERE user_id = $1 AND flagged = TRUE AND action <> 'hash_block' + AND mode <> 'cyber_log_only' + AND mode <> 'risk_control_log_only' AND ($3::bool IS FALSE OR action <> 'cyber_policy') AND created_at >= $2 AND created_at > COALESCE((SELECT at FROM last_auto_ban), '-infinity'::timestamptz) diff --git a/backend/internal/repository/content_moderation_repo_test.go b/backend/internal/repository/content_moderation_repo_test.go index 2b32d09f97b1..7be148c14975 100644 --- a/backend/internal/repository/content_moderation_repo_test.go +++ b/backend/internal/repository/content_moderation_repo_test.go @@ -102,3 +102,18 @@ func TestContentModerationRepositoryCountFlaggedByUserSince_ExcludesCyberPolicyW require.Equal(t, 3, count) require.NoError(t, mock.ExpectationsWereMet()) } + +func TestContentModerationRepositoryCountFlaggedByUserSince_ExcludesCyberLogOnly(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + repo := NewContentModerationRepository(db) + since := time.Now().Add(-time.Hour) + mock.ExpectQuery(regexp.QuoteMeta("AND mode <> 'cyber_log_only'\n AND mode <> 'risk_control_log_only'")). + WithArgs(int64(12), since, false). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + count, err := repo.CountFlaggedByUserSince(context.Background(), 12, since, false) + require.NoError(t, err) + require.Zero(t, count) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index b43c6d56ef12..1ba7609fa4e0 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -1577,11 +1577,32 @@ func (s *UsageLogRepoSuite) TestGetUserUsageTrend() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(48 * time.Hour) - trend, err := s.repo.GetUserUsageTrend(s.ctx, startTime, endTime, "day", 10) + trend, err := s.repo.GetUserUsageTrend(s.ctx, startTime, endTime, "day", 10, "tokens") s.Require().NoError(err, "GetUserUsageTrend") s.Require().GreaterOrEqual(len(trend), 2) } +func (s *UsageLogRepoSuite) TestGetUserUsageTrend_SelectsTopByMetric() { + highTokens := mustCreateUser(s.T(), s.client, &service.User{Email: "tokens@test.com"}) + highSpend := mustCreateUser(s.T(), s.client, &service.User{Email: "spend@test.com"}) + tokenKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: highTokens.ID, Key: "sk-trend-tokens", Name: "tokens"}) + spendKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: highSpend.ID, Key: "sk-trend-spend", Name: "spend"}) + account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-trend-metric"}) + at := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + s.createUsageLog(highTokens, tokenKey, account, 1000, 0, 0.1, at) + s.createUsageLog(highSpend, spendKey, account, 10, 0, 5, at) + start, end := at.Add(-time.Hour), at.Add(time.Hour) + tokens, err := s.repo.GetUserUsageTrend(s.ctx, start, end, "day", 1, "tokens") + s.Require().NoError(err) + s.Require().Len(tokens, 1) + s.Require().Equal(highTokens.ID, tokens[0].UserID) + spend, err := s.repo.GetUserUsageTrend(s.ctx, start, end, "day", 1, "actual_cost") + s.Require().NoError(err) + s.Require().Len(spend, 1) + s.Require().Equal(highSpend.ID, spend[0].UserID) + s.Require().Equal(5.0, spend[0].ActualCost) +} + // --- GetAPIKeyUsageTrend --- func (s *UsageLogRepoSuite) TestGetAPIKeyUsageTrend() { diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 0b1bbe7d912d..cc7a46ffbf1d 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -82,8 +82,12 @@ func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, } // GetUserUsageTrend returns usage trend data grouped by user and date -func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) { +func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) (results []UserUsageTrendPoint, err error) { dateFormat := safeDateFormat(granularity) + rankExpr := "SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens)" + if metric == "actual_cost" { + rankExpr = "SUM(actual_cost)" + } query := fmt.Sprintf(` WITH top_users AS ( @@ -91,7 +95,7 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e FROM usage_logs WHERE created_at >= $1 AND created_at < $2 GROUP BY user_id - ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC + ORDER BY %s DESC, user_id ASC LIMIT $3 ) SELECT @@ -109,7 +113,7 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e AND u.created_at >= $4 AND u.created_at < $5 GROUP BY date, u.user_id, us.email, us.username ORDER BY date ASC, tokens DESC - `, dateFormat) + `, rankExpr, dateFormat) rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) if err != nil { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 9b8415650839..040cb0dcdf0e 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1005,6 +1005,7 @@ func TestAPIContracts(t *testing.T) { "model_plaza_description": "", "plugin_management_enabled": false, "risk_control_enabled": false, + "cyber_policy_user_allowlist": "", "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, "openai_refusal_recovery_enabled": false, @@ -1330,6 +1331,7 @@ func TestAPIContracts(t *testing.T) { "model_plaza_description": "", "plugin_management_enabled": false, "risk_control_enabled": false, + "cyber_policy_user_allowlist": "", "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, "openai_refusal_recovery_enabled": false, @@ -2733,7 +2735,7 @@ func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, e return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) { +func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) { return nil, errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 74770c2ef28b..cfa8f7c6071b 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -408,6 +408,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings) accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings) accounts.GET("/:id", h.Admin.Account.GetByID) + accounts.GET("/:id/claude/reset-credits", h.Admin.Account.ClaudeResetCredits) accounts.POST("", h.Admin.Account.Create) accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate) accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 0ef8275eaee0..7f111e2e239c 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -53,7 +53,7 @@ type UsageLogRepository interface { GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) - GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) + GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) diff --git a/backend/internal/service/account_usage_service_batch_test.go b/backend/internal/service/account_usage_service_batch_test.go index b4eb32ed27bf..06b9f10e00d8 100644 --- a/backend/internal/service/account_usage_service_batch_test.go +++ b/backend/internal/service/account_usage_service_batch_test.go @@ -76,7 +76,7 @@ func (r *usageBatchLogRepoStub) GetAllGroupUsageSummary(context.Context, time.Ti func (r *usageBatchLogRepoStub) GetAPIKeyUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, nil } -func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.UserUsageTrendPoint, error) { +func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int, string) ([]usagestats.UserUsageTrendPoint, error) { return nil, nil } func (r *usageBatchLogRepoStub) GetUserSpendingRanking(context.Context, time.Time, time.Time, int) (*usagestats.UserSpendingRankingResponse, error) { diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go new file mode 100644 index 000000000000..2397a5802dda --- /dev/null +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -0,0 +1,123 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, adapter := range []string{"anthropic", "native"} { + for _, options := range []string{"", `,"stream_options":{"include_usage":true}`, `,"stream_options":{"include_usage":false}`} { + for _, tc := range []struct { + name, start, delta, repeatDelta string + want bool + prompt, billableInput, totalInput, output, cached, created int + }{ + {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, "", true, 566, 308, 566, 49, 241, 17}, + {"deepseek_partial_cache", "", `,"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, "", true, 1200, 400, 1200, 30, 800, 0}, + {"openai_partial_cache", "", `,"usage":{"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}}`, "", true, 1200, 400, 1200, 30, 800, 0}, + {"repeated_authoritative_cache_bucket", `,"usage":{"input_tokens":1200,"prompt_tokens":1200}`, `,"usage":{"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, + {"independent_input_with_late_cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"input_tokens":0,"output_tokens":49,"cache_read_input_tokens":300,"cache_creation_input_tokens":17}`, "", true, 625, 308, 625, 49, 300, 17}, + {"kimi_full_cache", `,"usage":{"input_tokens":173306,"prompt_tokens":173306}`, `,"usage":{"input_tokens":0,"output_tokens":49,"prompt_tokens":173306,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306,"prompt_tokens":173306}`, `,"usage":{"input_tokens":0,"output_tokens":49,"prompt_tokens":173306,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306,"prompt_tokens":173306}`, `,"usage":{"input_tokens":0,"output_tokens":49,"prompt_tokens":173306,"prompt_tokens_details":{"cached_tokens":173306}}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", "", true, 0, 0, 0, 0, 0, 0}, + {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0, 0, 0}, + {"absent", "", "", "", false, 0, 0, 0, 0, 0, 0}, + {"null", `,"usage":null`, `,"usage":null`, "", false, 0, 0, 0, 0, 0, 0}, + } { + for _, terminal := range []string{"stop", "eof"} { + t.Run(adapter+"/"+options+"/"+tc.name+"/"+terminal, func(t *testing.T) { + sse := fmt.Sprintf("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k3\",\"content\":[]%s}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.start, tc.delta) + if tc.repeatDelta != "" { + sse += fmt.Sprintf("event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.repeatDelta) + } + if terminal == "stop" { + // Repeated terminal events and EOF must not duplicate usage. + sse += "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + body := []byte(`{"model":"k3","messages":[{"role":"user","content":"hi"}],"stream":true` + options + "}") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + if adapter == "native" { + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + result, err := svc.forwardChatCompletionsViaNativeAnthropic(context.Background(), c, nativeAnthropicTestAccount(), body, "") + require.NoError(t, err) + require.Equal(t, tc.totalInput, result.Usage.InputTokens) + require.Equal(t, tc.output, result.Usage.OutputTokens) + require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) + require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) + } else { + svc := &GatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, nil) + require.NoError(t, err) + require.Equal(t, tc.billableInput, result.Usage.InputTokens) + require.Equal(t, tc.output, result.Usage.OutputTokens) + require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) + require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) + } + count, done := 0, 0 + var last *apicompat.ChatCompletionsChunk + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + done++ + if tc.want { + require.NotNil(t, last) + require.NotNil(t, last.Usage) + } + continue + } + require.Zero(t, done, "chunk after DONE") + var chunk apicompat.ChatCompletionsChunk + require.NoError(t, json.Unmarshal([]byte(payload), &chunk)) + last = &chunk + if chunk.Usage == nil { + continue + } + count++ + require.Empty(t, chunk.Choices) + require.Equal(t, tc.prompt, chunk.Usage.PromptTokens) + require.Equal(t, tc.output, chunk.Usage.CompletionTokens) + require.Equal(t, tc.prompt+tc.output, chunk.Usage.TotalTokens) + if tc.cached > 0 { + require.NotNil(t, chunk.Usage.PromptTokensDetails) + require.Equal(t, tc.cached, chunk.Usage.PromptTokensDetails.CachedTokens) + require.Equal(t, tc.created, chunk.Usage.PromptTokensDetails.CacheCreationTokens) + } + } + require.Equal(t, 1, done) + if tc.want { + require.Equal(t, 1, count) + } else { + require.Zero(t, count) + } + }) + } + } + } + } +} diff --git a/backend/internal/service/antigravity_gateway_compat_stream.go b/backend/internal/service/antigravity_gateway_compat_stream.go index a55d29fdfd64..217aaa17c2fe 100644 --- a/backend/internal/service/antigravity_gateway_compat_stream.go +++ b/backend/internal/service/antigravity_gateway_compat_stream.go @@ -138,15 +138,23 @@ type antigravityCompatScanEvent struct { } type antigravityCompatStreamSession struct { - processor *antigravity.StreamingProcessor - adapter antigravityCompatStreamAdapter - writer *antigravityClientWriter - usage *ClaudeUsage - pendingEvents []apicompat.AnthropicStreamEvent - firstTokenMs *int - startTime time.Time - meaningfulData bool -} + processor *antigravity.StreamingProcessor + adapter antigravityCompatStreamAdapter + writer *antigravityClientWriter + usage *ClaudeUsage + pendingEvents []apicompat.AnthropicStreamEvent + firstTokenMs *int + startTime time.Time + meaningfulData bool + preContentKeepaliveSent bool + preContentKeepaliveInterval time.Duration +} + +const ( + antigravityCompatPreContentKeepaliveInterval = 15 * time.Second + // Allow slow first tokens, but do not let comment-only streams reset the idle timer forever. + antigravityCompatPreContentMaxWait = 2 * time.Minute +) func newAntigravityCompatStreamSession( model string, @@ -155,11 +163,12 @@ func newAntigravityCompatStreamSession( writer *antigravityClientWriter, ) *antigravityCompatStreamSession { return &antigravityCompatStreamSession{ - processor: antigravity.NewStreamingProcessor(model), - adapter: adapter, - writer: writer, - usage: &ClaudeUsage{}, - startTime: startTime, + processor: antigravity.NewStreamingProcessor(model), + adapter: adapter, + writer: writer, + usage: &ClaudeUsage{}, + startTime: startTime, + preContentKeepaliveInterval: antigravityCompatPreContentKeepaliveInterval, } } @@ -175,11 +184,26 @@ func (s *antigravityCompatStreamSession) hasMeaningfulData() bool { return s.meaningfulData || s.processor.HasContent() } +func (s *antigravityCompatStreamSession) writePreContentKeepalive(now time.Time) { + // Intentional tradeoff: this commits HTTP 200 after 15s, so later upstream + // failures cannot fail over; report them as SSE errors to the client instead. + if s.hasMeaningfulData() || s.writer.Disconnected() || now.Sub(s.startTime) < s.preContentKeepaliveInterval { + return + } + if s.writer.Write([]byte(": ping\n\n")) { + s.preContentKeepaliveSent = true + } +} + func (s *antigravityCompatStreamSession) finish() (*antigravityStreamResult, error) { finalEvents, usage := s.processor.Finish() mergeAntigravityCompatUsage(s.usage, usage) s.consumeClaudeEvents(finalEvents) if !s.hasMeaningfulData() && !s.writer.Disconnected() { + if s.preContentKeepaliveSent { + s.adapter.WriteError(s.writer, "empty_stream") + return s.result(false), errors.New("empty Antigravity compatibility stream after keepalive") + } return nil, antigravityCompatEmptyStreamError() } s.adapter.Finalize(s.writer) @@ -288,6 +312,13 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( originalModel string, adapter antigravityCompatStreamAdapter, prefix string, +) (*antigravityStreamResult, error) { + return s.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, startTime, originalModel, adapter, prefix, antigravityCompatPreContentKeepaliveInterval, antigravityCompatPreContentMaxWait) +} + +func (s *AntigravityGatewayService) handleAntigravityCompatStreamWithKeepaliveInterval( + c *gin.Context, resp *http.Response, startTime time.Time, originalModel string, + adapter antigravityCompatStreamAdapter, prefix string, interval, maxPreContentWait time.Duration, ) (*antigravityStreamResult, error) { flusher, ok := c.Writer.(http.Flusher) if !ok { @@ -303,6 +334,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( c.Status(http.StatusOK) } session := newAntigravityCompatStreamSession(originalModel, startTime, adapter, writer) + session.preContentKeepaliveInterval = interval events, stopScanner, maxLineSize := s.startAntigravityCompatScanner(resp.Body) defer stopScanner() @@ -315,13 +347,36 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( if keepaliveTicker != nil { defer keepaliveTicker.Stop() } + preContentDelay := time.Until(startTime.Add(interval)) + if preContentDelay < 0 { + preContentDelay = 0 + } + preContentTimer := time.NewTimer(preContentDelay) + defer preContentTimer.Stop() + preContentDeadline := startTime.Add(maxPreContentWait) + deadlineDelay := time.Until(preContentDeadline) + if deadlineDelay < 0 { + deadlineDelay = 0 + } + deadlineTimer := time.NewTimer(deadlineDelay) + defer deadlineTimer.Stop() + preContentTimeout := func() (*antigravityStreamResult, error) { + if session.preContentKeepaliveSent { + writeAntigravityCompatStreamError(c, adapter, writer, "stream_timeout") + return session.collectResult(false), fmt.Errorf("pre-content stream timeout") + } + return nil, antigravityCompatEmptyStreamError() + } for { select { case event, open := <-events: + if !session.hasMeaningfulData() && !writer.Disconnected() && !time.Now().Before(preContentDeadline) { + return preContentTimeout() + } if !open { if !session.hasMeaningfulData() && !writer.Disconnected() { - return nil, antigravityCompatEmptyStreamError() + return handleAntigravityCompatEmptyStream(c, session) } result, finishErr := session.finish() if err := adapter.Err(); err != nil { @@ -348,7 +403,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( return session.collectResult(true), nil } if !session.hasMeaningfulData() { - return nil, antigravityCompatEmptyStreamError() + return preContentTimeout() } logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (%s)", prefix) writeAntigravityCompatStreamError(c, adapter, writer, "stream_timeout") @@ -358,10 +413,30 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( if session.hasMeaningfulData() && !writer.Disconnected() && adapter.Err() == nil { writer.Write([]byte(": ping\n\n")) } + case now := <-preContentTimer.C: + if !session.hasMeaningfulData() && !writer.Disconnected() && !now.Before(preContentDeadline) { + return preContentTimeout() + } + session.writePreContentKeepalive(now) + if !session.hasMeaningfulData() && !writer.Disconnected() { + preContentTimer.Reset(interval) + } + case <-deadlineTimer.C: + if !session.hasMeaningfulData() && !writer.Disconnected() { + return preContentTimeout() + } } } } +func handleAntigravityCompatEmptyStream(c *gin.Context, session *antigravityCompatStreamSession) (*antigravityStreamResult, error) { + if session.preContentKeepaliveSent { + writeAntigravityCompatStreamError(c, session.adapter, session.writer, "empty_stream") + return session.collectResult(false), errors.New("empty Antigravity compatibility stream after keepalive") + } + return nil, antigravityCompatEmptyStreamError() +} + func (s *AntigravityGatewayService) startAntigravityCompatScanner( body io.Reader, ) (<-chan antigravityCompatScanEvent, func(), int) { @@ -449,6 +524,10 @@ func (s *AntigravityGatewayService) handleAntigravityCompatReadError( return session.collectResult(session.writer.Disconnected()), protocolErr } if !session.hasMeaningfulData() && !session.writer.Disconnected() { + if session.preContentKeepaliveSent { + writeAntigravityCompatStreamError(c, session.adapter, session.writer, "stream_read_error") + return session.collectResult(false), fmt.Errorf("stream read error: %w", err) + } return nil, antigravityCompatEmptyStreamError() } if disconnect, handled := handleStreamReadError(err, session.writer.Disconnected(), prefix); handled { diff --git a/backend/internal/service/antigravity_gateway_compat_test.go b/backend/internal/service/antigravity_gateway_compat_test.go index 8912251c410b..825997e4f3b3 100644 --- a/backend/internal/service/antigravity_gateway_compat_test.go +++ b/backend/internal/service/antigravity_gateway_compat_test.go @@ -28,6 +28,20 @@ type antigravityCompatErrorReader struct { err error } +type antigravityCompatNotifyingWriter struct { + gin.ResponseWriter + wrote chan struct{} +} + +func (w *antigravityCompatNotifyingWriter) Write(data []byte) (int, error) { + n, err := w.ResponseWriter.Write(data) + select { + case w.wrote <- struct{}{}: + default: + } + return n, err +} + func (r *antigravityCompatErrorReader) Read(p []byte) (int, error) { if r.off < len(r.data) { n := copy(p, r.data[r.off:]) @@ -786,3 +800,230 @@ func TestAntigravityCompatKeepaliveAfterFirstEvent(t *testing.T) { require.Contains(t, recorder.Header().Get("Content-Type"), "text/event-stream") require.NoError(t, reader.Close()) } + +func TestAntigravityCompatPreContentKeepalive(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + adapter antigravityCompatStreamAdapter + want string + }{ + {"chat completions", newAntigravityChatStreamAdapter("gemini-3.1-pro", false), "data: "}, + {"responses", newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "event: "}, + } { + t.Run(tt.name, func(t *testing.T) { + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + writer := newAntigravityClientWriter(c.Writer, c.Writer, "test") + writer.beforeFirstWrite = func() { c.Header("Content-Type", "text/event-stream") } + start := time.Now().Add(-20 * time.Second) + session := newAntigravityCompatStreamSession("gemini-3.1-pro", start, tt.adapter, writer) + session.writePreContentKeepalive(start.Add(14 * time.Second)) + require.Empty(t, recorder.Body.String()) + session.consumeClaudeData("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant"}}`) + require.False(t, session.hasMeaningfulData()) + session.writePreContentKeepalive(start.Add(14 * time.Second)) + require.Empty(t, recorder.Body.String()) + session.writePreContentKeepalive(start.Add(15 * time.Second)) + require.Equal(t, ": ping\n\n", recorder.Body.String()) + require.Nil(t, session.firstTokenMs) + session.consumeClaudeData("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`) + require.True(t, session.hasMeaningfulData()) + require.NotNil(t, session.firstTokenMs) + require.GreaterOrEqual(t, *session.firstTokenMs, 20000) + require.Contains(t, recorder.Body.String(), tt.want) + require.Greater(t, strings.Index(recorder.Body.String(), tt.want), strings.Index(recorder.Body.String(), ": ping")) + before := recorder.Body.String() + session.writePreContentKeepalive(start.Add(30 * time.Second)) + require.Equal(t, before, recorder.Body.String()) + }) + } +} + +func TestAntigravityCompatHandlerPreContentKeepalive(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + adapter func() antigravityCompatStreamAdapter + line string + want string + }{ + {"silent chat", func() antigravityCompatStreamAdapter { return newAntigravityChatStreamAdapter("gemini-3.1-pro", false) }, "", `"upstream_error"`}, + {"signature-only responses", func() antigravityCompatStreamAdapter { return newAntigravityResponsesStreamAdapter("gemini-3.1-pro") }, `data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"sig","text":""}]}}]}}` + "\n\n", "event: error"}, + } { + t.Run(tt.name, func(t *testing.T) { + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 30}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + notifier := &antigravityCompatNotifyingWriter{ResponseWriter: c.Writer, wrote: make(chan struct{}, 1)} + c.Writer = notifier + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + done := make(chan error, 1) + go func() { + _, err := svc.handleAntigravityCompatStream(c, resp, time.Now().Add(-antigravityCompatPreContentKeepaliveInterval+200*time.Millisecond), "gemini-3.1-pro", tt.adapter(), "test") + done <- err + }() + if tt.line != "" { + _, err := io.WriteString(pipeWriter, tt.line) + require.NoError(t, err) + } + select { + case <-notifier.wrote: + case <-time.After(2 * time.Second): + _ = pipeWriter.Close() + t.Fatal("no pre-content keepalive before read timeout") + } + require.Equal(t, ": ping\n\n", recorder.Body.String()) + require.NoError(t, pipeWriter.Close()) + require.Error(t, <-done) + require.Contains(t, recorder.Body.String(), tt.want) + require.True(t, IsResponseCommitted(c)) + }) + } +} + +func TestAntigravityCompatHandlerRepeatsPreContentKeepalive(t *testing.T) { + svc := newAntigravityCompatService(config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 30, StreamKeepaliveInterval: 0, + }, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + done := make(chan error, 1) + go func() { + _, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval( + c, resp, time.Now().Add(-15*time.Millisecond), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 15*time.Millisecond, time.Second, + ) + done <- err + }() + time.Sleep(55 * time.Millisecond) + require.NoError(t, pipeWriter.Close()) + require.Error(t, <-done) + require.GreaterOrEqual(t, strings.Count(recorder.Body.String(), ": ping\n\n"), 3) + require.Contains(t, recorder.Body.String(), "event: error") + require.True(t, IsResponseCommitted(c)) +} + +func TestAntigravityCompatHandlerPreContentDeadlineWithCommentOnlyStream(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 1}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + done := make(chan error, 1) + go func() { + _, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, time.Now(), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 10*time.Millisecond, 100*time.Millisecond) + done <- err + }() + stop := make(chan struct{}) + defer close(stop) + go func() { + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + if _, err := io.WriteString(pipeWriter, ": upstream ping\n\n"); err != nil { + return + } + } + } + }() + select { + case err := <-done: + require.ErrorContains(t, err, "pre-content stream timeout") + case <-time.After(time.Second): + t.Fatal("comment-only stream exceeded pre-content deadline") + } + require.Contains(t, recorder.Body.String(), ": ping\n\n") + require.Contains(t, recorder.Body.String(), "event: error") + require.True(t, IsResponseCommitted(c)) + _ = pipeWriter.Close() +} + +func TestAntigravityCompatExpiredPreContentDeadlineDoesNotCommit(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + defer func() { _ = pipeWriter.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + result, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, + time.Now().Add(-time.Minute), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 10*time.Millisecond, 20*time.Millisecond) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Empty(t, recorder.Body.String()) + require.False(t, IsResponseCommitted(c)) +} + +func TestAntigravityCompatHandlerErrorsAfterPreContentPing(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + wait time.Duration + want string + }{ + {"read error", time.Second, "stream_read_error"}, + {"timeout", 25 * time.Millisecond, "stream_timeout"}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + defer func() { _ = pipeWriter.Close() }() + if tc.name == "read error" { + go func() { + time.Sleep(15 * time.Millisecond) + _ = pipeWriter.CloseWithError(io.ErrUnexpectedEOF) + }() + } + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + _, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, + time.Now().Add(-20*time.Millisecond), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 10*time.Millisecond, tc.wait) + require.Error(t, err) + require.Contains(t, recorder.Body.String(), ": ping\n\n") + require.Contains(t, recorder.Body.String(), tc.want) + require.True(t, IsResponseCommitted(c)) + }) + } +} + +func TestAntigravityCompatEmptyAfterKeepaliveReportsStreamError(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + adapter antigravityCompatStreamAdapter + want string + }{ + {"chat completions", newAntigravityChatStreamAdapter("gemini-3.1-pro", false), `"upstream_error"`}, + {"responses", newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "event: error"}, + } { + t.Run(tt.name, func(t *testing.T) { + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + writer := newAntigravityClientWriter(c.Writer, c.Writer, "test") + writer.beforeFirstWrite = func() { c.Header("Content-Type", "text/event-stream") } + start := time.Now().Add(-20 * time.Second) + session := newAntigravityCompatStreamSession("gemini-3.1-pro", start, tt.adapter, writer) + session.consumeClaudeData("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant"}}`) + session.writePreContentKeepalive(start.Add(15 * time.Second)) + result, err := handleAntigravityCompatEmptyStream(c, session) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) + require.NotNil(t, result) + require.True(t, IsResponseCommitted(c)) + require.Contains(t, recorder.Body.String(), tt.want) + }) + } +} diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go index d8f48541de43..4fc7fd7213a5 100644 --- a/backend/internal/service/bedrock_request.go +++ b/backend/internal/service/bedrock_request.go @@ -127,8 +127,17 @@ func normalizeBedrockModelID(modelID string) (normalized string, shouldAdjustReg return "", false, false } if mapped, exists := domain.DefaultBedrockModelMapping[modelID]; exists { + // Sonnet 5.5 currently has only a global inference profile on + // bedrock-runtime. A caller's AWS region selects the endpoint, but must + // not rewrite the profile ID to a regional one that does not exist. + if mapped == "global.anthropic.claude-sonnet-5-5" { + return mapped, false, true + } return mapped, true, true } + if modelID == "global.anthropic.claude-sonnet-5-5" { + return modelID, false, true + } if isRegionalBedrockModelID(modelID) { return modelID, true, true } @@ -181,7 +190,7 @@ func BuildBedrockURL(region, modelID string, stream bool) string { // PrepareBedrockRequestBody 处理请求体以适配 Bedrock API // 1. 注入 anthropic_version // 2. 注入 anthropic_beta(从客户端 anthropic-beta 头解析) -// 3. 移除 Bedrock 不支持的字段(model, stream, output_format, output_config) +// 3. 移除 Bedrock 不支持的字段(model, stream, output_format);Sonnet 5.5 保留 output_config.effort // 4. 移除工具定义中的 custom 字段(Claude Code 会发送 custom: {defer_loading: true}) // 5. 清理 cache_control 中 Bedrock 不支持的字段(scope, ttl) // 6. 修复 thinking 字段兼容性(Opus 4.7 仅支持 adaptive,enabled 需要 budget_tokens) @@ -240,10 +249,20 @@ func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens // 参考 litellm: _convert_output_format_to_inline_schema() body = convertOutputFormatToInlineSchema(body) - // 移除 output_config 字段(Bedrock Invoke 不支持) - body, err = sjson.DeleteBytes(body, "output_config") + // InvokeModel accepts output_config.effort for Sonnet 5.5. Keep just that + // field; output_config.format has already been inlined above, and older + // models retain the existing output_config stripping behavior. + if claude.IsSonnet55(modelID) { + if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() { + body, err = sjson.SetRawBytes(body, "output_config", []byte(`{"effort":`+effort.Raw+`}`)) + } else { + body, err = sjson.DeleteBytes(body, "output_config") + } + } else { + body, err = sjson.DeleteBytes(body, "output_config") + } if err != nil { - return nil, fmt.Errorf("remove output_config field: %w", err) + return nil, fmt.Errorf("normalize output_config field: %w", err) } // 移除工具定义中的 custom 字段 @@ -270,17 +289,21 @@ func ResolveBedrockBetaTokens(betaHeader string, body []byte, modelID string) [] return filterBedrockBetaTokens(betaTokens) } -// convertOutputFormatToInlineSchema 将 output_format 中的 JSON schema 内联到最后一条 user message +// convertOutputFormatToInlineSchema 将结构化输出的 JSON schema 内联到最后一条 user message // Bedrock Invoke 不支持 output_format 参数,litellm 的做法是将 schema 追加到用户消息中 // 参考: litellm AmazonAnthropicClaudeMessagesConfig._convert_output_format_to_inline_schema() func convertOutputFormatToInlineSchema(body []byte) []byte { - outputFormat := gjson.GetBytes(body, "output_format") + outputFormat := gjson.GetBytes(body, "output_config.format") + if !outputFormat.Exists() { + outputFormat = gjson.GetBytes(body, "output_format") + } if !outputFormat.Exists() || !outputFormat.IsObject() { return body } - // 先从请求体中移除 output_format + // 先从请求体中移除两个版本的结构化输出字段。 body, _ = sjson.DeleteBytes(body, "output_format") + body, _ = sjson.DeleteBytes(body, "output_config.format") schema := outputFormat.Get("schema") if !schema.Exists() { @@ -707,6 +730,7 @@ func isBedrockFable5(modelID string) bool { const defaultThinkingBudgetTokens = 10000 // sanitizeBedrockThinking 修复 thinking 字段的 Bedrock 兼容性问题: +// - Sonnet 5.5: enabled 改为 adaptive;disabled 改为 between_tools // - Fable 5: 仅使用 always-on adaptive thinking,不支持手动 budget_tokens // - Opus 4.7+: 仅支持 "adaptive",将 "enabled" 转换为 "adaptive" 并移除 budget_tokens // - 其他模型: "enabled" 必须带 budget_tokens,缺失时补充默认值 @@ -731,6 +755,17 @@ func sanitizeBedrockThinking(body []byte, modelID string) []byte { return body } + if claude.IsSonnet55(modelID) { + switch thinkingType { + case "enabled": + body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") + body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens") + case "disabled": + body, _ = sjson.SetBytes(body, "thinking.type", "between_tools") + } + return body + } + if isBedrockOpus47OrNewer(modelID) { if thinkingType == "enabled" { body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") diff --git a/backend/internal/service/bedrock_request_test.go b/backend/internal/service/bedrock_request_test.go index 71c954f44ce5..20b8eff3374a 100644 --- a/backend/internal/service/bedrock_request_test.go +++ b/backend/internal/service/bedrock_request_test.go @@ -482,6 +482,37 @@ func TestBedrockCrossRegionPrefix(t *testing.T) { } func TestResolveBedrockModelID(t *testing.T) { + t.Run("sonnet 5.5 uses the global inference profile in every region", func(t *testing.T) { + for _, region := range []string{"us-east-1", "eu-west-1", "ap-southeast-2"} { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeBedrock, + Credentials: map[string]any{ + "aws_region": region, + }, + } + modelID, ok := ResolveBedrockModelID(account, "claude-sonnet-5-5") + require.True(t, ok, region) + assert.Equal(t, "global.anthropic.claude-sonnet-5-5", modelID, region) + } + }) + + t.Run("explicit global sonnet 5.5 profile is not rewritten", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeBedrock, + Credentials: map[string]any{ + "aws_region": "eu-west-1", + "model_mapping": map[string]any{ + "public-sonnet": "global.anthropic.claude-sonnet-5-5", + }, + }, + } + modelID, ok := ResolveBedrockModelID(account, "public-sonnet") + require.True(t, ok) + assert.Equal(t, "global.anthropic.claude-sonnet-5-5", modelID) + }) + t.Run("default alias resolves and adjusts region", func(t *testing.T) { account := &Account{ Platform: PlatformAnthropic, @@ -779,6 +810,19 @@ func TestIsBedrockOpus47OrNewer(t *testing.T) { } func TestSanitizeBedrockThinking(t *testing.T) { + t.Run("sonnet 5.5 converts legacy enabled to adaptive", func(t *testing.T) { + input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}` + result := sanitizeBedrockThinking([]byte(input), "global.anthropic.claude-sonnet-5-5") + assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String()) + assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists()) + }) + + t.Run("sonnet 5.5 converts legacy disabled to between_tools", func(t *testing.T) { + input := `{"thinking":{"type":"disabled"},"messages":[]}` + result := sanitizeBedrockThinking([]byte(input), "global.anthropic.claude-sonnet-5-5") + assert.Equal(t, "between_tools", gjson.GetBytes(result, "thinking.type").String()) + }) + t.Run("Fable 5 将 enabled 转换为 adaptive 并移除预算", func(t *testing.T) { input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}` result := sanitizeBedrockThinking([]byte(input), "anthropic.claude-fable-5") @@ -1000,6 +1044,20 @@ func TestPrepareBedrockRequestBodyWithTokens_CCCompat(t *testing.T) { }) } +func TestPrepareBedrockSonnet55PreservesEffort(t *testing.T) { + input := []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"output_config":{"effort":"medium","format":{"type":"json_schema","schema":{"type":"object"}}},"messages":[{"role":"user","content":"hello"}]}`) + result, err := PrepareBedrockRequestBodyWithTokens(input, "global.anthropic.claude-sonnet-5-5", nil, false) + require.NoError(t, err) + assert.Equal(t, "medium", gjson.GetBytes(result, "output_config.effort").String()) + assert.False(t, gjson.GetBytes(result, "output_config.format").Exists()) + assert.Contains(t, gjson.GetBytes(result, "messages.0.content.1.text").String(), `"type":"object"`, "schema should still be included in the final user message") + assert.False(t, gjson.GetBytes(result, "model").Exists()) + + oldModel, err := PrepareBedrockRequestBodyWithTokens(input, "us.anthropic.claude-sonnet-4-6", nil, false) + require.NoError(t, err) + assert.False(t, gjson.GetBytes(oldModel, "output_config").Exists()) +} + func TestSanitizeBedrockCCFields(t *testing.T) { t.Run("removes service_tier and interface_geo", func(t *testing.T) { body := []byte(`{"model":"claude-opus-4-6","service_tier":"standard","interface_geo":"us","messages":[]}`) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index f26df8f435e6..1377132746a1 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -363,7 +363,15 @@ func (s *BillingService) initFallbackPricing() { CacheCreation1hPrice: 8e-6, SupportsCacheBreakdown: true, } - + s.fallbackPrices["claude-sonnet-5-5"] = &ModelPricing{ + InputPricePerToken: 2e-6, + OutputPricePerToken: 10e-6, + CacheCreationPricePerToken: 2.5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheCreation5mPrice: 2.5e-6, + CacheCreation1hPrice: 4e-6, + SupportsCacheBreakdown: true, + } // Claude Fable 5.x uses the same input/output and cache-write prices, while // Fable 5.1 reduces cache reads from $1 to $0.25 per MTok. s.fallbackPrices["claude-fable-5"] = &ModelPricing{ @@ -883,6 +891,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if claude.IsOpus55(modelLower) { return s.fallbackPrices["claude-opus-5-5"] } + if claude.IsSonnet55(modelLower) { + return s.fallbackPrices["claude-sonnet-5-5"] + } if strings.Contains(modelLower, "opus") { // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index e79e68391f6d..58145f07c5af 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -2020,6 +2020,26 @@ func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) { } }) } + for _, model := range []string{ + "claude-sonnet-5-5", + "anthropic/claude-sonnet-5.5", + "us.anthropic.claude-sonnet-5-5", + } { + t.Run(source+"/"+model, func(t *testing.T) { + tokens := UsageTokens{ + InputTokens: 100_000, OutputTokens: 500, + CacheReadTokens: 1000, CacheCreationTokens: 1000, + CacheCreation5mTokens: 400, CacheCreation1hTokens: 600, + } + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + require.InDelta(t, 100_000*2e-6, cost.InputCost, 1e-10) + require.InDelta(t, 400*2.5e-6+600*4e-6, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 1000*0.2e-6, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*10e-6, cost.OutputCost, 1e-10) + require.False(t, cost.LongContextBillingApplied) + }) + } } } diff --git a/backend/internal/service/claude_reset_credits.go b/backend/internal/service/claude_reset_credits.go new file mode 100644 index 000000000000..059aaa4b5eca --- /dev/null +++ b/backend/internal/service/claude_reset_credits.go @@ -0,0 +1,204 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" +) + +var claudeResetGrantIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,40}$`) + +const claudeResetUsageURL = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1" + +// ClaudeResetCredit is deliberately free of upstream grant and organization IDs. +type ClaudeResetCredit struct { + Label string `json:"label"` + ResetsLeft int `json:"resets_left"` + StartsAt *time.Time `json:"starts_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Clears []string `json:"clears"` + PercentUsed map[string]float64 `json:"percent_used"` + Blocking []string `json:"blocking"` + UseRequiresLimit bool `json:"use_requires_limit"` + Redeemable bool `json:"redeemable"` +} + +type ClaudeResetCredits struct { + Eligible bool `json:"eligible"` + AvailableCount int `json:"available_count"` + Credits []ClaudeResetCredit `json:"credits"` + CooldownUntil *time.Time `json:"cooldown_until,omitempty"` + WeeklyResetsAt *time.Time `json:"weekly_resets_at,omitempty"` + FetchedAt time.Time `json:"fetched_at"` +} + +type claudeResetGrant struct { + ID string `json:"id"` + Label string `json:"label"` + ResetsLeft int `json:"resets_left"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + Clears []string `json:"clears"` + Paused bool `json:"paused"` + UsableNow bool `json:"usable_now"` + UseRequiresLimit *bool `json:"use_requires_limit"` + PercentUsed map[string]float64 `json:"percent_used"` + Blocking []string `json:"blocking"` +} +type claudeResetBlock struct { + Eligible bool `json:"eligible"` + AtLimit bool `json:"at_limit"` + Grants []claudeResetGrant `json:"grants"` + NextGrantID string `json:"next_grant_id"` + CooldownUntil *time.Time `json:"cooldown_until"` + WeeklyResetsAt *time.Time `json:"weekly_resets_at"` +} + +type claudeResetAccounts interface { + GetByID(context.Context, int64) (*Account, error) +} +type claudeResetTokens interface { + GetAccessToken(context.Context, *Account) (string, error) +} + +type ClaudeResetCreditService struct { + accounts claudeResetAccounts + tokens claudeResetTokens + proxies ProxyRepository + settings *SettingService + do func(*http.Request, string) (*http.Response, error) + now func() time.Time +} + +func NewClaudeResetCreditService(accounts AccountRepository, tokens *ClaudeTokenProvider, proxies ProxyRepository, settings *SettingService) *ClaudeResetCreditService { + s := &ClaudeResetCreditService{accounts: accounts, tokens: tokens, proxies: proxies, settings: settings, now: time.Now} + s.do = func(req *http.Request, proxy string) (*http.Response, error) { + client, err := httpclient.GetClient(httpclient.Options{ProxyURL: proxy, Timeout: 25 * time.Second, ValidateResolvedIP: true}) + if err != nil { + return nil, err + } + // Never forward OAuth credentials across redirects, even to another public host. + isolated := *client + isolated.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return isolated.Do(req) + } + return s +} + +func (s *ClaudeResetCreditService) account(ctx context.Context, id int64) (*Account, string, string, error) { + a, err := s.accounts.GetByID(ctx, id) + if err != nil { + return nil, "", "", err + } + if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeOAuth { + return nil, "", "", infraerrors.BadRequest("CLAUDE_RESET_OAUTH_REQUIRED", "Claude OAuth account required") + } + profile := false + for _, scope := range strings.Fields(a.GetCredential("scope")) { + if scope == "user:profile" { + profile = true + } + } + if !profile { + return nil, "", "", infraerrors.BadRequest("CLAUDE_RESET_PROFILE_SCOPE_REQUIRED", "user:profile scope required") + } + proxy := "" + if a.ProxyID != nil { + p, e := s.proxies.GetByID(ctx, *a.ProxyID) + if e != nil || p == nil { + return nil, "", "", infraerrors.ServiceUnavailable("CLAUDE_RESET_PROXY_UNAVAILABLE", "account proxy unavailable") + } + proxy = p.URL() + } + token, err := s.tokens.GetAccessToken(ctx, a) + if err != nil || strings.TrimSpace(token) == "" { + return nil, "", "", infraerrors.ServiceUnavailable("CLAUDE_RESET_TOKEN_UNAVAILABLE", "OAuth token unavailable") + } + return a, token, proxy, nil +} + +func (s *ClaudeResetCreditService) headers(ctx context.Context, req *http.Request, token string) { + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + req.Header.Set("x-app", "cli") + req.Header.Set("User-Agent", "claude-cli/"+s.settings.GetClaudeCodeClientVersion(ctx)+" (external, cli)") +} + +func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, error) { + _, token, proxy, err := s.account(ctx, id) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil) + if err != nil { + return nil, err + } + s.headers(ctx, req, token) + resp, err := s.do(req, proxy) + if err != nil { + return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed") + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode)) + } + var envelope map[string]json.RawMessage + if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil || envelope == nil { + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") + } + if _, ok := envelope["error"]; ok { + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status") + } + var block *claudeResetBlock + raw, present := envelope["cedar_ember"] + if present && string(raw) != "null" { + if err = json.Unmarshal(raw, &block); err != nil || block == nil || block.Grants == nil { + return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants") + } + } + return projectClaudeResetCredits(block, s.now()), nil +} + +func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) { + return s.query(ctx, id) +} + +func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetCredits { + r := &ClaudeResetCredits{Credits: []ClaudeResetCredit{}, FetchedAt: now.UTC()} + if b == nil { + return r + } + r.Eligible = b.Eligible + if b.CooldownUntil != nil && now.Before(*b.CooldownUntil) { + r.CooldownUntil = b.CooldownUntil + } + r.WeeklyResetsAt = b.WeeklyResetsAt + for _, g := range b.Grants { + if !claudeResetGrantIDPattern.MatchString(g.ID) || len(g.Clears) == 0 || g.ResetsLeft <= 0 || g.Paused || (g.StartsAt != nil && now.Before(*g.StartsAt)) || (g.EndsAt != nil && !now.Before(*g.EndsAt)) { + continue + } + requires := g.UseRequiresLimit == nil || *g.UseRequiresLimit + usable := b.Eligible && g.UsableNow && g.ID == b.NextGrantID && (!requires || b.AtLimit) && len(g.Blocking) == 0 && (b.CooldownUntil == nil || !now.Before(*b.CooldownUntil)) + used := map[string]float64{} + for k, v := range g.PercentUsed { + if v >= 0 && v <= 100 { + used[k] = v + } + } + r.Credits = append(r.Credits, ClaudeResetCredit{Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable}) + if usable { + r.AvailableCount += g.ResetsLeft + } + } + return r +} diff --git a/backend/internal/service/claude_reset_credits_test.go b/backend/internal/service/claude_reset_credits_test.go new file mode 100644 index 000000000000..fc11f2a5ef4c --- /dev/null +++ b/backend/internal/service/claude_reset_credits_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "context" + "encoding/json" + "github.com/stretchr/testify/require" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type resetAccountStub struct{ account *Account } + +func (s resetAccountStub) GetByID(context.Context, int64) (*Account, error) { return s.account, nil } + +type resetTokenStub struct{} + +func (resetTokenStub) GetAccessToken(context.Context, *Account) (string, error) { + return "synthetic-token", nil +} +func TestClaudeResetStatusNativeContract(t *testing.T) { + now := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC) + s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile user:inference"}}}, tokens: resetTokenStub{}, now: func() time.Time { return now }} + s.do = func(r *http.Request, p string) (*http.Response, error) { + require.Equal(t, claudeResetUsageURL, r.URL.String()) + require.Equal(t, "GET", r.Method) + require.Equal(t, "Bearer synthetic-token", r.Header.Get("Authorization")) + require.Contains(t, r.Header.Get("User-Agent"), "claude-cli/") + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"cedar_ember":{"eligible":true,"at_limit":false,"next_grant_id":"launch","grants":[{"id":"launch","resets_left":2,"usable_now":true,"use_requires_limit":false,"ends_at":"2026-10-22T00:00:00Z","clears":["five_hour"],"percent_used":{"five_hour":65},"blocking":[]},{"id":"later","clears":["five_hour"],"resets_left":1,"usable_now":true,"use_requires_limit":false}]}}`))}, nil + } + r, e := s.Query(context.Background(), 1) + require.NoError(t, e) + require.Equal(t, 2, r.AvailableCount) + require.True(t, r.Credits[0].Redeemable) + require.False(t, r.Credits[1].Redeemable) + b, e := json.Marshal(r) + require.NoError(t, e) + require.NotContains(t, string(b), `"id"`) + require.NotContains(t, string(b), "launch") + require.NotContains(t, string(b), "synthetic-token") + require.NotContains(t, string(b), "selection_token") +} +func TestClaudeResetPastCooldownIsCleared(t *testing.T) { + now := time.Now() + past := now.Add(-time.Minute) + no := false + g := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}} + r := projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &past, Grants: []claudeResetGrant{g}}, now) + require.Nil(t, r.CooldownUntil) + require.Equal(t, 1, r.AvailableCount) + future := now.Add(time.Hour) + r = projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &future, Grants: []claudeResetGrant{g}}, now) + require.Equal(t, &future, r.CooldownUntil) +} +func TestClaudeResetEligibilityFailClosed(t *testing.T) { + now := time.Now() + past := now.Add(-time.Minute) + future := now.Add(time.Hour) + no := false + base := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}} + for _, name := range []string{"paused", "expired", "future", "cooldown", "requires-limit", "blocking", "ineligible", "spent", "not-next"} { + t.Run(name, func(t *testing.T) { + g := base + b := &claudeResetBlock{Eligible: true, NextGrantID: g.ID} + switch name { + case "paused": + g.Paused = true + case "expired": + g.EndsAt = &past + case "future": + g.StartsAt = &future + case "cooldown": + b.CooldownUntil = &future + case "requires-limit": + g.UseRequiresLimit = nil + case "blocking": + g.Blocking = []string{"seven_day"} + case "ineligible": + b.Eligible = false + case "spent": + g.ResetsLeft = 0 + case "not-next": + b.NextGrantID = "other" + } + b.Grants = []claudeResetGrant{g} + r := projectClaudeResetCredits(b, now) + require.Zero(t, r.AvailableCount) + }) + } +} +func TestClaudeResetStatusRejectsMissingScopeBeforeNetwork(t *testing.T) { + s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}}, do: func(*http.Request, string) (*http.Response, error) { t.Fatal("network called"); return nil, nil }} + _, e := s.Query(context.Background(), 1) + require.Error(t, e) +} +func TestClaudeResetMalformedAndAbsent(t *testing.T) { + for _, body := range []string{`{}`, `{"five_hour":{},"cedar_ember":null}`, `{"cedar_ember":{"eligible":true}}`, `{"error":{"message":"private upstream data"}}`, `not json`} { + t.Run(body, func(t *testing.T) { + s := &ClaudeResetCreditService{accounts: resetAccountStub{&Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile"}}}, tokens: resetTokenStub{}, now: time.Now, do: func(*http.Request, string) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil + }} + r, e := s.Query(context.Background(), 1) + if body == `{}` || strings.Contains(body, `"cedar_ember":null`) { + require.NoError(t, e) + require.Empty(t, r.Credits) + } else { + require.Error(t, e) + require.NotContains(t, e.Error(), "private upstream data") + } + }) + } +} diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index cb000e2a0a25..3c6df48ed216 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -316,18 +316,19 @@ type ContentModerationModelFilter struct { } type ContentModerationCheckInput struct { - RequestID string - UserID int64 - UserEmail string - APIKeyID int64 - APIKeyName string - GroupID *int64 - GroupName string - Endpoint string - Provider string - Model string - Protocol string - Body []byte + riskControlLogOnly bool // Captured at admission and retained by asynchronous tasks. + RequestID string + UserID int64 + UserEmail string + APIKeyID int64 + APIKeyName string + GroupID *int64 + GroupName string + Endpoint string + Provider string + Model string + Protocol string + Body []byte } type ContentModerationInput struct { @@ -545,6 +546,7 @@ type ContentModerationService struct { } type contentModerationRuntimeSnapshot struct { + allowlistedUsers map[int64]struct{} riskControlEnabled bool config *ContentModerationConfig keywordMatcher *contentModerationKeywordMatcher @@ -807,7 +809,7 @@ func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestCo return &TestContentModerationAPIKeysResult{Items: items, AuditResult: auditResult, ImageCount: imageCount}, nil } -func (s *ContentModerationService) Check(ctx context.Context, input ContentModerationCheckInput) (*ContentModerationDecision, error) { +func (s *ContentModerationService) Check(ctx context.Context, input ContentModerationCheckInput) (decision *ContentModerationDecision, err error) { allow := &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow} if s == nil || s.settingRepo == nil || s.repo == nil { slog.Info("content_moderation.skip_unavailable", @@ -838,6 +840,15 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer "protocol", input.Protocol) return allow, nil } + _, input.riskControlLogOnly = runtimeSnapshot.allowlistedUsers[input.UserID] + // Keep audit evidence while ensuring all local rejection paths allow trusted users. + defer func() { + if input.riskControlLogOnly && decision != nil { + decision.Allowed, decision.Blocked = true, false + decision.Action = ContentModerationActionAllow + decision.Message, decision.StatusCode = "", 0 + } + }() cfg := runtimeSnapshot.config inGroupScope := cfg.includesGroup(input.GroupID) inModelScope := cfg.includesModel(input.Model) @@ -909,7 +920,11 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer if cfg.KeywordBlockingMode != ContentModerationKeywordModeAPIOnly && len(cfg.BlockedKeywords) > 0 { keywordText := extractContentModerationKeywordText(input.Protocol, input.Body) if keyword, hit := runtimeSnapshot.matchBlockedKeyword(keywordText); hit { - s.recordPreBlockSyncMetric(0, ContentModerationActionKeywordBlock) + metricAction := ContentModerationActionKeywordBlock + if input.riskControlLogOnly { + metricAction = ContentModerationActionAllow + } + s.recordPreBlockSyncMetric(0, metricAction) slog.Info("content_moderation.keyword_block", "user_id", input.UserID, "api_key_id", input.APIKeyID, @@ -974,7 +989,11 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer } if matched { if cfg.Mode == ContentModerationModePreBlock { - s.recordPreBlockSyncMetric(0, ContentModerationActionHashBlock) + metricAction := ContentModerationActionHashBlock + if input.riskControlLogOnly { + metricAction = ContentModerationActionAllow + } + s.recordPreBlockSyncMetric(0, metricAction) } slog.Info("content_moderation.hash_block", "user_id", input.UserID, @@ -1081,7 +1100,7 @@ func (s *ContentModerationService) checkSync(ctx context.Context, input ContentM flagged, highestCategory, highestScore := evaluateModerationScores(result.CategoryScores, cfg.Thresholds) action := ContentModerationActionAllow blocked := false - if allowBlock && flagged && cfg.Mode == ContentModerationModePreBlock { + if allowBlock && !input.riskControlLogOnly && flagged && cfg.Mode == ContentModerationModePreBlock { action = ContentModerationActionBlock blocked = true } @@ -1572,14 +1591,20 @@ func (s *ContentModerationService) refreshRuntimeSnapshot(ctx context.Context) ( values, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyRiskControlEnabled, SettingKeyContentModerationConfig, + SettingKeyCyberPolicyUserAllowlist, }) if err != nil { return nil, fmt.Errorf("get content moderation runtime settings: %w", err) } + allowlistedUsers, err := ParseCyberPolicyUserAllowlist(values[SettingKeyCyberPolicyUserAllowlist]) + if err != nil { + return nil, err + } rawConfig := values[SettingKeyContentModerationConfig] configDigest := sha256.Sum256([]byte(rawConfig)) if current := s.runtimeSnapshot.Load(); current != nil && current.configDigest == configDigest { snapshot := &contentModerationRuntimeSnapshot{ + allowlistedUsers: allowlistedUsers, riskControlEnabled: values[SettingKeyRiskControlEnabled] == "true", config: current.config, keywordMatcher: current.keywordMatcher, @@ -1596,6 +1621,7 @@ func (s *ContentModerationService) refreshRuntimeSnapshot(ctx context.Context) ( } cfg = cfg.effectiveEngine(cfg.Engine) snapshot := &contentModerationRuntimeSnapshot{ + allowlistedUsers: allowlistedUsers, riskControlEnabled: values[SettingKeyRiskControlEnabled] == "true", config: cfg, keywordMatcher: newContentModerationKeywordMatcher(cfg.BlockedKeywords), @@ -1628,6 +1654,7 @@ func (s *ContentModerationService) replaceRuntimeConfig(cfg *ContentModerationCo return } s.runtimeSnapshot.Store(&contentModerationRuntimeSnapshot{ + allowlistedUsers: current.allowlistedUsers, riskControlEnabled: current.riskControlEnabled, config: config, keywordMatcher: keywordMatcher, @@ -1874,6 +1901,11 @@ func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, c if input.APIKeyID > 0 { apiKeyID = &input.APIKeyID } + mode := cfg.Mode + if input.riskControlLogOnly { + mode = ContentModerationModeRiskControlLogOnly + action = ContentModerationActionAllow + } return &ContentModerationLog{ RequestID: input.RequestID, UserID: userID, @@ -1885,7 +1917,7 @@ func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, c Endpoint: input.Endpoint, Provider: input.Provider, Model: input.Model, - Mode: cfg.Mode, + Mode: mode, Action: action, Flagged: flagged, HighestCategory: highestCategory, @@ -1903,6 +1935,9 @@ func (s *ContentModerationService) persistContentModerationLog(ctx context.Conte if s == nil || log == nil { return } + if log.Mode == ContentModerationModeRiskControlLogOnly { + recordHash, applySideEffects = false, false + } if recordHash && s.hashCache != nil { if err := s.hashCache.RecordFlaggedInputHash(ctx, hashText); err != nil { slog.Warn("content_moderation.record_hash_failed", "user_id", contentModerationEmailUserID(log), "endpoint", log.Endpoint, "error", err) @@ -2990,6 +3025,7 @@ func maskSecretTail(secret string) string { // CyberPolicyRecordInput 是一次 cyber_policy 硬阻断的风控记录入参。 type CyberPolicyRecordInput struct { + LogOnly bool // Trusted platform user: retain evidence without penalties or notifications. RequestID string UserID int64 UserEmail string @@ -3064,7 +3100,10 @@ func (s *ContentModerationService) RecordCyberPolicyEvent(ctx context.Context, i // 开关开时 cyber_policy 不参与封号计数:当次不判定(此处跳过), // 历史行由 CountFlaggedByUserSince 的 excludeCyberPolicy 排除。 autoBanned := false - if !cfg.CyberPolicyExcludeFromBanCount { + if in.LogOnly { + log.Mode = ContentModerationModeCyberLogOnly + } + if !in.LogOnly && !cfg.CyberPolicyExcludeFromBanCount { autoBanned = s.applyFlaggedAccountSideEffects(ctx, cfg, log) } log.EmailSent = false @@ -3074,7 +3113,7 @@ func (s *ContentModerationService) RecordCyberPolicyEvent(ctx context.Context, i slog.Warn("content_moderation.cyber_create_log_failed", "user_id", in.UserID, "error", err) } emailSent := false - if s.emailService != nil && strings.TrimSpace(log.UserEmail) != "" { + if !in.LogOnly && s.emailService != nil && strings.TrimSpace(log.UserEmail) != "" { if err := s.sendCyberPolicyEmail(ctx, log); err != nil { slog.Warn("content_moderation.cyber_email_failed", "user_id", in.UserID, "error", err) } else { diff --git a/backend/internal/service/cyber_policy_allowlist.go b/backend/internal/service/cyber_policy_allowlist.go new file mode 100644 index 000000000000..ed3fb59ef008 --- /dev/null +++ b/backend/internal/service/cyber_policy_allowlist.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "unicode" +) + +// ContentModerationModeCyberLogOnly preserves upstream evidence while excluding +// these events from subsequent automatic-ban counts, even after whitelist removal. +const ContentModerationModeCyberLogOnly = "cyber_log_only" +const ContentModerationModeRiskControlLogOnly = "risk_control_log_only" + +// The historical cyber_policy_user_allowlist storage/API key also governs ordinary moderation. + +// ParseCyberPolicyUserAllowlist accepts positive platform user IDs separated by +// commas or whitespace. Reject the whole list on invalid input; never accept a +// partially parsed privilege configuration. +func ParseCyberPolicyUserAllowlist(raw string) (map[int64]struct{}, error) { + if len(raw) > 16384 { + return nil, fmt.Errorf("cyber_policy_user_allowlist must not exceed 16384 bytes") + } + ids := make(map[int64]struct{}) + for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) { + id, err := strconv.ParseInt(field, 10, 64) + if err != nil || id <= 0 { + return nil, fmt.Errorf("cyber_policy_user_allowlist must contain positive user IDs separated by commas or whitespace") + } + ids[id] = struct{}{} + } + return ids, nil +} + +func (s *SettingService) IsCyberPolicyUserAllowlisted(ctx context.Context, userID int64) bool { + if s == nil || s.settingRepo == nil || userID <= 0 { + return false + } + s.GetCyberSessionBlockRuntime(ctx) + if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { + _, allowed := cached.allowlistedUsers[userID] + return allowed + } + return false +} + +func (s *OpenAIGatewayService) CyberPolicyLogOnly(ctx context.Context, apiKey *APIKey) bool { + return s != nil && apiKey != nil && s.settingService.IsCyberPolicyUserAllowlisted(ctx, apiKey.UserID) +} diff --git a/backend/internal/service/cyber_policy_allowlist_test.go b/backend/internal/service/cyber_policy_allowlist_test.go new file mode 100644 index 000000000000..32c945182540 --- /dev/null +++ b/backend/internal/service/cyber_policy_allowlist_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCyberPolicyUserAllowlist(t *testing.T) { + ids, err := ParseCyberPolicyUserAllowlist("12, 34\n12\t56") + require.NoError(t, err) + require.Len(t, ids, 3) + for _, raw := range []string{"0", "-1", "12,invalid", "9223372036854775808", strings.Repeat("1", 16385)} { + ids, err := ParseCyberPolicyUserAllowlist(raw) + require.Error(t, err) + require.Nil(t, ids) + } + ids, err = ParseCyberPolicyUserAllowlist("") + require.NoError(t, err) + require.Empty(t, ids) +} + +func TestCyberPolicyAllowlistCoversAllUserKeysAndRefreshes(t *testing.T) { + repo := &fakeSettingRepo{vals: map[string]string{SettingKeyCyberPolicyUserAllowlist: "12"}} + settings := &SettingService{settingRepo: repo} + svc := &OpenAIGatewayService{settingService: settings} + ctx := context.Background() + // The allowlist also applies when session blocking is disabled. + require.True(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 1, UserID: 12})) + require.True(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 2, UserID: 12})) + require.False(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 3, UserID: 34})) + require.False(t, svc.CyberPolicyLogOnly(ctx, nil)) + repo.vals[SettingKeyCyberPolicyUserAllowlist] = "34" + settings.cyberSessionBlockRuntimeCache.Store(&cachedCyberSessionBlockRuntime{}) + require.False(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 1, UserID: 12})) + require.True(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 3, UserID: 34})) +} + +func TestRecordCyberPolicyEventLogOnlyPreservesEvidenceWithoutSideEffects(t *testing.T) { + repo := &banCountArgsTestRepo{} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{SettingKeyRiskControlEnabled: "true"}}, + repo, nil, nil, nil, nil, nil, &EmailService{}, + ) + // An unconfigured email service must never be invoked in log-only mode. + svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ + LogOnly: true, UserID: 12, UserEmail: "test@example.com", Model: "gpt-5", + UpstreamMessage: "blocked", UpstreamBody: `{"error":{"code":"cyber_policy"}}`, + }) + logs := repo.snapshotLogs() + require.Len(t, logs, 1) + require.Equal(t, ContentModerationModeCyberLogOnly, logs[0].Mode) + require.Equal(t, ContentModerationActionCyberPolicy, logs[0].Action) + require.True(t, logs[0].Flagged) + require.Contains(t, logs[0].Error, "cyber_policy") + require.False(t, logs[0].AutoBanned) + require.False(t, logs[0].EmailSent) + require.Zero(t, logs[0].ViolationCount) + require.Empty(t, repo.snapshotCountCalls()) +} diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3c656b61f5d7..f0bb2b9f6ed0 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -392,8 +392,8 @@ func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, e return trend, nil } -func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) { - trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit) +func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) { + trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric) if err != nil { return nil, fmt.Errorf("get user usage trend: %w", err) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 74999bab1300..2c5b91a601b3 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -240,6 +240,7 @@ const ( SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) + SettingKeyCyberPolicyUserAllowlist = "cyber_policy_user_allowlist" // Platform user IDs with log-only cyber handling SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) SettingKeyOpenAIRefusalRecoveryEnabled = "openai_refusal_recovery_enabled" SettingKeyOpenAICyberFailoverEnabled = "openai_cyber_failover_enabled" @@ -252,7 +253,6 @@ const ( SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) - // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPPort = "smtp_port" // SMTP端口 diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 1925404467cc..8b7c2d4a09cf 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1812,3 +1812,108 @@ func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) { require.Equal(t, "omitted", gjson.GetBytes(out, "thinking.display").String()) require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) } + +func TestSonnet55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) { + fields := []string{ + `"thinking":{"type":"disabled"}`, + `"thinking":{"type":"enabled","budget_tokens":1024}`, + `"thinking":{"type":"between_tools"},"output_config":{"effort":"xhigh"}`, + `"thinking":{"type":"between_tools","display":"summarized"}`, + `"tool_choice":{"type":"any"}`, + `"tool_choice":{"type":"tool","name":"lookup"}`, + `"temperature":0.7`, + `"top_p":0.5`, + `"top_k":1`, + } + for _, field := range fields { + for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey, AccountTypeBedrock, AccountTypeServiceAccount} { + for _, count := range []bool{false, true} { + model := "claude-sonnet-5-5" + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: typ} + switch typ { + case AccountTypeAPIKey: + model = "public-sonnet" + account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-sonnet-5-5"}} + case AccountTypeBedrock: + account.Credentials = map[string]any{"aws_region": "eu-west-1"} + } + body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}],` + field + `}`) + parsed := &ParsedRequest{Model: model, Body: NewRequestBodyRef(body)} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + svc := &GatewayService{} + var err error + if count { + err = svc.ForwardCountTokens(context.Background(), c, account, parsed) + } else { + _, err = svc.Forward(context.Background(), c, account, parsed) + } + require.Error(t, err, field) + require.Equal(t, http.StatusBadRequest, rec.Code, field) + require.Contains(t, rec.Body.String(), "invalid_request_error", field) + } + } + } +} + +func TestSonnet55PreservesSignedHistoryAndAcceptsDefaultParameters(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"thinking":{"type":"between_tools"},"output_config":{"effort":"high"},"tool_choice":{"type":"none"},"temperature":1,"top_p":0.99}`) + require.NoError(t, validateClaude55Request(body, "anthropic/claude-sonnet-5.5")) + require.JSONEq(t, string(body), string(FilterThinkingBlocks(body, "claude-sonnet-5-5"))) + withoutThinking, _ := deleteJSONPathBytes(body, "thinking") + require.JSONEq(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-sonnet-5-5"))) + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-sonnet-5-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, "none", gjson.GetBytes(out, "tool_choice.type").String()) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) +} + +func TestSonnet55BedrockCCCompatTransformsBeforeValidation(t *testing.T) { + groupID := int64(55) + channels := &ChannelService{} + channels.cache.Store(&channelCache{ + loadedAt: time.Now(), + channelByGroupID: map[int64]*Channel{ + groupID: {Status: StatusActive, FeaturesConfig: map[string]any{featureKeyBedrockCCCompat: true}}, + }, + }) + svc := &GatewayService{channelService: channels} + account := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock, + Credentials: map[string]any{"aws_region": "eu-west-1"}} + for _, tc := range []struct { + name string + thinking string + expectedType string + wantErr bool + }{ + {name: "enabled", thinking: `{"type":"enabled","budget_tokens":2048}`, expectedType: "adaptive"}, + {name: "disabled", thinking: `{"type":"disabled"}`, expectedType: "between_tools"}, + {name: "between_tools invalid effort", thinking: `{"type":"between_tools"}`, expectedType: "between_tools", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"thinking":` + tc.thinking + `,"output_config":{"effort":"low"}}`) + if tc.wantErr { + body = []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"thinking":` + tc.thinking + `,"output_config":{"effort":"xhigh"}}`) + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + converted := svc.ApplyBedrockCCCompat(c, body, "claude-sonnet-5-5", account, &groupID) + require.Equal(t, tc.expectedType, gjson.GetBytes(converted, "thinking.type").String()) + if tc.name == "enabled" { + require.False(t, gjson.GetBytes(converted, "thinking.budget_tokens").Exists()) + } + mapped, ok := ResolveBedrockModelID(account, "claude-sonnet-5-5") + require.True(t, ok) + err := validateClaude55Request(converted, mapped) + if tc.wantErr { + require.ErrorContains(t, err, "only low, medium or high effort") + return + } + require.NoError(t, err) + prepared, err := PrepareBedrockRequestBodyWithTokens(converted, mapped, nil, false) + require.NoError(t, err) + require.Equal(t, "low", gjson.GetBytes(prepared, "output_config.effort").String()) + }) + } +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 8ae4fb675dfe..3102a2377e00 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -364,6 +364,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) account.ApplyHeaderOverrides(req.Header) + filterSonnet55ToolsetBetaHeader(req.Header, body, gjson.GetBytes(body, "model").String()) return req, body, nil } diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 80e2afcfac59..97ee44341dc0 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -25,6 +25,9 @@ func (s *GatewayService) ApplyBedrockCCCompat(c *gin.Context, body []byte, model if !s.isBedrockCCCompatEnabled(c.Request.Context(), account, groupID) { return body } + if resolved, ok := ResolveBedrockModelID(account, model); ok { + model = resolved + } body = sanitizeBedrockCCFields(body) body = sanitizeBedrockThinking(body, model) body = sanitizeBedrockToolUseIDs(body) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index fbdeb683eaaa..9fa4c1e134be 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -304,7 +304,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // - 其他形态(auto/any/none)原样透传 // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除 if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 { - if !claude.IsOpus55(modelID) && gjson.GetBytes(out, "tool_choice").Exists() { + if !isClaude55SignedThinkingModel(modelID) && gjson.GetBytes(out, "tool_choice").Exists() { if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { out = next modified = true diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 7a0578f08cda..2a5f4a04010c 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -24,11 +24,17 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } validationModel := parsed.Model - if account != nil && account.Type == AccountTypeAPIKey { - validationModel = account.GetMappedModel(validationModel) + if account != nil { + if account.IsBedrock() { + if resolved, ok := ResolveBedrockModelID(account, validationModel); ok { + validationModel = resolved + } + } else if account.Type == AccountTypeAPIKey { + validationModel = account.GetMappedModel(validationModel) + } } - if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { - if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil { + if account != nil && account.Platform == PlatformAnthropic { + if err := validateClaude55Request(parsed.Body.Bytes(), validationModel); err != nil { s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return err } @@ -447,6 +453,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) account.ApplyHeaderOverrides(req.Header) + filterSonnet55ToolsetBetaHeader(req.Header, body, gjson.GetBytes(body, "model").String()) return req, nil } @@ -527,6 +534,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { finalBetaHeader, finalBetaShouldSet = beta, true } + finalBetaHeader = filterSonnet55ToolsetBeta(finalBetaHeader, body, modelID) // 能力维度 body sanitize:与最终 anthropic-beta header 对称 if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { @@ -598,6 +606,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + filterSonnet55ToolsetBetaHeader(req.Header, body, modelID) if c != nil && tokenType == "oauth" { c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index b76d57df5946..0e88aef75413 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -95,11 +95,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } // API-key mappings and OAuth native IDs are resolved before mimicry. validationModel := parsed.Model - if account != nil && account.Type == AccountTypeAPIKey { - validationModel = account.GetMappedModel(validationModel) + if account != nil { + if account.IsBedrock() { + if resolved, ok := ResolveBedrockModelID(account, validationModel); ok { + validationModel = resolved + } + } else if account.Type == AccountTypeAPIKey { + validationModel = account.GetMappedModel(validationModel) + } } - if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { - if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil { + if account != nil && account.Platform == PlatformAnthropic { + if err := validateClaude55Request(parsed.Body.Bytes(), validationModel); err != nil { c.JSON(http.StatusBadRequest, gin.H{"type": "error", "error": gin.H{"type": "invalid_request_error", "message": err.Error()}}) return nil, err } diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 43ee2ea24b2b..b1e1cbba6336 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -42,7 +42,6 @@ func (s *GatewayService) ForwardAsChatCompletions( } originalModel := ccReq.Model clientStream := ccReq.Stream - includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage // 2. Convert CC → Responses → Anthropic (chained conversion) responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) @@ -66,14 +65,14 @@ func (s *GatewayService) ForwardAsChatCompletions( mappedModel = normalized } } - if err := validateClaudeOpus55Request(body, mappedModel); err != nil { + if err := validateClaude55Request(body, mappedModel); err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } responsesReq.Model = mappedModel anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) if err != nil { - if claude.IsOpus55(mappedModel) { + if isClaude55SignedThinkingModel(mappedModel) { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } return nil, fmt.Errorf("convert responses to anthropic: %w", err) @@ -189,7 +188,7 @@ func (s *GatewayService) ForwardAsChatCompletions( var result *ForwardResult var handleErr error if clientStream { - result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime, includeUsage) + result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime) } else { result, handleErr = s.handleCCBufferedFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime) } @@ -362,7 +361,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mappedModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -380,7 +378,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -470,6 +467,11 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( return false } + // Keep the outward Responses/Chat usage on the same normalized buckets used + // for billing, including converter handlers that consume event usage. + syncAnthropicResponsesUsage(anthState, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // Chain: Anthropic event → Responses events → CC chunks responsesEvents := apicompat.AnthropicEventToResponsesEvents(event, anthState) for _, resEvt := range responsesEvents { @@ -501,6 +503,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( continue } + // Forward received usage regardless of the client stream_options. + // The intermediate Responses converter synthesizes usage even when absent. + ccState.IncludeUsage = ccState.IncludeUsage || anthropicChatStreamHasUsage(&event, payload) + if processAnthropicEvent(&event) { return resultWithUsage(), protocolErr } @@ -548,3 +554,16 @@ func writeGatewayCCError(c *gin.Context, statusCode int, errType, message string }, }) } + +// anthropicChatStreamHasUsage distinguishes an explicit zero-valued usage object +// from omitted/null usage before the Anthropic→Responses conversion loses that distinction. +func anthropicChatStreamHasUsage(event *apicompat.AnthropicStreamEvent, payload string) bool { + switch event.Type { + case "message_start": + return event.Message != nil && gjson.Get(payload, "message.usage").IsObject() + case "message_delta": + return event.Usage != nil + default: + return false + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions_test.go b/backend/internal/service/gateway_forward_as_chat_completions_test.go index f7cb42c69c7c..163b20449030 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions_test.go +++ b/backend/internal/service/gateway_forward_as_chat_completions_test.go @@ -199,7 +199,7 @@ func TestHandleCCStreamingFromAnthropic_CompactSSEFormat(t *testing.T) { } svc := &GatewayService{} - result, err := svc.handleCCStreamingFromAnthropic(resp, c, "k3", "k3", nil, time.Now(), true) + result, err := svc.handleCCStreamingFromAnthropic(resp, c, "k3", "k3", nil, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 21, result.Usage.InputTokens) @@ -236,7 +236,7 @@ func TestHandleCCStreamingFromAnthropic_PreservesMessageStartCacheUsageAndReason } svc := &GatewayService{} - result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now(), true) + result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 20, result.Usage.InputTokens) diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 0a04d0d554a8..507730d38ad1 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -76,14 +76,14 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } - if err := validateClaudeOpus55Request(body, mappedModel); err != nil { + if err := validateClaude55Request(body, mappedModel); err != nil { writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } responsesReq.Model = mappedModel anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) if err != nil { - if claude.IsOpus55(mappedModel) { + if isClaude55SignedThinkingModel(mappedModel) { writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } return nil, fmt.Errorf("convert responses to anthropic: %w", err) @@ -287,22 +287,22 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { return } + cacheReadTokens := src.CacheReadInputTokens + if cacheReadTokens == 0 && src.CachedTokens > 0 { + cacheReadTokens = src.CachedTokens + } + if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { + cacheReadTokens = src.PromptTokensDetails.CachedTokens + } + if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { + cacheReadTokens = max(*src.PromptCacheHitTokens, 0) + } + // Some Anthropic-compatible providers retain OpenAI-style prompt/cache // fields. Prefer those authoritative totals or hit/miss buckets over the // overloaded input_tokens field. This covers Kimi's changing stream // semantics as well as GLM/DeepSeek cache aliases. if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil { - cacheReadTokens := src.CacheReadInputTokens - if cacheReadTokens == 0 && src.CachedTokens > 0 { - cacheReadTokens = src.CachedTokens - } - if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { - cacheReadTokens = src.PromptTokensDetails.CachedTokens - } - if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { - cacheReadTokens = max(*src.PromptCacheHitTokens, 0) - } - if src.PromptCacheMissTokens != nil { dst.InputTokens = max(*src.PromptCacheMissTokens, 0) } else { @@ -311,13 +311,16 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { dst.CacheReadInputTokens = cacheReadTokens dst.CacheCreationInputTokens = src.CacheCreationInputTokens } else { + // Without an authoritative prompt total or miss bucket, input_tokens is + // provider-specific: it may already be the uncached bucket, or it may be + // a total from an earlier event. Do not infer a subtraction merely because + // a later event contains cache buckets; that would corrupt providers whose + // stream uses independent input and cache fields. if src.InputTokens > 0 { dst.InputTokens = src.InputTokens } - if src.CacheReadInputTokens > 0 { - dst.CacheReadInputTokens = src.CacheReadInputTokens - } else if src.CachedTokens > 0 { - dst.CacheReadInputTokens = src.CachedTokens + if cacheReadTokens > 0 { + dst.CacheReadInputTokens = cacheReadTokens } if src.CacheCreationInputTokens > 0 { dst.CacheCreationInputTokens = src.CacheCreationInputTokens @@ -328,6 +331,29 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { } } +func syncAnthropicResponsesUsage(state *apicompat.AnthropicEventToResponsesState, usage ClaudeUsage) { + state.InputTokens = usage.InputTokens + state.OutputTokens = usage.OutputTokens + state.CacheReadInputTokens = usage.CacheReadInputTokens + state.CacheCreationInputTokens = usage.CacheCreationInputTokens +} + +func normalizeAnthropicEventUsageForResponses(event *apicompat.AnthropicStreamEvent, usage ClaudeUsage) { + normalize := func(dst *apicompat.AnthropicUsage) { + if dst == nil { + return + } + dst.InputTokens = usage.InputTokens + dst.OutputTokens = usage.OutputTokens + dst.CacheReadInputTokens = usage.CacheReadInputTokens + dst.CacheCreationInputTokens = usage.CacheCreationInputTokens + } + normalize(event.Usage) + if event.Message != nil { + normalize(&event.Message.Usage) + } +} + // parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value". // According to the SSE spec (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation), // the space after the colon is optional. This function handles both formats. @@ -453,7 +479,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( } // Convert to Responses format - if claude.IsOpus55(mappedModel) { + if isClaude55SignedThinkingModel(mappedModel) { finalResp.Model = mappedModel } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) @@ -515,7 +541,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel - state.PreserveThinkingSignatures = claude.IsOpus55(mappedModel) + state.PreserveThinkingSignatures = isClaude55SignedThinkingModel(mappedModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage var firstTokenMs *int @@ -559,6 +585,12 @@ func (s *GatewayService) handleResponsesStreamingResponse( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the terminal Responses usage aligned with the normalized billing + // buckets. Normalize the converter input too, so message handlers cannot + // restore the provider's overlapping raw input total. + syncAnthropicResponsesUsage(state, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // Convert to Responses events events := apicompat.AnthropicEventToResponsesEvents(event, state) for _, evt := range events { diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 5f74dfac5978..9e00b8007a7a 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -310,6 +310,116 @@ func TestHandleResponsesStreamingResponse_PreservesMessageStartCacheUsage(t *tes require.Contains(t, rec.Body.String(), `response.completed`) } +func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + startUsage string + deltaUsage string + wantInput int + wantOutput int + wantCached int + wantCacheCreation int + }{ + { + name: "cache_read_input_tokens full cache", + startUsage: `"input_tokens":173306,"prompt_tokens":173306`, + deltaUsage: `"input_tokens":0,"output_tokens":8,"prompt_tokens":173306,"cache_read_input_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "cached_tokens full cache", + startUsage: `"input_tokens":173306,"prompt_tokens":173306`, + deltaUsage: `"input_tokens":0,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "prompt_tokens_details full cache", + startUsage: `"input_tokens":173306,"prompt_tokens":173306`, + deltaUsage: `"input_tokens":0,"output_tokens":8,"prompt_tokens":173306,"prompt_tokens_details":{"cached_tokens":173306}`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "DeepSeek input total with hit and miss buckets", + startUsage: `"input_tokens":0`, + deltaUsage: `"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400`, + wantInput: 400, + wantOutput: 30, + wantCached: 800, + }, + { + name: "OpenAI prompt total with cached details", + startUsage: `"input_tokens":0`, + deltaUsage: `"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}`, + wantInput: 400, + wantOutput: 30, + wantCached: 800, + }, + { + name: "cache creation only without prompt total or miss bucket keeps independent input", + startUsage: `"input_tokens":1200`, + deltaUsage: `"input_tokens":0,"output_tokens":30,"cache_creation_input_tokens":800`, + wantInput: 1200, + wantOutput: 30, + wantCacheCreation: 800, + }, + } + + for _, tt := range tests { + for _, terminal := range []string{"message_stop", "eof"} { + t.Run(tt.name+"/"+terminal, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + lines := []string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_usage","type":"message","role":"assistant","content":[],"model":"k3","stop_reason":"","usage":{` + tt.startUsage + `}}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{` + tt.deltaUsage + `}}`, + ``, + } + if terminal == "message_stop" { + lines = append(lines, `event: message_stop`, `data: {"type":"message_stop"}`, ``) + } + resp := &http.Response{Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n")))} + + result, err := (&GatewayService{}).handleResponsesStreamingResponse(resp, c, "k3", "k3", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + require.Equal(t, tt.wantInput, result.Usage.InputTokens) + require.Equal(t, tt.wantCached, result.Usage.CacheReadInputTokens) + require.Equal(t, tt.wantCacheCreation, result.Usage.CacheCreationInputTokens) + + var completed apicompat.ResponsesStreamEvent + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event apicompat.ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + if event.Type == "response.completed" { + completed = event + } + } + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, completed.Response.Usage.InputTokens) + require.Equal(t, tt.wantOutput, completed.Response.Usage.OutputTokens) + require.Equal(t, completed.Response.Usage.InputTokens+tt.wantOutput, completed.Response.Usage.TotalTokens) + require.Equal(t, tt.wantCacheCreation, completed.Response.Usage.CacheCreationInputTokens) + if tt.wantCached == 0 { + require.Nil(t, completed.Response.Usage.InputTokensDetails) + } else { + require.Equal(t, tt.wantCached, completed.Response.Usage.InputTokensDetails.CachedTokens) + } + }) + } + } +} + func TestParseAnthropicSSEField(t *testing.T) { t.Parallel() @@ -453,7 +563,7 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) { require.Contains(t, rec.Body.String(), `response.completed`) } -func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) { +func TestClaude55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) { payload := strings.Join([]string{ "event: message_start\n" + `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10}}}`, "event: content_block_start\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, @@ -465,60 +575,65 @@ func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) { "event: message_delta\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, "event: message_stop\n" + `data: {"type":"message_stop"}`, }, "\n\n") + "\n\n" - for _, stream := range []bool{false, true} { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - resp := &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload))} - svc := &GatewayService{} - var err error - if stream { - _, err = svc.handleResponsesStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) - } else { - _, err = svc.handleResponsesBufferedStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) - } - require.NoError(t, err) - require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:") - require.Contains(t, rec.Body.String(), "public-opus") - require.Contains(t, rec.Body.String(), `"text":"ok"`) - } -} - -func TestOpus55BridgeUsesMappedModelBeforeThinkingConversion(t *testing.T) { - for _, chat := range []bool{false, true} { - for _, forced := range []bool{false, true} { + for _, model := range []string{"claude-opus-5-5", "claude-sonnet-5-5"} { + modelPayload := strings.ReplaceAll(payload, "claude-opus-5-5", model) + for _, stream := range []bool{false, true} { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) - body := `{"model":"public-opus","input":"hello","reasoning":{"effort":"xhigh"}}` - if chat { - body = `{"model":"public-opus","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh"}` - } - if forced { - body = body[:len(body)-1] + `,"tool_choice":"required","tools":[{"type":"function","name":"lookup","function":{"name":"lookup"}}]}` - } - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) - upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))}} - svc := &GatewayService{cfg: &config.Config{}, httpUpstream: upstream} - account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture-key", "model_mapping": map[string]any{"public-opus": "claude-opus-5-5"}}} + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(modelPayload))} + svc := &GatewayService{} var err error - var result *ForwardResult - if chat { - result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(body), nil) + if stream { + _, err = svc.handleResponsesStreamingResponse(resp, c, "public-claude", model, nil, time.Now(), apicompat.ResponsesClientToolMapping{}) } else { - result, err = svc.ForwardAsResponses(context.Background(), c, account, []byte(body), nil) + _, err = svc.handleResponsesBufferedStreamingResponse(resp, c, "public-claude", model, nil, time.Now(), apicompat.ResponsesClientToolMapping{}) } - if forced { - require.Error(t, err) - require.Equal(t, 400, rec.Code) - require.Nil(t, upstream.lastReq) - } else { - require.NoError(t, err) - require.NotNil(t, result) - require.NotNil(t, upstream.lastReq) - require.Equal(t, "claude-opus-5-5", gjson.GetBytes(upstream.lastBody, "model").String()) - require.Equal(t, "adaptive", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) - require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) - require.False(t, gjson.GetBytes(upstream.lastBody, "thinking.budget_tokens").Exists()) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:") + require.Contains(t, rec.Body.String(), "public-claude") + require.Contains(t, rec.Body.String(), `"text":"ok"`) + } + } +} + +func TestClaude55BridgeUsesMappedModelBeforeThinkingConversion(t *testing.T) { + for _, model := range []string{"claude-opus-5-5", "claude-sonnet-5-5"} { + for _, chat := range []bool{false, true} { + for _, forced := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := `{"model":"public-claude","input":"hello","reasoning":{"effort":"xhigh"}}` + if chat { + body = `{"model":"public-claude","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh"}` + } + if forced { + body = body[:len(body)-1] + `,"tool_choice":"required","tools":[{"type":"function","name":"lookup","function":{"name":"lookup"}}]}` + } + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))}} + svc := &GatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture-key", "model_mapping": map[string]any{"public-claude": model}}} + var err error + var result *ForwardResult + if chat { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(body), nil) + } else { + result, err = svc.ForwardAsResponses(context.Background(), c, account, []byte(body), nil) + } + if forced { + require.Error(t, err) + require.Equal(t, 400, rec.Code) + require.Nil(t, upstream.lastReq) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "adaptive", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "thinking.budget_tokens").Exists()) + } } } } diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index da4e5d601740..678d7d3d3bc0 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -223,7 +223,8 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro parsed.MetadataUserID = gjson.Get(jsonStr, "metadata.user_id").String() thinkingType := gjson.Get(jsonStr, "thinking.type").String() - parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || (protocol == domain.PlatformAnthropic && claude.IsOpus55(parsed.Model)) + parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || thinkingType == "between_tools" || + (protocol == domain.PlatformAnthropic && isClaude55SignedThinkingModel(parsed.Model)) parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) if protocol == domain.PlatformAnthropic { @@ -566,22 +567,60 @@ func StripEmptyTextBlocks(body []byte) []byte { return out } -// validateClaudeOpus55Request rejects settings that the upstream cannot honor. +// isClaude55SignedThinkingModel identifies models whose default thinking mode +// requires signed history to survive protocol conversion and request filtering. +func isClaude55SignedThinkingModel(model string) bool { + return claude.IsOpus55(model) || claude.IsSonnet55(model) +} + +// validateClaude55Request rejects settings that the upstream cannot honor. // Call before OAuth mimicry can remove tool_choice or alter thinking defaults. -func validateClaudeOpus55Request(body []byte, model string) error { - if !claude.IsOpus55(model) { +func validateClaude55Request(body []byte, model string) error { + if !isClaude55SignedThinkingModel(model) { return nil } + isSonnet55 := claude.IsSonnet55(model) switch gjson.GetBytes(body, "thinking.type").String() { case "disabled", "enabled": + if isSonnet55 { + return fmt.Errorf("claude-sonnet-5-5 requires adaptive thinking or thinking.type=between_tools; omit thinking or use one of those modes") + } return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort") + case "between_tools": + if !isSonnet55 { + return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; thinking.type=between_tools is unsupported") + } + effort := gjson.GetBytes(body, "output_config.effort").String() + if effort == "xhigh" || effort == "max" { + return fmt.Errorf("claude-sonnet-5-5 thinking.type=between_tools supports only low, medium or high effort") + } + for _, field := range []string{"thinking.display", "thinking.budget_tokens", "thinking.block_binding"} { + if gjson.GetBytes(body, field).Exists() { + return fmt.Errorf("claude-sonnet-5-5 thinking.type=between_tools does not support %s", field) + } + } + } + modelName := "claude-opus-5-5" + if isSonnet55 { + modelName = "claude-sonnet-5-5" } if gjson.GetBytes(body, "tool_choice").String() == "required" { - return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName) } switch gjson.GetBytes(body, "tool_choice.type").String() { case "any", "tool", "function", "custom", "namespace": - return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName) + } + if isSonnet55 { + if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && (temperature.Type != gjson.Number || temperature.Float() != 1) { + return fmt.Errorf("claude-sonnet-5-5 does not support non-default temperature") + } + if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && (topP.Type != gjson.Number || topP.Float() < 0.99 || topP.Float() > 1) { + return fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p") + } + if gjson.GetBytes(body, "top_k").Exists() { + return fmt.Errorf("claude-sonnet-5-5 does not support top_k") + } } return nil } @@ -598,13 +637,13 @@ func validateClaudeOpus55Request(body []byte, model string) error { // // 策略 (anthropic-strict only): // - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块 -// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块(避免 400) -// (blocks with missing/empty/dummy signatures that would cause 400 errors) +// - 当 thinking.type 是 "enabled"/"adaptive":移除缺失/无效 signature 的 thinking 块(避免 400) +// - 对要求签名历史完整的 5.5 模型,一旦有无效块,移除历史中的全部 thinking 块 func FilterThinkingBlocks(body []byte, mappedModel string) []byte { if !ShouldPreFilterThinkingBlocks(mappedModel) { return body } - return filterThinkingBlocksInternal(body, claude.IsOpus55(mappedModel)) + return filterThinkingBlocksInternal(body, isClaude55SignedThinkingModel(mappedModel)) } // FilterThinkingBlocksForRetry strips thinking-related constructs for retry scenarios. @@ -675,6 +714,9 @@ func FilterThinkingBlocksForRetry(body []byte, mappedModel string) []byte { bytes.Contains(body, patternThinkingFieldSpaced) if !hasEmptyContent && !hasEmptyTextBlock && !containsThinkingBlocks { if topThinking := gjson.Get(jsonStr, "thinking"); topThinking.Exists() { + if claude.IsSonnet55(mappedModel) && topThinking.Get("type").String() == "between_tools" { + return body + } if out, err := sjson.DeleteBytes(body, "thinking"); err == nil { out = removeThinkingDependentContextStrategies(out) return out @@ -692,7 +734,9 @@ func FilterThinkingBlocksForRetry(body []byte, mappedModel string) []byte { modified := false // Disable top-level thinking mode for retry to avoid structural/signature constraints upstream. - deleteTopLevelThinking := gjson.Get(jsonStr, "thinking").Exists() + thinkingMode := gjson.Get(jsonStr, "thinking.type").String() + deleteTopLevelThinking := gjson.Get(jsonStr, "thinking").Exists() && + (!claude.IsSonnet55(mappedModel) || thinkingMode != "between_tools") for i := 0; i < len(messages); i++ { msgMap, ok := messages[i].(map[string]any) @@ -1340,7 +1384,9 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte, mappedModel string) []b // filterThinkingBlocksInternal removes invalid thinking blocks from request // 策略: // - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块 -// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块 +// - 当 thinking.type 是 "enabled"/"adaptive":移除缺失/无效 signature 的 thinking 块 +// - 对 5.5 模型,只要历史中有无效 thinking,就移除所有 thinking/redacted_thinking, +// 避免留下不连续的签名历史 func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte { // Fast path: if body doesn't contain "thinking", skip parsing if !bytes.Contains(body, []byte(`"type":"thinking"`)) && @@ -1436,6 +1482,44 @@ func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte { if !filtered { return body } + if alwaysThinking { + for _, msg := range messages { + msgMap, ok := msg.(map[string]any) + if !ok { + continue + } + content, ok := msgMap["content"].([]any) + if !ok { + continue + } + cleaned := make([]any, 0, len(content)) + for _, block := range content { + blockMap, ok := block.(map[string]any) + if !ok { + cleaned = append(cleaned, block) + continue + } + blockType, _ := blockMap["type"].(string) + if blockType == "thinking" || blockType == "redacted_thinking" { + continue + } + if blockType == "" { + if _, hasThinking := blockMap["thinking"]; hasThinking { + continue + } + } + cleaned = append(cleaned, block) + } + if len(cleaned) == 0 { + placeholder := "(content removed)" + if role, _ := msgMap["role"].(string); role == "assistant" { + placeholder = "(assistant content removed)" + } + cleaned = append(cleaned, map[string]any{"type": "text", "text": placeholder}) + } + msgMap["content"] = cleaned + } + } newBody, err := json.Marshal(req) if err != nil { diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 27b71c31fe4e..242b02110b54 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -386,6 +386,33 @@ func TestFilterThinkingBlocksForRetry_DisablesThinkingAndPreservesAsText(t *test require.Equal(t, "Let me think...", first["text"]) } +func TestClaude55FilterThinkingBlocksRemovesSignedChainAfterInvalidBlock(t *testing.T) { + input := []byte(`{"model":"claude-sonnet-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"old","signature":""},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"later","signature":"valid"},{"type":"redacted_thinking","data":"encrypted"},{"type":"text","text":"later answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"only thought","signature":"valid"}]}]}`) + for _, model := range []string{"claude-sonnet-5-5", "claude-opus-5-5"} { + t.Run(model, func(t *testing.T) { + out := FilterThinkingBlocks(input, model) + require.False(t, gjson.GetBytes(out, "messages.0.content.#(type==thinking)").Exists()) + require.Equal(t, "answer", gjson.GetBytes(out, "messages.0.content.0.text").String()) + require.Len(t, gjson.GetBytes(out, "messages.1.content").Array(), 1) + require.Equal(t, "later answer", gjson.GetBytes(out, "messages.1.content.0.text").String()) + require.Equal(t, "(assistant content removed)", gjson.GetBytes(out, "messages.2.content.0.text").String()) + }) + } +} + +func TestSonnet55RetryKeepsBetweenToolsMode(t *testing.T) { + for _, messages := range []string{ + `[{"role":"user","content":"hi"}]`, + `[{"role":"assistant","content":[{"type":"thinking","thinking":"old","signature":"invalid"},{"type":"text","text":"answer"}]}]`, + } { + input := []byte(`{"model":"claude-sonnet-5-5","thinking":{"type":"between_tools"},"output_config":{"effort":"low"},"messages":` + messages + `}`) + out := FilterThinkingBlocksForRetry(input, "claude-sonnet-5-5") + require.Equal(t, "between_tools", gjson.GetBytes(out, "thinking.type").String()) + require.Equal(t, "low", gjson.GetBytes(out, "output_config.effort").String()) + require.NoError(t, validateClaude55Request(out, "claude-sonnet-5-5")) + } +} + func TestFilterThinkingBlocksForRetry_DisablesThinkingEvenWithoutThinkingBlocks(t *testing.T) { input := []byte(`{ "model":"claude-3-5-sonnet-20241022", diff --git a/backend/internal/service/gateway_sonnet55_toolset_beta_test.go b/backend/internal/service/gateway_sonnet55_toolset_beta_test.go new file mode 100644 index 000000000000..2338a34b957b --- /dev/null +++ b/backend/internal/service/gateway_sonnet55_toolset_beta_test.go @@ -0,0 +1,118 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSonnet55ToolsetDropsLegacyStreamingBeta(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{InjectBetaForAPIKey: true}}} + for _, tc := range []struct { + name string + model string + toolType string + wantDrop bool + }{ + {"computer toolset", "claude-sonnet-5-5", "computer_toolset_20260801", true}, + {"browser toolset", "claude-sonnet-5-5", "browser_toolset_20260801", true}, + {"legacy computer", "claude-sonnet-5-5", "computer_20251124", false}, + {"older model", "claude-sonnet-5", "computer_toolset_20260801", false}, + } { + t.Run(tc.name, func(t *testing.T) { + body := []byte(`{"model":"` + tc.model + `","tools":[{"type":"` + tc.toolType + `"}],"messages":[{"role":"user","content":"hi"}]}`) + headers := http.Header{} + headers.Set("anthropic-beta", claude.BetaFineGrainedToolStreaming+",context-1m-2025-08-07") + for _, tokens := range []struct { + name string + count bool + clientBeta bool + }{ + {"explicit messages", false, true}, + {"injected messages", false, false}, + {"explicit count_tokens", true, true}, + {"injected count_tokens", true, false}, + } { + t.Run(tokens.name, func(t *testing.T) { + requestHeaders := http.Header{} + if tokens.clientBeta { + requestHeaders = headers + } + var got string + var set bool + if tokens.count { + got, set = svc.computeFinalCountTokensAnthropicBeta("apikey", false, tc.model, requestHeaders, body, nil) + } else { + got, set = svc.computeFinalAnthropicBeta("apikey", false, tc.model, requestHeaders, body, nil) + } + require.True(t, set) + require.Equal(t, !tc.wantDrop, containsBetaToken(got, claude.BetaFineGrainedToolStreaming)) + if tokens.clientBeta { + require.True(t, containsBetaToken(got, claude.BetaContext1M)) + } + }) + } + }) + } +} + +func TestSonnet55ToolsetBetaFilteredAfterAccountOverrideAndOnVertex(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-5-5","max_tokens":128,"tools":[{"type":"browser_toolset_20260801"}],"messages":[{"role":"user","content":"hi"}]}`) + account := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"anthropic-beta": claude.BetaFineGrainedToolStreaming + ",context-1m-2025-08-07"}, + }} + svc := &GatewayService{cfg: &config.Config{}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + for _, build := range []struct { + name string + fn func() (*http.Request, error) + }{ + {"normal", func() (*http.Request, error) { + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "key", "apikey", "claude-sonnet-5-5", false, false) + return req, err + }}, + {"passthrough", func() (*http.Request, error) { + req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "key") + return req, err + }}, + } { + t.Run(build.name, func(t *testing.T) { + req, err := build.fn() + require.NoError(t, err) + header := getHeaderRaw(req.Header, "anthropic-beta") + require.False(t, containsBetaToken(header, claude.BetaFineGrainedToolStreaming)) + require.True(t, containsBetaToken(header, claude.BetaContext1M)) + }) + } + + vertexContext := newVertexBetaTestContext(t, claude.BetaFineGrainedToolStreaming+","+claude.BetaContext1M) + req, _, err := svc.buildUpstreamRequest(context.Background(), vertexContext, newVertexServiceAccount(9001), body, + "vertex-token", "service_account", "claude-sonnet-5-5@20260928", false, false) + require.NoError(t, err) + vertexHeader := getHeaderRaw(req.Header, "anthropic-beta") + require.False(t, containsBetaToken(vertexHeader, claude.BetaFineGrainedToolStreaming)) + require.True(t, containsBetaToken(vertexHeader, claude.BetaContext1M)) + + nativeAccount := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"anthropic-beta": claude.BetaFineGrainedToolStreaming + ",context-1m-2025-08-07"}, + }} + native := &OpenAIGatewayService{} + nativeReq, _, err := native.buildNativeAnthropicUpstreamRequest(context.Background(), c, nativeAccount, body, + "key", "https://api.anthropic.com/v1/messages") + require.NoError(t, err) + nativeHeader := getHeaderRaw(nativeReq.Header, "anthropic-beta") + require.False(t, containsBetaToken(nativeHeader, claude.BetaFineGrainedToolStreaming)) + require.True(t, containsBetaToken(nativeHeader, claude.BetaContext1M)) +} diff --git a/backend/internal/service/gateway_tool_rewrite.go b/backend/internal/service/gateway_tool_rewrite.go index d1b69937232e..c3e1d8da7f70 100644 --- a/backend/internal/service/gateway_tool_rewrite.go +++ b/backend/internal/service/gateway_tool_rewrite.go @@ -1,6 +1,8 @@ package service import ( + "bytes" + "encoding/json" "fmt" "hash/fnv" "math/rand" @@ -167,6 +169,11 @@ func buildToolNameRewriteFromBody(body []byte) *ToolNameRewrite { return rw } +type toolNameSpan struct { + start, end int + value []byte +} + // applyToolNameRewriteToBody 把已构造的 ToolNameRewrite 应用到 body 上: // // - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool) @@ -181,68 +188,66 @@ func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte { return body } + // gjson child indexes are absolute offsets into the original body. Gather all + // replacements before changing its length, then copy the body only once. + var edits []toolNameSpan + addName := func(name gjson.Result) { + if !name.Exists() { + return + } + fake, ok := rw.Forward[name.String()] + if !ok { + return + } + encoded, err := json.Marshal(fake) + if err != nil || name.Index < 0 || name.Index+len(name.Raw) > len(body) || + !bytes.Equal(body[name.Index:name.Index+len(name.Raw)], []byte(name.Raw)) { + return + } + edits = append(edits, toolNameSpan{name.Index, name.Index + len(name.Raw), encoded}) + } + tools := gjson.GetBytes(body, "tools") if tools.IsArray() { - idx := -1 - tools.ForEach(func(_, t gjson.Result) bool { - idx++ - if !shouldMimicToolName(t.Get("type").String()) { - return true - } - name := t.Get("name").String() - if name == "" { - return true - } - fake, ok := rw.Forward[name] - if !ok { - return true - } - if next, err := sjson.SetBytes(body, fmt.Sprintf("tools.%d.name", idx), fake); err == nil { - body = next + tools.ForEach(func(_, tool gjson.Result) bool { + if shouldMimicToolName(tool.Get("type").String()) { + addName(tool.Get("name")) } return true }) } - - if tc := gjson.GetBytes(body, "tool_choice"); tc.Exists() && tc.Get("type").String() == "tool" { - name := tc.Get("name").String() - if fake, ok := rw.Forward[name]; ok { - if next, err := sjson.SetBytes(body, "tool_choice.name", fake); err == nil { - body = next - } - } + if choice := gjson.GetBytes(body, "tool_choice"); choice.Get("type").String() == "tool" { + addName(choice.Get("name")) } - - // 同步改写历史消息中的 tool_use.name,确保它和 tools[] 中的假名一致。 - // 否则 Anthropic 会因为 tool_use 引用了未声明的原始工具名而拒绝请求。 - messages := gjson.GetBytes(body, "messages") - if messages.IsArray() { - messages.ForEach(func(msgKey, msg gjson.Result) bool { - msgIdx := int(msgKey.Num) + if messages := gjson.GetBytes(body, "messages"); messages.IsArray() { + messages.ForEach(func(_, msg gjson.Result) bool { content := msg.Get("content") - if !content.IsArray() { - return true - } - content.ForEach(func(blkKey, blk gjson.Result) bool { - blkIdx := int(blkKey.Num) - if blk.Get("type").String() != "tool_use" { - return true - } - name := blk.Get("name").String() - if name == "" { - return true - } - if fake, ok := rw.Forward[name]; ok { - path := fmt.Sprintf("messages.%d.content.%d.name", msgIdx, blkIdx) - if next, err := sjson.SetBytes(body, path, fake); err == nil { - body = next + if content.IsArray() { + content.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").String() == "tool_use" { + addName(block.Get("name")) } - } - return true - }) + return true + }) + } return true }) } + if len(edits) != 0 { + sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) + var out []byte + out = make([]byte, 0, len(body)) + pos := 0 + for _, edit := range edits { + if edit.start < pos { // Ignore any overlapping result from malformed JSON. + continue + } + out = append(out, body[pos:edit.start]...) + out = append(out, edit.value...) + pos = edit.end + } + body = append(out, body[pos:]...) + } body = applyToolsLastCacheBreakpoint(body) return body diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go index c894ef708c63..04f405b12922 100644 --- a/backend/internal/service/gateway_tool_rewrite_test.go +++ b/backend/internal/service/gateway_tool_rewrite_test.go @@ -295,3 +295,66 @@ func TestBuildDynamicToolMap_FakeNameShape(t *testing.T) { require.True(t, strings.Contains(fake, head), "fake %q should contain head3 %q of %q", fake, head, name) } } + +func TestApplyToolNameRewriteToBody_Spans(t *testing.T) { + body := []byte(`{"tools":[{"name":"sessions_\"quoted"},{"name":"session_last","defer_loading":true,"cache_control":{"type":"ephemeral"}}],"tool_choice":{"type":"tool","name":"sessions_\"quoted"},"messages":[{"content":[{"type":"tool_use","name":"sessions_\"quoted","input":{"name":"sessions_\"quoted"}},{"type":"tool_use"}]}],"metadata":{"name":"sessions_\"quoted"}}`) + out := applyToolNameRewriteToBody(body, buildToolNameRewriteFromBody(body)) + require.True(t, gjson.ValidBytes(out)) + for _, path := range []string{"tools.0.name", "tool_choice.name", "messages.0.content.0.name"} { + require.Equal(t, `cc_sess_"quoted`, gjson.GetBytes(out, path).String()) + } + require.Equal(t, `sessions_"quoted`, gjson.GetBytes(out, "messages.0.content.0.input.name").String()) + require.Equal(t, `sessions_"quoted`, gjson.GetBytes(out, "metadata.name").String()) + require.False(t, gjson.GetBytes(out, "tools.1.cache_control").Exists()) + require.Equal(t, "5m", gjson.GetBytes(out, "tools.0.cache_control.ttl").String()) + require.False(t, gjson.GetBytes(out, "messages.0.content.1.name").Exists()) +} + +func TestApplyToolNameRewriteToBody_DuplicateNames(t *testing.T) { + body := []byte(`{"tools":[{"name":"sessions_first","name":"sessions_second"}],"messages":[{"content":[{"type":"tool_use","name":"sessions_first","name":"sessions_second"}]}]}`) + out := applyToolNameRewriteToBody(body, buildToolNameRewriteFromBody(body)) + require.True(t, gjson.ValidBytes(out)) + require.Equal(t, 2, strings.Count(string(out), `"name":"cc_sess_first","name":"sessions_second"`)) +} + +func TestApplyToolNameRewriteToBody_NonStringReferences(t *testing.T) { + body := []byte(`{"tools":[{"name":"123"}],"tool_choice":{"type":"tool","name":123},"messages":[{"content":[{"type":"tool_use","name":123},{"type":"tool_use","name":true}]}]}`) + rw := &ToolNameRewrite{Forward: map[string]string{"123": "replacement", "true": "also_replaced"}} + out := applyToolNameRewriteToBody(body, rw) + require.True(t, gjson.ValidBytes(out)) + require.Equal(t, "replacement", gjson.GetBytes(out, "tools.0.name").String()) + require.Equal(t, `"replacement"`, gjson.GetBytes(out, "tool_choice.name").Raw) + require.Equal(t, `"replacement"`, gjson.GetBytes(out, "messages.0.content.0.name").Raw) + require.Equal(t, `"also_replaced"`, gjson.GetBytes(out, "messages.0.content.1.name").Raw) +} + +func makeLongToolHistory() []byte { + var b strings.Builder + _, _ = b.WriteString(`{"tools":[{"name":"sessions_list"}],"tool_choice":{"type":"tool","name":"sessions_list"},"messages":[`) + for i := 0; i < 1000; i++ { + if i > 0 { + _ = b.WriteByte(',') + } + _, _ = b.WriteString(`{"content":[{"type":"tool_use","name":"sessions_list","input":{"name":"sessions_list"}}]}`) + } + _, _ = b.WriteString(`]}`) + return []byte(b.String()) +} + +func TestApplyToolNameRewriteToBody_LongHistory(t *testing.T) { + body := makeLongToolHistory() + out := applyToolNameRewriteToBody(body, buildToolNameRewriteFromBody(body)) + require.True(t, gjson.ValidBytes(out)) + require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "messages.999.content.0.name").String()) + require.Equal(t, "sessions_list", gjson.GetBytes(out, "messages.999.content.0.input.name").String()) +} + +func BenchmarkApplyToolNameRewriteToBody_LongHistory(b *testing.B) { + body := makeLongToolHistory() + rw := buildToolNameRewriteFromBody(body) + b.ReportAllocs() + b.SetBytes(int64(len(body))) + for i := 0; i < b.N; i++ { + _ = applyToolNameRewriteToBody(body, rw) + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ff88e71003db..b6e91a52a87c 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -116,6 +116,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok { finalBetaHeader, finalBetaShouldSet = beta, true } + finalBetaHeader = filterSonnet55ToolsetBeta(finalBetaHeader, body, modelID) // 能力维度 body sanitize:与最终 anthropic-beta header 对称 if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed { @@ -200,6 +201,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。 // 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。 account.ApplyHeaderOverrides(req.Header) + filterSonnet55ToolsetBetaHeader(req.Header, body, modelID) // === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 === s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{ @@ -294,7 +296,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex( if policy.blockErr != nil { return nil, policy.blockErr } - finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet)) + finalBeta := filterSonnet55ToolsetBeta(filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet)), vertexBody, modelID) // 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的 // context_management,与 Anthropic 直连 / Bedrock 路径对称。 @@ -415,6 +417,39 @@ func defaultAPIKeyBetaHeader(body []byte) string { return claude.APIKeyBetaHeader } +// The legacy streaming beta is incompatible with the stable computer and +// browser toolsets. The per-tool eager_input_streaming field remains available. +func filterSonnet55ToolsetBeta(header string, body []byte, modelID string) string { + if at := strings.IndexByte(modelID, '@'); at >= 0 { + modelID = modelID[:at] // Vertex's dated model ID + } + if !claude.IsSonnet55(modelID) { + return header + } + for _, tool := range gjson.GetBytes(body, "tools").Array() { + switch tool.Get("type").String() { + case "computer_toolset_20260801", "browser_toolset_20260801": + if !containsBetaToken(header, claude.BetaFineGrainedToolStreaming) { + return header + } + return stripBetaTokensWithSet(header, map[string]struct{}{claude.BetaFineGrainedToolStreaming: {}}) + } + } + return header +} + +func filterSonnet55ToolsetBetaHeader(headers http.Header, body []byte, modelID string) { + header := getHeaderRaw(headers, "anthropic-beta") + filtered := filterSonnet55ToolsetBeta(header, body, modelID) + if filtered == header { + return + } + deleteHeaderAllForms(headers, "anthropic-beta") + if filtered != "" { + setHeaderRaw(headers, "anthropic-beta", filtered) + } +} + func applyClaudeOAuthHeaderDefaults(req *http.Request) { if req == nil { return @@ -501,7 +536,8 @@ func (s *GatewayService) computeFinalAnthropicBeta( clientHeaders http.Header, body []byte, effectiveDropSet map[string]struct{}, -) (string, bool) { +) (beta string, shouldSet bool) { + defer func() { beta = filterSonnet55ToolsetBeta(beta, body, modelID) }() clientBeta := "" if clientHeaders != nil { clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta") @@ -553,7 +589,8 @@ func (s *GatewayService) computeFinalCountTokensAnthropicBeta( clientHeaders http.Header, body []byte, effectiveDropSet map[string]struct{}, -) (string, bool) { +) (beta string, shouldSet bool) { + defer func() { beta = filterSonnet55ToolsetBeta(beta, body, modelID) }() clientBeta := "" if clientHeaders != nil { clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta") diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 410e749f7352..ee2f87a05592 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1852,6 +1852,11 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReasonWithRunt if account == nil { return false, "account_nil" } + if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount { + if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) { + return false, "account_model_not_owned" + } + } if req.RequirePrivacySet && !account.IsPrivacySet() { return false, "privacy_not_set" } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 74eb9411541e..a9af7bca3ce6 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -44,6 +44,10 @@ func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx return result, nil } +func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { + return append([]Account(nil), r.accounts...), nil +} + func (r schedulerTestOpenAIAccountRepo) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) { var result []Account for _, acc := range r.accounts { @@ -1249,6 +1253,116 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedSessionIn } } +func TestOpenAIGatewayService_SelectAccountWithScheduler_AccountModelRouteOnlyUsesClaimingAccount(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(1010711) + owner := Account{ + ID: 371011, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}}, + } + nonOwner := Account{ + ID: 371012, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, + } + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{owner, nonOwner}} + ownership, err := (&GatewayService{accountRepo: repo}).resolveCompositeModelOwnership(ctx, groupID, "team-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, ownership) + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: ownership.TargetPlatform, UpstreamModel: "team-alias", + }) + + for _, tt := range []struct { + name string + sessionHash string + sessionBindings map[string]int64 + }{ + {name: "initial filter excludes non owner"}, + {name: "non owner sticky hit is rejected", sessionHash: "non-owner", sessionBindings: map[string]int64{"openai:non-owner": nonOwner.ID}}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: &schedulerTestGatewayCache{sessionBindings: tt.sessionBindings}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, _, selectErr := svc.SelectAccountWithScheduler( + ctx, &groupID, "", tt.sessionHash, "team-alias", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, selectErr) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, owner.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_AccountModelRouteDBRecheckFailsOverToOwner(t *testing.T) { + ctx := context.Background() + groupID := int64(1010712) + mapping := map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}} + stalePrimary := &Account{ + ID: 371021, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + staleBackup := &Account{ + ID: 371022, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + dbPrimary := *stalePrimary + dbPrimary.Credentials = nil + dbBackup := *staleBackup + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{dbPrimary, dbBackup}} + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: PlatformOpenAI, UpstreamModel: "team-alias", + }) + acquiredIDs, releasedIDs := []int64{}, []int64{} + svc := &OpenAIGatewayService{ + accountRepo: repo, + cfg: &config.Config{RunMode: config.RunModeStandard}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{ + snapshotAccounts: []*Account{stalePrimary, staleBackup}, + accountsByID: map[int64]*Account{stalePrimary.ID: stalePrimary, staleBackup.ID: staleBackup}, + }}, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{ + acquiredIDs: &acquiredIDs, releasedIDs: &releasedIDs, + }), + } + + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "", "team-alias", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, dbBackup.ID, selection.Account.ID) + if containsInt64(acquiredIDs, dbPrimary.ID) { + require.Contains(t, releasedIDs, dbPrimary.ID) + } + require.Contains(t, acquiredIDs, dbBackup.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedPreviousRequiresMovableContext(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -3833,3 +3947,126 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityWai require.Equal(t, int64(38011), selection.WaitPlan.AccountID) require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) } + +func TestOpenAIGatewayService_SelectAccountWithScheduler_LegacyAccountModelRouteExcludesNonOwner(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(1010713) + owner := Account{ + ID: 371031, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}}, + // WS transport must be resolvable for the websocket sub-case so the + // ownership filter, not transport admission, is the behavior under test. + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + nonOwner := Account{ + ID: 371032, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{owner, nonOwner}} + ownership, err := (&GatewayService{accountRepo: repo}).resolveCompositeModelOwnership(ctx, groupID, "team-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, ownership) + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: ownership.TargetPlatform, UpstreamModel: "team-alias", + }) + + for _, tt := range []struct { + name string + requiredTransport OpenAIUpstreamTransport + sessionHash string + sessionBindings map[string]int64 + }{ + {name: "initial filter excludes non owner", requiredTransport: OpenAIUpstreamTransportAny}, + {name: "initial filter excludes non owner for websocket transport", requiredTransport: OpenAIUpstreamTransportResponsesWebsocket}, + {name: "non owner sticky hit is rejected", requiredTransport: OpenAIUpstreamTransportAny, sessionHash: "non-owner", sessionBindings: map[string]int64{"openai:non-owner": nonOwner.ID}}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsockets = true + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: &schedulerTestGatewayCache{sessionBindings: tt.sessionBindings}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + require.Nil(t, svc.getOpenAIAccountScheduler(ctx), "test precondition: advanced scheduler disabled") + + selection, _, selectErr := svc.SelectAccountWithScheduler( + ctx, &groupID, "", tt.sessionHash, "team-alias", nil, tt.requiredTransport, false, + ) + require.NoError(t, selectErr) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, owner.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_LegacyAccountModelRouteDBRecheckFailsOverToOwner(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(1010714) + mapping := map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}} + stalePrimary := &Account{ + ID: 371041, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + staleBackup := &Account{ + ID: 371042, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + dbPrimary := *stalePrimary + dbPrimary.Credentials = nil + dbBackup := *staleBackup + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{dbPrimary, dbBackup}} + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: PlatformOpenAI, UpstreamModel: "team-alias", + }) + acquiredIDs, releasedIDs := []int64{}, []int64{} + cfg := &config.Config{RunMode: config.RunModeStandard} + svc := &OpenAIGatewayService{ + accountRepo: repo, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{ + snapshotAccounts: []*Account{stalePrimary, staleBackup}, + accountsByID: map[int64]*Account{stalePrimary.ID: stalePrimary, staleBackup.ID: staleBackup}, + }}, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{ + acquiredIDs: &acquiredIDs, releasedIDs: &releasedIDs, + }), + } + require.Nil(t, svc.getOpenAIAccountScheduler(ctx), "test precondition: advanced scheduler disabled") + + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "", "team-alias", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, dbBackup.ID, selection.Account.ID) + if containsInt64(acquiredIDs, dbPrimary.ID) { + require.Contains(t, releasedIDs, dbPrimary.ID) + } + require.Contains(t, acquiredIDs, dbBackup.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 3298435b56af..0e5ebbb3ef82 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -469,11 +469,18 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr } if isClaudeCodexModel(modelID) { + if claude.IsOpus55(modelID) || claude.IsSonnet55(modelID) { + descriptor.ContextWindow = 1_000_000 + descriptor.MaxContextWindow = 1_000_000 + } descriptor.DisplayName = claudeCodexDisplayName(modelID) descriptor.Description = "Claude coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true if levels := configuredCodexClaudeReasoningLevels(modelID); len(levels) > 0 { defaultReasoningLevel := claudeCodexDefaultReasoningLevel(levels) + if claude.IsSonnet55(modelID) { + defaultReasoningLevel = "high" + } descriptor.DefaultReasoningLevel = &defaultReasoningLevel descriptor.SupportedReasoningLevels = levels } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index bfd5cf3b8823..11793f8d2d5f 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -322,6 +322,12 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5") require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels)) + claudeSonnet55 := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-5-5") + require.Equal(t, "Claude Sonnet 5.5", claudeSonnet55.DisplayName) + require.Equal(t, int64(1_000_000), claudeSonnet55.ContextWindow) + require.Equal(t, int64(1_000_000), claudeSonnet55.MaxContextWindow) + require.Equal(t, "high", *claudeSonnet55.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeSonnet55.SupportedReasoningLevels)) providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6") require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName) diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go index f224363da704..736e8f7ae42f 100644 --- a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go +++ b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go @@ -140,7 +140,7 @@ func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { resp, pr, pw := newHangingUpstreamResponse() start := time.Now() - res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true) + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start) _ = pw.Close() _ = pr.Close() @@ -222,7 +222,7 @@ func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { }() defer func() { _ = pr.Close() }() - res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true) + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index ceed01cdd2d3..383c7a6f44c6 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -55,7 +55,6 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( return nil, fmt.Errorf("missing model in request") } clientStream := ccReq.Stream - includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage // 2. Convert CC → Responses → Anthropic (chained conversion) responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) @@ -66,7 +65,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + if err := validateClaude55Request(body, upstreamModel); err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -141,7 +140,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel) if clientStream { - return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) + return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } @@ -308,7 +307,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( upstreamModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -325,7 +323,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -440,8 +437,12 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } - // 客户端已断开或协议失败:跳过转换与写出,继续读上游直到流结束(usage 完整、 - // 连接及时归还),不再提前 return。 + // Keep the outward Responses/Chat usage on the same normalized buckets used + // for billing, including converter handlers that consume event usage. + syncAnthropicResponsesUsage(anthState, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + + // 客户端已断开:跳过转换与写出,继续读上游直到流结束(usage 完整、 // 连接及时归还),不再提前 return。 if clientDisconnected || protocolErr != nil { return false } @@ -488,6 +489,10 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( continue } + // Forward received usage regardless of the client stream_options. + // The intermediate Responses converter synthesizes usage even when absent. + ccState.IncludeUsage = ccState.IncludeUsage || anthropicChatStreamHasUsage(&event, payload) + if processAnthropicEvent(&event) { return resultWithUsage(), protocolErr } diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 06b5d1efa88f..fb231b71e478 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -11,9 +11,12 @@ package service import ( "context" + "encoding/json" "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -109,6 +112,83 @@ func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *te "output_tokens 必须来自排水读到的末尾 message_delta(断开即弃时会是 1)") } +func TestResponsesStreamingFromNativeAnthropic_NormalizesTerminalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + startUsage string + deltaUsage string + repeatDelta bool + wantInput int + wantOutput int + wantCached int + wantCacheCreation int + }{ + {name: "full cache", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, + {name: "partial cache", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, + {name: "cache creation", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, + {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800`, repeatDelta: true, wantInput: 400, wantOutput: 30, wantCached: 800}, + } + + for _, tt := range tests { + for _, terminal := range []string{"message_stop", "eof"} { + t.Run(tt.name+"/"+terminal, func(t *testing.T) { + lines := []string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_usage","type":"message","role":"assistant","content":[],"model":"k3","stop_reason":"","usage":{` + tt.startUsage + `}}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{` + tt.deltaUsage + `}}`, + ``, + } + if tt.repeatDelta { + lines = append(lines, `event: message_delta`, `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{`+tt.deltaUsage+`}}`, ``) + } + if terminal == "message_stop" { + lines = append(lines, `event: message_stop`, `data: {"type":"message_stop"}`, ``) + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n")))} + + result, err := (&OpenAIGatewayService{}).handleResponsesStreamingFromNativeAnthropic( + resp, c, "k3", "k3", "k3", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, result.Usage.InputTokens) + require.Equal(t, tt.wantOutput, result.Usage.OutputTokens) + require.Equal(t, tt.wantCached, result.Usage.CacheReadInputTokens) + require.Equal(t, tt.wantCacheCreation, result.Usage.CacheCreationInputTokens) + + var completed apicompat.ResponsesStreamEvent + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event apicompat.ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + if event.Type == "response.completed" { + completed = event + } + } + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, completed.Response.Usage.InputTokens) + require.Equal(t, tt.wantOutput, completed.Response.Usage.OutputTokens) + require.Equal(t, completed.Response.Usage.InputTokens+tt.wantOutput, completed.Response.Usage.TotalTokens) + require.Equal(t, tt.wantCacheCreation, completed.Response.Usage.CacheCreationInputTokens) + if tt.wantCached == 0 { + require.Nil(t, completed.Response.Usage.InputTokensDetails) + } else { + require.Equal(t, tt.wantCached, completed.Response.Usage.InputTokensDetails.CachedTokens) + } + }) + } + } +} + func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) { for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { repo := &rateLimitAccountRepoStub{} diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 8c786aeb05e6..e1dea9ef374d 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -215,6 +215,7 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) account.ApplyHeaderOverrides(req.Header) + filterSonnet55ToolsetBetaHeader(req.Header, body, gjson.GetBytes(body, "model").String()) payloads := append([][]byte{body}, sessionBodies...) applyOpenCodeSessionHeader(c, account, targetURL, req.Header, payloads...) diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 141d10d7376f..00c165d04833 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -20,7 +20,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" @@ -68,7 +67,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + if err := validateClaude55Request(body, upstreamModel); err != nil { writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -272,7 +271,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } } - if claude.IsOpus55(upstreamModel) { + if isClaude55SignedThinkingModel(upstreamModel) { finalResp.Model = upstreamModel } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) @@ -333,7 +332,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel - state.PreserveThinkingSignatures = claude.IsOpus55(upstreamModel) + state.PreserveThinkingSignatures = isClaude55SignedThinkingModel(upstreamModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage @@ -407,6 +406,12 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep terminal Responses usage aligned with the normalized billing + // buckets. Normalize converter input too so raw overlapping totals cannot + // overwrite the state when message_start/message_delta handlers run. + syncAnthropicResponsesUsage(state, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + events := apicompat.AnthropicEventToResponsesEvents(event, state) if clientDisconnected { return diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 18035f7ab168..69343c49d9ce 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -372,7 +372,20 @@ func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context func openAICompatibleAccountEligibilityBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) (bool, string) { platform = NormalizeOpenAICompatiblePlatform(platform) - if account == nil || account.Platform != platform || !account.IsOpenAICompatible() { + if account == nil { + return false, "account_nil" + } + // account_model composite routes publish an alias that only accounts with an + // explicit model mapping own. Both scheduler modes (advanced and legacy) must + // enforce ownership before any priority/sticky/transport consideration; a + // non-owner would forward the raw public alias and get model_not_found or + // hit an unrelated same-name upstream model. + if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount { + if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) { + return false, "account_model_not_owned" + } + } + if account.Platform != platform || !account.IsOpenAICompatible() { return false, "platform_mismatch" } if !account.IsSchedulableForModelWithContext(ctx, requestedModel) { diff --git a/backend/internal/service/openai_images_json_keepalive_test.go b/backend/internal/service/openai_images_json_keepalive_test.go index e231cfc96801..780f47a1c3a2 100644 --- a/backend/internal/service/openai_images_json_keepalive_test.go +++ b/backend/internal/service/openai_images_json_keepalive_test.go @@ -134,6 +134,7 @@ func TestOpenAIImagesJSONKeepalive_KeepsOAuthNonStreamResponseValid(t *testing.T }() stop := StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond) + waitForOpenAIImagesJSONKeepalive(t, c) resp := &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index ad0566ae051a..cff9c4e0ae64 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -676,7 +676,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeOwnedTurnState.turnID = currentLogicalTurn bridgeLogicalTurn = currentLogicalTurn if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { - if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { + if err := hooks.BeforeRequest(turn, currentBridgePayload.rawForHash, currentBridgePayload.originalModel); err != nil { return err } } @@ -1546,6 +1546,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } currentPayload := firstPayload.payloadRaw + // Admission hooks must see client model candidates before upstream mapping. + currentClientPayload := firstPayload.rawForHash currentOriginalModel := firstPayload.originalModel currentImageBillingModel := firstPayload.imageBillingModel currentImageSizeTier := firstPayload.imageSizeTier @@ -1692,7 +1694,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } for { if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil { - if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil { + if err := hooks.BeforeRequest(turn, currentClientPayload, currentOriginalModel); err != nil { return err } } @@ -2030,6 +2032,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } currentPayload = nextPayload.payloadRaw + currentClientPayload = nextPayload.rawForHash currentOriginalModel = nextPayload.originalModel currentImageBillingModel = nextPayload.imageBillingModel currentImageSizeTier = nextPayload.imageSizeTier diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 4a44ef33d62d..8de9809cc250 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -116,6 +116,12 @@ var ( CacheCreationInputTokenCostPriority: 10e-6, CacheReadInputTokenCostPriority: 0.4e-6, SupportsServiceTier: true, LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, } + claudeSonnet55FallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 2e-6, OutputCostPerToken: 10e-6, + CacheCreationInputTokenCost: 2.5e-6, CacheCreationInputTokenCostAbove1hr: 4e-6, + CacheReadInputTokenCost: 0.2e-6, + LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -1388,6 +1394,12 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { } return claudeOpus55FallbackPricing } + if claude.IsSonnet55(model) { + if pricing, ok := s.pricingData["claude-sonnet-5-5"]; ok { + return pricing + } + return claudeSonnet55FallbackPricing + } // modelFamily 定义一个模型系列的匹配和定价查找规则。 type modelFamily struct { name string // 系列名称 diff --git a/backend/internal/service/responses_chat_protocol_callers_test.go b/backend/internal/service/responses_chat_protocol_callers_test.go index d13e591938e6..8cfc4607e5be 100644 --- a/backend/internal/service/responses_chat_protocol_callers_test.go +++ b/backend/internal/service/responses_chat_protocol_callers_test.go @@ -111,12 +111,12 @@ func TestAnthropicChatCallersSurfaceProtocolFailure(t *testing.T) { var err error if native { var result *OpenAIForwardResult - result, err = (&OpenAIGatewayService{}).handleCCStreamingFromNativeAnthropic(resp, c, "fixture", "fixture", "fixture", nil, time.Now(), true) + result, err = (&OpenAIGatewayService{}).handleCCStreamingFromNativeAnthropic(resp, c, "fixture", "fixture", "fixture", nil, time.Now()) require.NotNil(t, result) input, output = result.Usage.InputTokens, result.Usage.OutputTokens } else { var result *ForwardResult - result, err = (&GatewayService{}).handleCCStreamingFromAnthropic(resp, c, "fixture", "fixture", nil, time.Now(), true) + result, err = (&GatewayService{}).handleCCStreamingFromAnthropic(resp, c, "fixture", "fixture", nil, time.Now()) require.NotNil(t, result) input, output = result.Usage.InputTokens, result.Usage.OutputTokens } @@ -166,7 +166,7 @@ func TestNativeAnthropicChatProtocolGuardPreservesDisconnectDrain(t *testing.T) c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) c.Writer = &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 2} - result, err := (&OpenAIGatewayService{}).handleCCStreamingFromNativeAnthropic(resp, c, "fixture", "fixture", "fixture", nil, time.Now(), true) + result, err := (&OpenAIGatewayService{}).handleCCStreamingFromNativeAnthropic(resp, c, "fixture", "fixture", "fixture", nil, time.Now()) require.NoError(t, err, "an intentionally unconverted tail is not evidence of upstream corruption") require.NotNil(t, result) require.True(t, result.ClientDisconnect) diff --git a/backend/internal/service/risk_control_allowlist_test.go b/backend/internal/service/risk_control_allowlist_test.go new file mode 100644 index 000000000000..8019c1cdd844 --- /dev/null +++ b/backend/internal/service/risk_control_allowlist_test.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type failingAllowlistRepo struct { + fakeSettingRepo + failKey string +} + +func (r *failingAllowlistRepo) GetValue(ctx context.Context, key string) (string, error) { + if key == r.failKey || r.failKey == "all" { + return "", errors.New("database unavailable") + } + return r.fakeSettingRepo.GetValue(ctx, key) +} + +func TestRiskControlAllowlistRetainsLastGoodValueAndRetries(t *testing.T) { + for _, failKey := range []string{SettingKeyCyberPolicyUserAllowlist, "all"} { + t.Run(failKey, func(t *testing.T) { + repo := &failingAllowlistRepo{fakeSettingRepo: fakeSettingRepo{vals: map[string]string{SettingKeyCyberPolicyUserAllowlist: "12"}}} + svc := &SettingService{settingRepo: repo} + ctx := context.Background() + require.True(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + expire := func() { + cached, ok := svc.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime) + require.True(t, ok) + old := *cached + old.expiresAt = 0 + svc.cyberSessionBlockRuntimeCache.Store(&old) + } + expire() + repo.failKey = failKey + require.True(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + cached, ok := svc.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime) + require.True(t, ok) + require.LessOrEqual(t, time.Until(time.Unix(0, cached.expiresAt)), cyberSessionBlockRuntimeErrorTTL) + repo.failKey = "" + repo.vals[SettingKeyCyberPolicyUserAllowlist] = "" + expire() + require.False(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12), "successful removal must replace stale membership") + }) + } +} + +func TestRiskControlAllowlistAuditsWithoutLocalPenalties(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{CategoryScores: map[string]float64{"sexual": 0.99}}}}) + })) + defer server.Close() + for _, path := range []string{"keyword", "hash", "pre_block", "observe"} { + t.Run(path, func(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.Enabled, cfg.AutoBanEnabled, cfg.EmailOnHit = true, true, true + cfg.BanThreshold = 1 + cfg.Mode = ContentModerationModePreBlock + cfg.BaseURL, cfg.APIKeys = server.URL, []string{"sk-test"} + if path == "observe" { + cfg.Mode = ContentModerationModeObserve + } + if path == "keyword" { + cfg.BlockedKeywords = []string{"blocked prompt"} + } + hashes := &contentModerationTestHashCache{hashes: map[string]struct{}{}} + if path == "hash" { + cfg.PreHashCheckEnabled = true + hashes.hashes[(ContentModerationInput{Text: "blocked prompt"}).Hash()] = struct{}{} + } + raw, err := json.Marshal(cfg) + require.NoError(t, err) + repo := &banCountArgsTestRepo{} + svc := &ContentModerationService{ + settingRepo: &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", SettingKeyContentModerationConfig: string(raw), SettingKeyCyberPolicyUserAllowlist: "12", + }}, + repo: repo, hashCache: hashes, emailService: &EmailService{}, + httpClient: server.Client(), asyncQueue: make(chan contentModerationTask, 4), keyHealth: make(map[string]*contentModerationKeyHealth), + } + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ + UserID: 12, UserEmail: "trusted@example.com", Protocol: ContentModerationProtocolOpenAIChat, + Body: []byte(`{"messages":[{"role":"user","content":"blocked prompt"}]}`), + }) + require.NoError(t, err) + require.True(t, decision.Allowed) + require.False(t, decision.Blocked) + var task contentModerationTask + select { + case task = <-svc.asyncQueue: + case <-time.After(time.Second): + t.Fatal("expected an audit task") + } + if task.log != nil { + svc.persistContentModerationLog(context.Background(), task.config, task.log, task.inputHash, task.recordHash, task.applySideEffects) + } else { + delay := 1 + svc.checkSync(context.Background(), task.input, svc.runtimeSnapshot.Load().config, task.content, task.inputHash, &delay, false) + } + logs := repo.snapshotLogs() + require.Len(t, logs, 1) + require.True(t, logs[0].Flagged) + require.Equal(t, ContentModerationModeRiskControlLogOnly, logs[0].Mode) + require.False(t, logs[0].AutoBanned) + require.False(t, logs[0].EmailSent) + require.Zero(t, logs[0].ViolationCount) + require.Empty(t, repo.snapshotCountCalls()) + require.Zero(t, svc.preBlockBlocked.Load()) + if path != "hash" { + require.Empty(t, hashes.hashes) + } + }) + } +} + +type failingModerationAllowlistRepo struct { + contentModerationTestSettingRepo + fail bool +} + +func (r *failingModerationAllowlistRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if r.fail { + return nil, errors.New("database unavailable") + } + return r.contentModerationTestSettingRepo.GetMultiple(ctx, keys) +} + +func TestRiskControlAllowlistSnapshotRetainsMembershipOnFailure(t *testing.T) { + repo := &failingModerationAllowlistRepo{contentModerationTestSettingRepo: contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", SettingKeyCyberPolicyUserAllowlist: "12", + }}} + svc := &ContentModerationService{settingRepo: repo} + ctx := context.Background() + _, err := svc.refreshRuntimeSnapshot(ctx) + require.NoError(t, err) + repo.fail = true + _, err = svc.refreshRuntimeSnapshot(ctx) + require.Error(t, err) + require.Contains(t, svc.runtimeSnapshot.Load().allowlistedUsers, int64(12)) + repo.fail = false + repo.values[SettingKeyCyberPolicyUserAllowlist] = "34" + _, err = svc.refreshRuntimeSnapshot(ctx) + require.NoError(t, err) + require.NotContains(t, svc.runtimeSnapshot.Load().allowlistedUsers, int64(12)) + require.Contains(t, svc.runtimeSnapshot.Load().allowlistedUsers, int64(34)) +} diff --git a/backend/internal/service/setting_cyber_allowlist_test.go b/backend/internal/service/setting_cyber_allowlist_test.go new file mode 100644 index 000000000000..7c0c6752acb8 --- /dev/null +++ b/backend/internal/service/setting_cyber_allowlist_test.go @@ -0,0 +1,42 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type cyberAllowlistSettingsRepo struct { + authSourceDefaultsRepoStub +} + +func (r *cyberAllowlistSettingsRepo) GetValue(_ context.Context, key string) (string, error) { + value, ok := r.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil +} + +func TestUpdateSettingsCyberAllowlistRoundTripAndImmediateRefresh(t *testing.T) { + ctx := context.Background() + repo := &cyberAllowlistSettingsRepo{authSourceDefaultsRepoStub{values: map[string]string{}}} + svc := NewSettingService(repo, &config.Config{}) + resetGatewayForwardingSettingsCacheForTest(t) + defer svc.refreshCachedSettings(&SystemSettings{}) + require.False(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + settings := &SystemSettings{CyberPolicyUserAllowlist: "12, 34"} + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.True(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + require.Equal(t, "12, 34", svc.parseSettings(repo.values).CyberPolicyUserAllowlist) + settings.CyberPolicyUserAllowlist = "12, invalid" + require.Error(t, svc.UpdateSettings(ctx, settings)) + require.Equal(t, "12, 34", repo.values[SettingKeyCyberPolicyUserAllowlist]) + settings.CyberPolicyUserAllowlist = "" + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.False(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index c27f02652324..149f3ccc00c3 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -152,12 +152,13 @@ type cachedCodexRestrictionPolicy struct { expiresAt int64 // unix nano } -// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 +// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关、TTL 与用户白名单缓存(60s TTL)。 // GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 type cachedCyberSessionBlockRuntime struct { - enabled bool - ttl time.Duration - expiresAt int64 // unix nano + allowlistedUsers map[int64]struct{} + enabled bool + ttl time.Duration + expiresAt int64 // unix nano } const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second @@ -298,7 +299,7 @@ func buildOpenAIRefusalRecoveryRuntime(values map[string]string) OpenAIRefusalRe // GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, // 供网关热路径读取时避免 DB 往返。 -// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 +// 屏蔽设置与用户白名单在单次 singleflight 中一起读取。 // 默认值:开关 false,TTL 1h(与粘性会话对齐)。 func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { @@ -307,6 +308,8 @@ func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, } } result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) { + s.cyberSessionBlockRuntimeMu.Lock() + defer s.cyberSessionBlockRuntimeMu.Unlock() if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return cached, nil @@ -318,15 +321,11 @@ func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled) ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds) + previous, _ := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime) + cacheTTL := cyberSessionBlockRuntimeCacheTTL if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) { slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr) - entry := &cachedCyberSessionBlockRuntime{ - enabled: false, - ttl: time.Hour, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(), - } - s.cyberSessionBlockRuntimeCache.Store(entry) - return entry, nil + cacheTTL = cyberSessionBlockRuntimeErrorTTL } enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true" @@ -338,10 +337,23 @@ func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, } } + allowlistVal, allowlistErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberPolicyUserAllowlist) + var allowlistedUsers map[int64]struct{} + if allowlistErr == nil { + allowlistedUsers, allowlistErr = ParseCyberPolicyUserAllowlist(allowlistVal) + } + if allowlistErr != nil && !errors.Is(allowlistErr, ErrSettingNotFound) { + slog.Warn("failed to load risk control user allowlist", "error", allowlistErr) + cacheTTL = cyberSessionBlockRuntimeErrorTTL + if previous != nil { + allowlistedUsers = previous.allowlistedUsers + } + } entry := &cachedCyberSessionBlockRuntime{ - enabled: enabled, - ttl: ttl, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(), + allowlistedUsers: allowlistedUsers, + enabled: enabled, + ttl: ttl, + expiresAt: time.Now().Add(cacheTTL).UnixNano(), } s.cyberSessionBlockRuntimeCache.Store(entry) return entry, nil diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 69fd73c26991..66db3492dcdf 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -220,6 +220,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) SettingKeyCyberSessionBlockEnabled: "false", + SettingKeyCyberPolicyUserAllowlist: "", SettingKeyCyberSessionBlockTTLSeconds: "3600", SettingKeyOpenAIRefusalRecoveryEnabled: "false", SettingKeyOpenAICyberFailoverEnabled: "false", @@ -228,7 +229,6 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyOpenAIRefusalRewriteEnabled: "false", SettingKeyOpenAIRefusalKeywords: defaultOpenAIRefusalKeywordsJSON, SettingKeyOpenAIRefusalReplacement: "", - // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", @@ -850,6 +850,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true" + result.CyberPolicyUserAllowlist = settings[SettingKeyCyberPolicyUserAllowlist] if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 { result.CyberSessionBlockTTLSeconds = v } else { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index b7b6bdafd4c7..1d8f5d4b5d13 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -135,6 +135,7 @@ type SettingService struct { codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group + cyberSessionBlockRuntimeMu sync.Mutex cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime cyberSessionBlockRuntimeSF singleflight.Group openAIRefusalRecoveryCache atomic.Value diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 5ed8227c61a0..dd888ed1a53e 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -457,6 +457,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting // cyber 会话屏蔽开关 + TTL updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled) + if _, err := ParseCyberPolicyUserAllowlist(settings.CyberPolicyUserAllowlist); err != nil { + return nil, err + } + updates[SettingKeyCyberPolicyUserAllowlist] = settings.CyberPolicyUserAllowlist if settings.CyberSessionBlockTTLSeconds > 0 { updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds) } @@ -838,6 +842,11 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 s.codexRestrictionPolicySF.Forget("codex_restriction_policy") s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + // Retain the successfully saved allowlist if the next DB refresh fails. + s.cyberSessionBlockRuntimeMu.Lock() + allowlistedUsers, _ := ParseCyberPolicyUserAllowlist(settings.CyberPolicyUserAllowlist) + s.cyberSessionBlockRuntimeCache.Store(&cachedCyberSessionBlockRuntime{allowlistedUsers: allowlistedUsers}) + s.cyberSessionBlockRuntimeMu.Unlock() if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 78e608b63620..fd7846b428c7 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -169,6 +169,7 @@ type SystemSettings struct { DefaultBalance float64 RiskControlEnabled bool CyberSessionBlockEnabled bool + CyberPolicyUserAllowlist string CyberSessionBlockTTLSeconds int OpenAIRefusalRecoveryEnabled bool OpenAICyberFailoverEnabled bool @@ -185,7 +186,6 @@ type SystemSettings struct { AdminRechargeRebateEnabled bool DefaultUserRPMLimit int DefaultSubscriptions []DefaultSubscriptionSetting - // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` FallbackModelAnthropic string `json:"fallback_model_anthropic"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index a7d8099c37f0..83366dba1c74 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -1035,6 +1035,7 @@ var ProviderSet = wire.NewSet( NewImageStudioService, wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)), NewOAuthService, + NewClaudeResetCreditService, ProvideOpenAIOAuthService, ProvideGrokOAuthService, wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)), diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index a266d6bc3ba6..c727d9fdbad3 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -867,6 +867,33 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, + "claude-sonnet-5-5": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_1hr": 4e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://platform.claude.com/docs/en/models/sonnet-5-5/overview", + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "codex-auto-review": { "cache_read_input_token_cost": 2e-08, "input_cost_per_token": 2e-07, diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index bf32c902bc9f..99456f6efcc2 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -63,6 +63,13 @@ payloads, and apply the configured `upstream_model` before dispatch. Codex model manifest requests reuse the existing OpenAI account selection and failover path within the Composite group. +Responses WebSocket resolves the first `response.create` model in the +`responses` route domain (including `any` routes). Only OpenAI and Grok targets +are supported. The public model remains the admission and response identity; +the route's upstream model is applied before channel/account mapping. Subsequent +turns can repeat or omit the public model. Changing it requires reconnecting so +the gateway can resolve the route and select an account again. + ## Built-In Detection Composite routing detects common public model IDs and provider-prefixed IDs: diff --git a/frontend/src/api/admin/claudeResetCredits.ts b/frontend/src/api/admin/claudeResetCredits.ts new file mode 100644 index 000000000000..f7011d05cc3d --- /dev/null +++ b/frontend/src/api/admin/claudeResetCredits.ts @@ -0,0 +1,27 @@ +import { apiClient } from '../client' + +export interface ClaudeResetCredit { + label: string + resets_left: number + starts_at?: string + expires_at?: string + clears: string[] + percent_used: Record + blocking: string[] + use_requires_limit: boolean + redeemable: boolean +} + +export interface ClaudeResetCredits { + eligible: boolean + available_count: number + credits: ClaudeResetCredit[] + cooldown_until?: string + weekly_resets_at?: string + fetched_at: string +} + +export async function getClaudeResetCredits(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/claude/reset-credits`) + return data +} diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 92d9861d4f9d..b50be760f320 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -235,6 +235,7 @@ export async function getApiKeyUsageTrend( export interface UserTrendParams extends TrendParams { limit?: number + metric?: 'tokens' | 'actual_cost' } export interface UserTrendResponse { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index bfff070925ba..130ff167861f 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -689,6 +689,7 @@ export interface SystemSettings { risk_control_enabled: boolean; // Cyber session block + cyber_policy_user_allowlist: string; cyber_session_block_enabled: boolean; cyber_session_block_ttl_seconds: number; openai_refusal_recovery_enabled: boolean; @@ -1017,6 +1018,7 @@ export interface UpdateSettingsRequest { risk_control_enabled?: boolean; // Cyber session block + cyber_policy_user_allowlist?: string; cyber_session_block_enabled?: boolean; cyber_session_block_ttl_seconds?: number; openai_refusal_recovery_enabled?: boolean; diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 7a6c6e1882b8..e5c5bf52b2be 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -278,6 +278,7 @@ const formatScopeName = (scope: string): string => { 'claude-sonnet-4-6': 'CSon46', 'claude-sonnet-4-5': 'CSon45', 'claude-sonnet-4-5-thinking': 'CSon45T', + 'claude-sonnet-5-5': 'CSon55', 'claude-sonnet-5': 'CSon5', // Gemini 2.5 系列 'gemini-2.5-flash': 'G25F', diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index f1c6a2e096e2..919a0a3a6807 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -137,8 +137,21 @@ color="amber" /> - -
+
+ + +
+
{{ emptyUsageText }}
+ + +
+ + + + @@ -828,6 +834,7 @@ import { enqueueUsageRequest } from '@/utils/usageLoadQueue' import { formatCompactNumber, formatRelativeTime } from '@/utils/format' import UsageProgressBar from './UsageProgressBar.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue' +import ClaudeResetCreditsCell from './ClaudeResetCreditsCell.vue' import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue' import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue' import CNProviderQuotaCell from './CNProviderQuotaCell.vue' diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 3ba2745b97c3..20867967b25f 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -351,6 +351,7 @@ diff --git a/frontend/src/components/account/ClaudeResetCreditsCell.vue b/frontend/src/components/account/ClaudeResetCreditsCell.vue new file mode 100644 index 000000000000..4a139b16659d --- /dev/null +++ b/frontend/src/components/account/ClaudeResetCreditsCell.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c7f4a368b156..0efba782b5e7 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1541,6 +1541,7 @@ :sync-source-key="capacitySyncSourceKey" @capacity-validity="setCapacityFieldValidity('selector', $event)" v-model="allowedModels" + :model-mappings="modelMappings" :platform="form.platform" :sync-credentials="syncPreviewCredentials" :synced-models="capacitySyncedModels" @@ -2041,6 +2042,7 @@ :sync-source-key="capacitySyncSourceKey" @capacity-validity="setCapacityFieldValidity('selector', $event)" v-model="allowedModels" + :model-mappings="modelMappings" platform="anthropic" :sync-credentials="syncPreviewCredentials" :synced-models="capacitySyncedModels" @@ -2395,6 +2397,7 @@ :sync-source-key="capacitySyncSourceKey" @capacity-validity="setCapacityFieldValidity('selector', $event)" v-model="allowedModels" + :model-mappings="modelMappings" :platform="form.platform" :sync-credentials="syncPreviewCredentials" :synced-models="capacitySyncedModels" diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index a47f10dbe7b9..9875a51d2167 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -306,6 +306,7 @@ :sync-source-key="capacitySyncSourceKey" @capacity-validity="setCapacityFieldValidity('selector', $event)" v-model="allowedModels" + :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" :synced-models="capacitySyncedModels" @@ -803,7 +804,7 @@
{ appStore.showInfo(t('admin.accounts.modelExists')) return } + const conflict = props.modelMappings?.find(mapping => mapping.from.trim() === model && mapping.to.trim() && mapping.to.trim() !== model) + if (conflict) { + appStore.showInfo(t('admin.accounts.modelMappingConflict', { from: model, to: conflict.to.trim() })) + return + } emit('update:modelValue', [...props.modelValue, model]) customModel.value = '' } diff --git a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts index f7bab5adf1f3..9718c4e126e2 100644 --- a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts +++ b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts @@ -51,7 +51,7 @@ function makeAccount(overrides: Partial): Account { } describe('AccountStatusIndicator', () => { - it('Claude 5 模型限流时显示 Opus 和 Sonnet 的短别名', () => { + it('Claude 5 系列模型限流时显示 Opus 和 Sonnet 的短别名', () => { const wrapper = mount(AccountStatusIndicator, { props: { account: makeAccount({ @@ -64,6 +64,10 @@ describe('AccountStatusIndicator', () => { 'claude-sonnet-5': { rate_limited_at: '2026-07-28T00:00:00Z', rate_limit_reset_at: '2099-07-28T00:00:00Z' + }, + 'claude-sonnet-5-5': { + rate_limited_at: '2026-09-28T00:00:00Z', + rate_limit_reset_at: '2099-09-28T00:00:00Z' } } } @@ -78,6 +82,7 @@ describe('AccountStatusIndicator', () => { expect(wrapper.text()).toContain('COpus5') expect(wrapper.text()).toContain('CSon5') + expect(wrapper.text()).toContain('CSon55') expect(wrapper.text()).not.toContain('claude-sonnet-5') }) diff --git a/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts b/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts new file mode 100644 index 000000000000..81aca5a4798c --- /dev/null +++ b/frontend/src/components/account/__tests__/ClaudeResetCreditsCell.spec.ts @@ -0,0 +1,110 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ClaudeResetCreditsCell from '../ClaudeResetCreditsCell.vue' +import type { Account } from '@/types' +const getCredits = vi.hoisted(() => vi.fn()) +vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCredits })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +const account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account +const credit = { + label: 'Launch reset', resets_left: 1, + expires_at: '2026-10-22T16:00:00Z', clears: ['five_hour', 'seven_day'], + percent_used: {}, blocking: [], use_requires_limit: false, redeemable: true +} +const snapshot = { eligible: true, available_count: 1, credits: [credit], fetched_at: '2026-09-25T00:00:00Z' } +const countButton = (wrapper: ReturnType) => wrapper.find('[data-testid="claude-reset-count"]') + +describe('Claude reset credit status', () => { + beforeEach(() => getCredits.mockReset()) + + it('queries only on explicit request and shows count and expiry', async () => { + getCredits.mockResolvedValue(snapshot) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + expect(getCredits).not.toHaveBeenCalled() + expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count') + await countButton(wrapper).trigger('click') + await flushPromises() + expect(getCredits).toHaveBeenCalledWith(1) + expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1') + const expiry = wrapper.get('[data-testid="claude-reset-expiry"]') + expect(expiry.attributes('title')).toContain('Launch reset') + }) + + it('counts held resets and prefers the next redeemable grant', async () => { + const later = { ...credit, label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false } + const next = { ...credit, label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true } + getCredits.mockResolvedValue({ ...snapshot, available_count: 1, credits: [later, next] }) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await flushPromises() + expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count2') + expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Next') + expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(false) + }) + + it('flags held resets that cannot be used yet', async () => { + const waiting = { ...credit, use_requires_limit: true, redeemable: false } + getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [waiting] }) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await flushPromises() + expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count1') + expect(wrapper.find('[data-testid="claude-reset-not-usable"]').exists()).toBe(true) + }) + + it('offers no redeem action', () => { + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + expect(wrapper.findAll('button')).toHaveLength(1) + }) + + it('keeps slot content for setup tokens and discards responses after account changes', async () => { + let resolve!: (value: typeof snapshot) => void + getCredits.mockReturnValue(new Promise(r => { resolve = r })) + const wrapper = mount(ClaudeResetCreditsCell, { + props: { account }, + slots: { 'pre-actions': '' } + }) + await countButton(wrapper).trigger('click') + await wrapper.setProps({ account: { ...account, id: 2, type: 'setup-token' } }) + resolve(snapshot) + await flushPromises() + expect(countButton(wrapper).exists()).toBe(false) + expect(wrapper.find('[data-testid="local-query"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false) + }) + + it('falls back to the earliest parsed expiry across offsets, invalid last', async () => { + const a = { ...credit, label: 'Tokyo', expires_at: '2026-11-01T08:00:00+09:00', redeemable: false } + const b = { ...credit, label: 'Chicago', expires_at: '2026-10-31T20:00:00-05:00', redeemable: false } + const bad = { ...credit, label: 'Bad', expires_at: 'not-a-date', redeemable: false } + const none = { ...credit, label: 'None', expires_at: undefined, redeemable: false } + getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [none, bad, b, a] }) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Tokyo') + expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).not.toContain('Chicago') + }) + + it('hides a cooldown hint that is already in the past', async () => { + getCredits.mockResolvedValue({ ...snapshot, cooldown_until: '2000-01-01T00:00:00Z' }) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await flushPromises() + expect(wrapper.find('[data-testid="claude-reset-cooldown"]').exists()).toBe(false) + }) + + it('discards in-flight responses when the same account changes type', async () => { + let resolve!: (value: typeof snapshot) => void + getCredits.mockReturnValue(new Promise(r => { resolve = r })) + const wrapper = mount(ClaudeResetCreditsCell, { props: { account } }) + await countButton(wrapper).trigger('click') + await wrapper.setProps({ account: { ...account, type: 'setup-token' } }) + await wrapper.setProps({ account: { ...account } }) + resolve(snapshot) + await flushPromises() + expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count') + expect(countButton(wrapper).attributes('disabled')).toBeUndefined() + expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 097edeaa15aa..849342cdedb5 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -84,6 +84,7 @@ const ModelWhitelistSelectorStub = defineComponent({ name: 'ModelWhitelistSelector', components: { ModelContextCapacityField }, props: { + modelMappings: { type: Array, default: () => [] }, modelValue: { type: Array as PropType, default: () => [] @@ -790,6 +791,24 @@ describe('EditAccountModal', () => { afterEach(() => vi.useRealTimers()) + it('passes existing non-identity mappings to the whitelist selector and preserves them on save', async () => { + const account = buildAccount() + account.credentials.model_mapping = { 'gpt-5.2': 'gpt-5.2', 'gpt-latest': 'deepseek-chat' } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([ + { from: 'gpt-latest', to: 'deepseek-chat' } + ]) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual(account.credentials.model_mapping) + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, account: { ...account } }) + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([ + { from: 'gpt-latest', to: 'deepseek-chat' } + ]) + }) + it('sets expiry presets from now instead of extending the saved expiry', async () => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2028-02-29T12:34:00')) diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index e729471a14c8..f01be91a566b 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -28,6 +28,9 @@ vi.mock('vue-i18n', async () => { useI18n: () => ({ t: (key: string, params?: Record) => { if (key === 'common.copy') return '复制' + if (key === 'admin.accounts.modelMappingConflict') { + return `Model mapping conflict: ${params?.from} → ${params?.to}` + } return params?.count ? `${key} ${params.count}` : key } }) @@ -111,6 +114,37 @@ describe('ModelWhitelistSelector', () => { syncUpstreamModelsPreview.mockReset() }) + it('rejects a custom whitelist model that is already mapped to a different target', async () => { + const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue(' gpt-latest ') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(showInfo).toHaveBeenCalledWith(expect.stringContaining('gpt-latest → deepseek-chat')) + }) + + it('keeps the existing duplicate identity warning before checking mappings', async () => { + const wrapper = mountSelector({ modelValue: ['gpt-latest'], modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(showInfo).toHaveBeenCalledWith('admin.accounts.modelExists') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('allows matching identity mapping as a whitelist model', async () => { + const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'gpt-latest' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-latest']]]) + }) + + it('still allows custom models without a mapping prop', async () => { + const wrapper = mountSelector() + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('custom-model') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[['custom-model']]]) + }) + it('copies a model ID without selecting the model', async () => { const wrapper = mountSelector() await wrapper.get('div.cursor-pointer').trigger('click') diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index adb4d3752220..8d7578b5079a 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -277,6 +277,7 @@ interface Props { apiKey: string baseUrl: string platform: GroupPlatform | null + claudeCodeOnly?: boolean allowMessagesDispatch?: boolean } @@ -315,11 +316,14 @@ const codexModelManifestModelCount = ref(0) let codexModelManifestController: AbortController | null = null let codexModelManifestRequestID = 0 +// Official #7680 removed the Codex model catalog for OpenAI groups: the Codex CLI +// config no longer references model_catalog_json and the catalog panel is hidden. +// The OpenCode tab still needs the manifest, because downstream capacity is +// derived from resolver-tagged manifest rows rather than local hard-coded facts. const showCodexModelCatalog = computed(() => props.show && - (activeClientTab.value === 'codex' || - (props.platform === 'openai' && activeClientTab.value === 'codex-ws') || - activeClientTab.value === 'opencode') + (props.platform !== 'openai' || activeClientTab.value === 'opencode') && + (activeClientTab.value === 'codex' || activeClientTab.value === 'opencode') ) const codexModelCatalogPath = computed(() => { @@ -339,6 +343,7 @@ const codexManifestContext = computed(() => { // Reset tabs when platform changes const defaultClientTab = computed(() => { + if (props.claudeCodeOnly) return 'claude' switch (props.platform) { case 'openai': return 'codex' @@ -353,7 +358,7 @@ const defaultClientTab = computed(() => { } }) -watch(() => props.platform, () => { +watch(() => [props.platform, props.claudeCodeOnly], () => { activeTab.value = 'unix' activeClientTab.value = defaultClientTab.value codexAuthMode.value = 'legacy' @@ -448,6 +453,9 @@ const SparkleIcon = { const clientTabs = computed((): TabConfig[] => { if (!props.platform) return [] + if (props.claudeCodeOnly) { + return [{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }] + } switch (props.platform) { case 'openai': { const tabs: TabConfig[] = [ @@ -1003,7 +1011,6 @@ function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] { model = "${model}" review_model = "${model}" ${reasoningEffortLine}disable_response_storage = true -model_catalog_json = "${CODEX_MODEL_CATALOG_CONFIG_PATH}" network_access = "enabled" windows_wsl_setup_acknowledged = true @@ -1352,7 +1359,6 @@ function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] { model = "${model}" review_model = "${model}" ${reasoningEffortLine}disable_response_storage = true -model_catalog_json = "${CODEX_MODEL_CATALOG_CONFIG_PATH}" network_access = "enabled" windows_wsl_setup_acknowledged = true @@ -1823,6 +1829,18 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin xhigh: { effort: 'xhigh' }, max: { effort: 'max' } } + }, + 'claude-sonnet-5-5': { + name: 'Claude Sonnet 5.5', + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + options: { thinking: { type: 'adaptive' }, effort: 'high' }, + variants: { + low: { effort: 'low' }, + medium: { effort: 'medium' }, + high: { effort: 'high' }, + xhigh: { effort: 'xhigh' }, + max: { effort: 'max' } + } } } } else if (platform === 'antigravity-claude') { diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 47a3fd39e2aa..acfbd4e5b2a5 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -40,6 +40,44 @@ describe('UseKeyModal', () => { saveAsMock.mockClear() }) + it('shows only Claude Code for Claude Code-only groups', async () => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-anthropic-test', + baseUrl: 'https://example.com/v1', + platform: 'anthropic' + }, + global: { + stubs: { + BaseDialog: { template: '
' }, + Icon: { template: '' } + } + } + }) + + const clientTabs = () => wrapper.find('nav[aria-label="Client"]').text() + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.codexCli') + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.opencode') + + const codexTab = wrapper.find('nav[aria-label="Client"]').findAll('button').find( + (button) => button.text().includes('keys.useKeyModal.cliTabs.codexCli') + ) + await codexTab!.trigger('click') + await wrapper.setProps({ claudeCodeOnly: true }) + + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.claudeCode') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.codexCli') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.opencode') + expect(wrapper.find('pre code').text()).toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC') + + await wrapper.setProps({ platform: 'openai' }) + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.claudeCode') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.codexCli') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.opencode') + expect(wrapper.find('pre code').text()).toContain('ANTHROPIC_BASE_URL') + }) + it('omits the attribution override from every standard Claude Code setup form', async () => { const wrapper = mount(UseKeyModal, { props: { @@ -767,11 +805,17 @@ describe('UseKeyModal', () => { expect(tab).toBeDefined() await tab!.trigger('click') await nextTick() - const model = JSON.parse(wrapper.find('pre code').text()).provider.anthropic.models['claude-opus-5-5'] - expect(model.limit).toBeUndefined() - expect(model.options).toEqual({ thinking: { type: 'adaptive' }, effort: 'medium' }) - expect(model.variants.xhigh.effort).toBe('xhigh') - expect(model.variants).not.toHaveProperty('none') + const models = JSON.parse(wrapper.find('pre code').text()).provider.anthropic.models + // Downstream capacity contract: no local capacity facts. A limit may only + // come from a resolver-tagged manifest row, so a bare config stays unknown. + expect(models['claude-opus-5-5'].limit).toBeUndefined() + expect(models['claude-opus-5-5'].options).toEqual({ thinking: { type: 'adaptive' }, effort: 'medium' }) + expect(models['claude-opus-5-5'].variants.xhigh.effort).toBe('xhigh') + expect(models['claude-opus-5-5'].variants).not.toHaveProperty('none') + expect(models['claude-sonnet-5-5'].limit).toBeUndefined() + expect(models['claude-sonnet-5-5'].options).toEqual({ thinking: { type: 'adaptive' }, effort: 'high' }) + expect(models['claude-sonnet-5-5'].variants.xhigh.effort).toBe('xhigh') + expect(models['claude-sonnet-5-5'].variants).not.toHaveProperty('none') }) it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => { @@ -1009,21 +1053,9 @@ describe('UseKeyModal', () => { expect(config).toContain('review_model = "gpt-5.5"') }) - it('derives OpenAI Codex reasoning effort from the selected catalog descriptor', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - models: [ - { - slug: 'glm-5.3', - default_reasoning_level: 'none', - supported_reasoning_levels: [{ effort: 'none' }] - } - ] - }) - })) - + it('omits the Codex catalog for OpenAI in both transport modes and on both platforms', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) const wrapper = mount(UseKeyModal, { props: { show: true, @@ -1043,13 +1075,18 @@ describe('UseKeyModal', () => { } }) - await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click') - await flushPromises() - - const configToml = wrapper.findAll('pre code') - .map((code) => code.text()) - .find((content) => content.includes('model_provider = "OpenAI"')) - expect(configToml).toContain('model = "glm-5.3"') - expect(configToml).not.toContain('model_reasoning_effort') + for (const transport of ['keys.useKeyModal.cliTabs.codexCli', 'keys.useKeyModal.cliTabs.codexCliWs']) { + await wrapper.findAll('button').find((button) => button.text().trim() === transport)!.trigger('click') + for (const os of ['macOS / Linux', 'Windows']) { + await wrapper.findAll('button').find((button) => button.text().trim() === os)!.trigger('click') + const configToml = wrapper.findAll('pre code') + .map((code) => code.text()) + .find((content) => content.includes('model_provider = "OpenAI"')) + expect(configToml).toContain('model = "gpt-5.5"') + expect(configToml).not.toContain('model_catalog_json') + expect(wrapper.find('[data-testid="codex-model-catalog"]').exists()).toBe(false) + } + } + expect(fetchMock).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index 608988b82179..e8b278872abf 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -56,10 +56,21 @@ describe('useModelWhitelist', () => { expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5') expect(getModelsByPlatform('claude')).toContain('claude-opus-5-5') expect(getModelsByPlatform('antigravity')).not.toContain('claude-opus-5-5') + expect(getModelsByPlatform('claude')).toContain('claude-sonnet-5-5') + expect(getModelsByPlatform('antigravity')).not.toContain('claude-sonnet-5-5') expect(getModelsByPlatform('claude')).toContain('claude-opus-4-8') expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8') }) + it('Claude Sonnet 5.5 预设使用各平台的官方模型 ID', () => { + expect(getPresetMappingsByPlatform('claude')).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'claude-sonnet-5-5' }) + ])) + expect(getPresetMappingsByPlatform('bedrock')).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'global.anthropic.claude-sonnet-5-5' }) + ])) + }) + it('xAI 模型列表包含 Grok 4.5 官方模型和别名', () => { const models = getModelsByPlatform('grok') @@ -160,6 +171,19 @@ describe('useModelWhitelist', () => { }) }) + it('combined mode retains a mapping when a whitelist entry has the same source', () => { + expect(buildModelMappingObject('combined', ['gpt-latest'], [{ from: 'gpt-latest', to: 'deepseek-chat' }])).toEqual({ + 'gpt-latest': 'deepseek-chat' + }) + }) + + it('split mapping keeps only identity entries in the whitelist after reopening', () => { + expect(splitModelMappingObject({ 'gpt-latest': 'deepseek-chat', 'gpt-5.4': 'gpt-5.4' })).toEqual({ + allowedModels: ['gpt-5.4'], + modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] + }) + }) + it('splitModelMappingObject 会把身份映射还原成白名单,其余保留为映射', () => { const parsed = splitModelMappingObject({ 'gpt-5.4': 'gpt-5.4', diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 733c874fd8a0..133ee43371fc 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -36,8 +36,9 @@ export const claudeModels = [ 'claude-opus-4-8', 'claude-opus-5-5', 'claude-opus-5', - 'claude-sonnet-4-6', + 'claude-sonnet-5-5', 'claude-sonnet-5', + 'claude-sonnet-4-6', 'claude-fable-5-1', 'claude-fable-5' ] @@ -283,6 +284,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m })) const anthropicPresetMappings = [ { label: 'Fable 5.1', from: 'claude-fable-5-1', to: 'claude-fable-5-1', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, { label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, + { label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'claude-sonnet-5-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'claude-sonnet-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, { label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, @@ -395,6 +397,7 @@ const bedrockPresetMappings = [ { label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, { label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, { label: 'Opus 5', from: 'claude-opus-5', to: 'us.anthropic.claude-opus-5-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, + { label: 'Sonnet 5.5', from: 'claude-sonnet-5-5', to: 'global.anthropic.claude-sonnet-5-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'us.anthropic.claude-sonnet-5-v1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 01803b732849..6e4f923e21e7 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -1073,6 +1073,7 @@ export default {accounts: { enterCustomModelName: 'Enter custom model name', addModel: 'Add', modelExists: 'Model already exists', + modelMappingConflict: 'A mapping already exists for {from} → {to}. Modify or remove it under Model Mapping before adding this whitelist model', modelCount: '{count} models', poolMode: 'Pool Mode', poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status', @@ -1170,7 +1171,21 @@ export default {accounts: { autoPause5hDisabled: 'Disable 5h auto-pause', autoPause7dDisabled: 'Disable 7d auto-pause', autoPauseDisabledHint: 'When enabled, this account is never auto-paused (even if a global default threshold is configured).', - autoResetCredit: { + claudeResetCredits: { + count: 'Resets', + countTooltipLoad: 'Check remaining Claude resets (read-only, never consumes one)', + countTooltipRefresh: 'Refresh remaining Claude resets (read-only, never consumes one)', + fetched: 'Checked at {time}', + error: 'Could not check reset credits', + ineligible: 'This account cannot use resets right now', + cooldown: 'Cooldown until {time}', + expiresAt: 'Expires {time}', + expiresAtFull: 'Reset credit expires at: {time}', + clears: 'Clears windows: {windows}', + notUsableNow: 'Not usable now', + requiresLimit: 'Usable only after hitting a limit' + }, + autoResetCredit: { title: 'Automatically use reset credits', hint: 'Uses the earliest-expiring available credit only when actual usage reaches a threshold. Off by default; the account remains paused if no credit is available or reset fails.', threshold5h: '5h auto-reset threshold (%)', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 2509f92f210b..33db03d886df 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -50,6 +50,7 @@ export default { accountCost: 'Cost', noDataAvailable: 'No data available', recentUsage: 'Recent Usage', + actualSpending: 'Actual spending ($)', viewModelDistribution: 'Model Distribution', viewSpendingRanking: 'User Spending Ranking', spendingRankingTitle: 'User Spending Ranking', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 768fff7fd72c..e77b8446906f 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -84,6 +84,8 @@ export default { enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.', cyberSessionBlock: 'Cyber session auto-block', cyberSessionBlockHint: 'When enabled, sessions hit by upstream cyber_policy are blocked locally for the TTL and no longer forwarded. Only the offending session is blocked; other sessions on the same key are unaffected.', + riskControlUserAllowlist: 'Risk control allowlist', + riskControlUserAllowlistHint: 'Enter any email keyword to search for matching users. Allowlisted users will not trigger account bans or local blocking, but upstream restrictions still apply. This feature is typically used for trusted downstream relays.', cyberSessionBlockTTL: 'Block TTL (seconds)', refusalRecovery: { title: 'OpenAI Refusal Recovery', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index a50d5aac275f..bc438c6abf68 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -1183,6 +1183,7 @@ export default {accounts: { enterCustomModelName: '输入自定义模型名称', addModel: '填入', modelExists: '该模型已存在', + modelMappingConflict: '该模型已配置映射 {from} → {to},请在模型映射中修改或删除后再添加白名单模型', modelCount: '{count} 个模型', poolMode: '池模式', poolModeHint: '上游为账号池时启用,错误不标记本地账号状态', @@ -1276,7 +1277,21 @@ export default {accounts: { autoPause5hDisabled: '禁用 5h 自动暂停', autoPause7dDisabled: '禁用 7d 自动暂停', autoPauseDisabledHint: '开启后该账号永不进入自动暂停(即使全局默认阈值已配置)。', - autoResetCredit: { + claudeResetCredits: { + count: '次数', + countTooltipLoad: '点击查询 Claude 剩余重置次数(只读,不会消耗)', + countTooltipRefresh: '点击刷新 Claude 剩余重置次数(只读,不会消耗)', + fetched: '查询时间:{time}', + error: '无法查询重置次数', + ineligible: '此账号当前不可使用重置', + cooldown: '冷却至 {time}', + expiresAt: '到期 {time}', + expiresAtFull: '重置次数到期时间:{time}', + clears: '可清除窗口:{windows}', + notUsableNow: '暂不可用', + requiresLimit: '需达到限额后才能使用' + }, + autoResetCredit: { title: '自动使用重置卡', hint: '仅在实际用量达到阈值时使用最早到期的可用卡;默认关闭。无卡或失败时账号保持暂停。', threshold5h: '5h 自动用卡阈值(%)', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index d7a45758a22a..573cfe80ea56 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -50,6 +50,7 @@ export default { tokens: 'Token', cache: '缓存', recentUsage: '最近使用', + actualSpending: '实际消费 ($)', viewModelDistribution: '模型分布', viewSpendingRanking: '用户消费榜', spendingRankingTitle: '用户消费榜', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index b5ea649c9d09..7259743908e9 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -84,6 +84,8 @@ export default { enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。', cyberSessionBlock: 'cyber 会话自动屏蔽', cyberSessionBlockHint: '开启后,被上游网络安全策略(cyber_policy)拦截的会话将在 TTL 内被本地屏蔽,不再发往上游。仅屏蔽该会话,不影响同 Key 其他会话。', + riskControlUserAllowlist: '风控白名单', + riskControlUserAllowlistHint: '输入任意邮箱关键词进行模糊搜索。 白名单中的用户不会触发封号或本地屏蔽,但仍然无法突破上游拦截。该功能通常用于可信的下游中转站。', cyberSessionBlockTTL: '屏蔽时长(秒)', refusalRecovery: { title: 'OpenAI 拒答恢复', diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue index d9a00281150e..60668568ab92 100644 --- a/frontend/src/views/admin/DashboardView.vue +++ b/frontend/src/views/admin/DashboardView.vue @@ -318,9 +318,16 @@
-

- {{ t('admin.dashboard.recentUsage') }} (Top 12) -

+
+

{{ t('admin.dashboard.recentUsage') }} (Top 12)

+
+ +
+
@@ -402,6 +409,7 @@ const rankingError = ref(false) const trendData = ref([]) const modelStats = ref([]) const userTrend = ref([]) +const userTrendMetric = ref<'tokens' | 'actual_cost'>('tokens') const rankingItems = ref([]) const rankingTotalActualCost = ref(0) const rankingTotalRequests = ref(0) @@ -475,7 +483,7 @@ const lineOptions = computed(() => ({ }, callbacks: { label: (context: any) => { - return `${context.dataset.label}: ${formatTokens(context.raw)}` + return `${context.dataset.label}: ${formatUserTrendValue(Number(context.raw))}` } } } @@ -501,7 +509,7 @@ const lineOptions = computed(() => ({ font: { size: 10 }, - callback: (value: string | number) => formatTokens(Number(value)) + callback: (value: string | number) => formatUserTrendValue(Number(value)) } } } @@ -535,7 +543,7 @@ const userTrendChartData = computed(() => { if (!userGroups.has(key)) { userGroups.set(key, { name: getDisplayName(point), data: new Map() }) } - userGroups.get(key)!.data.set(point.date, point.tokens) + userGroups.get(key)!.data.set(point.date, userTrendMetric.value === 'tokens' ? point.tokens : point.actual_cost) }) const sortedDates = Array.from(allDates).sort() @@ -558,6 +566,9 @@ const userTrendChartData = computed(() => { }) // Format helpers +const formatUserTrendValue = (value: number): string => + userTrendMetric.value === 'tokens' ? formatTokens(value) : `$${formatCost(value)}` + const formatTokens = (value: number | undefined): string => { if (value === undefined || value === null) return '0' if (value >= 1_000_000_000) { @@ -666,6 +677,13 @@ const loadDashboardSnapshot = async (includeStats: boolean) => { } } +const setUserTrendMetric = (metric: 'tokens' | 'actual_cost') => { + if (userTrendMetric.value === metric) return + userTrendMetric.value = metric + userTrend.value = [] + loadUsersTrend() +} + const loadUsersTrend = async () => { const currentSeq = ++usersTrendLoadSeq userTrendLoading.value = true @@ -674,7 +692,8 @@ const loadUsersTrend = async () => { start_date: startDate.value, end_date: endDate.value, granularity: granularity.value, - limit: 12 + limit: 12, + metric: userTrendMetric.value }) if (currentSeq !== usersTrendLoadSeq) return userTrend.value = response.trend || [] diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 5ec855a70eb9..3eeaf03119bf 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -7511,6 +7511,18 @@
+
+ + +

+ {{ t('admin.settings.features.riskControl.riskControlUserAllowlistHint') }} +

+
+