From d1ba57977d2dfec66d06438a9e8863ae72935c6c Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:23:00 +0800 Subject: [PATCH 01/70] feat: add native TypeSafe Jev System One support --- README.md | 23 ++ README_CN.md | 23 ++ backend/ent/schema/user_platform_quota.go | 2 +- backend/internal/domain/constants.go | 1 + .../internal/handler/admin/channel_handler.go | 1 + .../handler/admin/channel_handler_test.go | 2 +- .../internal/handler/admin/group_handler.go | 6 +- .../admin/group_handler_platform_test.go | 5 +- backend/internal/handler/endpoint.go | 6 + backend/internal/handler/endpoint_test.go | 2 + backend/internal/handler/gateway_handler.go | 11 +- .../handler/gateway_models_retrieve_test.go | 2 +- .../internal/handler/gateway_models_test.go | 4 + backend/internal/handler/gateway_systemone.go | 212 ++++++++++++++++++ .../handler/gateway_systemone_test.go | 43 ++++ backend/internal/pkg/typesafe/client.go | 51 ++++- backend/internal/pkg/typesafe/systemone.go | 133 +++++++++++ .../internal/pkg/typesafe/systemone_test.go | 59 +++++ backend/internal/server/api_contract_test.go | 4 +- backend/internal/server/routes/gateway.go | 1 + .../routes/gateway_model_allowlist_test.go | 1 + .../prompt_audit_route_coverage_test.go | 1 + backend/internal/service/account.go | 22 ++ backend/internal/service/account_service.go | 7 + backend/internal/service/admin_account.go | 6 + backend/internal/service/admin_group.go | 2 +- backend/internal/service/billing_service.go | 9 + .../internal/service/billing_service_test.go | 14 ++ backend/internal/service/channel_service.go | 2 +- .../internal/service/channel_service_test.go | 2 +- .../internal/service/composite_platform.go | 6 +- .../service/composite_platform_test.go | 6 +- .../internal/service/content_moderation.go | 1 + .../service/content_moderation_input.go | 16 ++ .../content_moderation_systemone_test.go | 25 +++ backend/internal/service/domain_constants.go | 2 + backend/internal/service/gateway_systemone.go | 110 +++++++++ .../service/gateway_systemone_test.go | 190 ++++++++++++++++ .../service/scheduler_snapshot_service.go | 6 +- frontend/src/api/admin/settings.ts | 4 +- frontend/src/api/admin/users.ts | 2 +- .../components/account/CreateAccountModal.vue | 32 +++ .../components/account/EditAccountModal.vue | 3 + .../admin/user/UserPlatformQuotaModal.vue | 2 +- .../__tests__/UserPlatformQuotaModal.spec.ts | 15 +- frontend/src/components/keys/UseKeyModal.vue | 53 +++++ .../components/user/UserPlatformQuotaCell.vue | 2 +- .../__tests__/UserPlatformQuotaCell.spec.ts | 4 +- .../user/dashboard/UserDashboardStats.vue | 3 +- .../__tests__/UserDashboardStats.spec.ts | 8 + frontend/src/composables/useModelWhitelist.ts | 1 + .../src/constants/__tests__/platforms.spec.ts | 3 +- frontend/src/constants/platforms.ts | 3 +- .../src/i18n/locales/en/admin/accounts.ts | 1 + .../src/i18n/locales/en/admin/overview.ts | 1 + frontend/src/i18n/locales/en/dashboard.ts | 5 + .../src/i18n/locales/zh/admin/accounts.ts | 1 + .../src/i18n/locales/zh/admin/overview.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 5 + frontend/src/types/index.ts | 4 +- frontend/src/utils/keyGroupProviders.ts | 3 +- frontend/src/utils/platformColors.ts | 16 ++ frontend/src/views/admin/ChannelsView.vue | 4 +- frontend/src/views/admin/SettingsView.vue | 4 +- .../admin/__tests__/SettingsView.spec.ts | 10 +- .../src/views/user/__tests__/KeysView.spec.ts | 8 +- 66 files changed, 1157 insertions(+), 60 deletions(-) create mode 100644 backend/internal/handler/gateway_systemone.go create mode 100644 backend/internal/handler/gateway_systemone_test.go create mode 100644 backend/internal/pkg/typesafe/systemone.go create mode 100644 backend/internal/pkg/typesafe/systemone_test.go create mode 100644 backend/internal/service/content_moderation_systemone_test.go create mode 100644 backend/internal/service/gateway_systemone.go create mode 100644 backend/internal/service/gateway_systemone_test.go diff --git a/README.md b/README.md index 3d6fd891be00..ec11a1d2df1a 100644 --- a/README.md +++ b/README.md @@ -824,6 +824,29 @@ Administrators can override automatic media eligibility through the account crea --- +## TypeSafe / Jev Support + +Sub2API supports TypeSafe API-key accounts through Jev's native, non-streaming System One protocol. + +- Platform: `typesafe`; account type: API Key +- Default upstream: `https://api.typesafe.ai` +- Public endpoint: `POST /v1/systemone` +- Model: `jev-latest`, also returned by `/v1/models` for TypeSafe groups +- Questions: `noul`, `choice`, and `score` + +Requests and successful responses retain the native System One JSON structure. This endpoint is not compatible with Chat Completions, Responses, Anthropic Messages, or streaming clients. + +```bash +curl https://your-sub2api.example.com/v1/systemone \ + -H 'Authorization: Bearer sk-your-sub2api-key' \ + -H 'Content-Type: application/json' \ + --data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}' +``` + +The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, rate-limit, overload, server, and network failures use the existing account cooldown and failover path; request errors (`400` and `422`) are returned without retrying another account. + +--- + ## Antigravity Support Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models. diff --git a/README_CN.md b/README_CN.md index 9a4907c46199..6919c7008230 100644 --- a/README_CN.md +++ b/README_CN.md @@ -740,6 +740,29 @@ OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_gener --- +## TypeSafe / Jev 使用说明 + +Sub2API 支持使用 TypeSafe API Key 账户,通过 Jev 原生、非流式的 System One 协议调用模型。 + +- 平台:`typesafe`;账号类型:API Key +- 默认上游:`https://api.typesafe.ai` +- 对外端点:`POST /v1/systemone` +- 模型:`jev-latest`,TypeSafe 分组的 `/v1/models` 也会返回该模型 +- 问题类型:`noul`、`choice`、`score` + +请求和成功响应保持 System One 原生 JSON 结构。该端点不兼容 Chat Completions、Responses、Anthropic Messages 或流式客户端。 + +```bash +curl https://your-sub2api.example.com/v1/systemone \ + -H 'Authorization: Bearer sk-your-sub2api-key' \ + -H 'Content-Type: application/json' \ + --data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}' +``` + +`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、限流、过载、服务端和网络错误沿用现有账号冷却与故障切换链路;请求错误(`400`、`422`)不会切换账号重试。 + +--- + ## Antigravity 使用说明 Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。 diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index 20c0b3aa14ad..663be0a6ecbc 100644 --- a/backend/ent/schema/user_platform_quota.go +++ b/backend/ent/schema/user_platform_quota.go @@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field { // 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。 switch s { case "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek", "minimax", "opencode_go": + "kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe": return nil default: return fmt.Errorf("platform %q is not allowed", s) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index e82c1df1dc66..e1e524c99fd3 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -28,6 +28,7 @@ const ( PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) PlatformDeepseek = "deepseek" // DeepSeek PlatformMiniMax = "minimax" // MiniMax (M 系列) + PlatformTypeSafe = "typesafe" // TypeSafe AI System One (Jev) // PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。 // 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。 PlatformOpenCodeGo = "opencode_go" diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 3b65ac97a883..99765c97dfe6 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -645,6 +645,7 @@ var platformToLiteLLMProvider = map[string]string{ service.PlatformDeepseek: "deepseek", service.PlatformMiniMax: "minimax", service.PlatformOpenCodeGo: "opencode-go", + service.PlatformTypeSafe: "typesafe", } // SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表 diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index d735c5470754..43e475835a33 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -549,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) { svc := service.NewPricingService(nil, nil) router := setupSyncPricingModelsRouter(svc) - for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} { + for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax", "typesafe"} { req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 4af0492550d7..774a00f88273 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go typesafe composite"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` @@ -258,7 +258,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go typesafe composite"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` @@ -332,7 +332,7 @@ type UpdateGroupRequest struct { type CompositeRouteRequest struct { PublicModel string `json:"public_model" binding:"required"` MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"` - TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"` + TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go typesafe"` UpstreamModel string `json:"upstream_model"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` Priority int `json:"priority"` diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index 0c820868b482..d5d48845e1d3 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -28,6 +28,7 @@ func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) { allowed := []string{ "anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite", + "typesafe", } for _, platform := range allowed { t.Run("create_"+platform, func(t *testing.T) { @@ -71,8 +72,8 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { } } -func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) { - for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} { +func TestCompositeRouteTargetPlatform_AllowsConcreteProviders(t *testing.T) { + for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe"} { var req CompositeRouteRequest body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform) require.NoError(t, bindGroupPlatformJSON(t, &req, body)) diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index 2115c2d7c010..82d8279e9046 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -16,6 +16,7 @@ import ( const ( EndpointMessages = "/v1/messages" + EndpointSystemOne = "/v1/systemone" EndpointChatCompletions = "/v1/chat/completions" EndpointEmbeddings = "/v1/embeddings" EndpointAlphaSearch = "/v1/alpha/search" @@ -94,6 +95,8 @@ func NormalizeInboundEndpoint(path string) string { return EndpointChatCompletions case strings.Contains(path, EndpointMessages): return EndpointMessages + case strings.Contains(path, EndpointSystemOne): + return EndpointSystemOne case strings.Contains(path, EndpointImagesGenerations) || strings.Contains(path, "/images/generations"): return EndpointImagesGenerations case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"): @@ -223,6 +226,9 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { case service.PlatformAnthropic: return EndpointMessages + case service.PlatformTypeSafe: + return EndpointSystemOne + case service.PlatformGemini: return EndpointGeminiModels diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index aaa7e9f8fd9d..6941e5637411 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -23,6 +23,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { }{ // Direct canonical paths. {"/v1/messages", EndpointMessages}, + {"/v1/systemone", EndpointSystemOne}, {"/v1/chat/completions", EndpointChatCompletions}, {"/v1/embeddings", EndpointEmbeddings}, {"/v1/alpha/search", EndpointAlphaSearch}, @@ -97,6 +98,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) { }{ // Anthropic. {"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages}, + {"typesafe system one", EndpointSystemOne, "/v1/systemone", service.PlatformTypeSafe, EndpointSystemOne}, // Gemini. {"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels}, diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index c14143151537..54be03ed1ed8 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -24,6 +24,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/securityaudit" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -1185,6 +1186,10 @@ func (h *GatewayHandler) Models(c *gin.Context) { writeGrokModelsList(c, xai.DefaultModelIDs()) return } + if platform == service.PlatformTypeSafe { + writeModelsList(c, platform, []string{typesafe.JevLatestModel}) + return + } writeModelsListResponse(c, claude.DefaultModels) } @@ -1269,7 +1274,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * seen := make(map[string]struct{}) models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) - for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { + for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 @@ -1453,10 +1458,12 @@ func defaultModelIDsForPlatform(platform string) []string { return xai.DefaultModelIDs() case service.PlatformOpenCodeGo: return service.DefaultOpenCodeGoModelIDs() + case service.PlatformTypeSafe: + return []string{"jev-latest"} case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/gateway_models_retrieve_test.go b/backend/internal/handler/gateway_models_retrieve_test.go index 26a5b6107ac8..fc590a35dfd2 100644 --- a/backend/internal/handler/gateway_models_retrieve_test.go +++ b/backend/internal/handler/gateway_models_retrieve_test.go @@ -27,7 +27,7 @@ func requestModelForTest(h *GatewayHandler, group *service.Group, modelID, etag func TestRetrieveModelMatchesVisibleCatalogue(t *testing.T) { gin.SetMode(gin.TestMode) - for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformComposite} { + for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformTypeSafe, service.PlatformComposite} { for _, mapped := range []bool{false, true} { name := platform + "/fallback" if mapped { diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 26c0c72ec436..0f9e57bc5e17 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -919,6 +919,10 @@ func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic)) } +func TestDefaultModelIDsForPlatform_TypeSafeUsesJev(t *testing.T) { + require.Equal(t, []string{"jev-latest"}, defaultModelIDsForPlatform(service.PlatformTypeSafe)) +} + func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) { gin.SetMode(gin.TestMode) const groupID int64 = 130 diff --git a/backend/internal/handler/gateway_systemone.go b/backend/internal/handler/gateway_systemone.go new file mode 100644 index 000000000000..7c2713e79d4f --- /dev/null +++ b/backend/internal/handler/gateway_systemone.go @@ -0,0 +1,212 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// SystemOne proxies TypeSafe's native, non-streaming System One protocol. +func (h *GatewayHandler) SystemOne(c *gin.Context) { + requestStart := time.Now() + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + reqLog := requestLogger(c, "handler.gateway.systemone", + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) + + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + model, err := typesafe.ValidateSystemOneRequest(body) + if err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + ensureCompositeTargetPlatform(c, apiKey, model) + if apiKey.Group.Platform != service.PlatformTypeSafe && + (apiKey.Group.Platform != service.PlatformComposite || !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformTypeSafe)) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "System One is only available for TypeSafe and compatible Composite groups") + return + } + if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolTypeSafeSystemOne, model, body); decision != nil && !decision.AllowNextStage { + h.anthropicSecurityAuditError(c, decision) + return + } + + setOpsRequestContext(c, model, false) + setOpsEndpointContext(c, "", int16(service.RequestTypeSync)) + service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) + pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context()) + c.Request = c.Request.WithContext(pricingCtx) + channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, model) + subscription, _ := middleware2.GetSubscriptionFromContext(c) + + streamStarted := false + userRelease, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, false, &streamStarted) + if err != nil { + h.handleConcurrencyError(c, err, "user", false) + return + } + if userRelease != nil { + defer userRelease() + } + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + failedAccountIDs := make(map[int64]struct{}) + var lastFailoverErr *service.UpstreamFailoverError + maxSwitches := h.maxAccountSwitches + if maxSwitches <= 0 { + maxSwitches = 3 + } + for switchCount := 0; ; { + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", model, failedAccountIDs, "", subject.UserID) + if err != nil || selection == nil || selection.Account == nil { + if failoverClientGone(c) { + return + } + if lastFailoverErr != nil { + h.handleFailoverExhausted(c, lastFailoverErr, service.PlatformTypeSafe, false) + return + } + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, model, model, service.PlatformTypeSafe) + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + account := selection.Account + setOpsSelectedAccount(c, account.ID, account.Platform) + service.SetOpsUpstreamModel(c, model) + + accountRelease, err := h.acquireSystemOneAccountSlot(c, selection) + if err != nil { + h.handleConcurrencyError(c, err, "account", false) + return + } + forwardStart := time.Now() + result, forwardErr := func() (*service.SystemOneForwardResult, error) { + if accountRelease != nil { + defer accountRelease() + } + return h.gatewayService.ForwardSystemOne(c.Request.Context(), c, account, body) + }() + service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds()) + + if forwardErr != nil { + var failoverErr *service.UpstreamFailoverError + if errors.As(forwardErr, &failoverErr) { + if failoverClientGone(c) { + return + } + failedAccountIDs[account.ID] = struct{}{} + lastFailoverErr = failoverErr + if switchCount >= maxSwitches { + h.handleFailoverExhausted(c, failoverErr, service.PlatformTypeSafe, false) + return + } + switchCount++ + reqLog.Warn("systemone.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", switchCount), + ) + continue + } + var upstreamErr *service.SystemOneUpstreamError + if errors.As(forwardErr, &upstreamErr) { + status := upstreamErr.StatusCode + if status != http.StatusBadRequest && status != http.StatusUnprocessableEntity { + status = http.StatusBadGateway + } + h.errorResponse(c, status, "upstream_error", "TypeSafe rejected the request") + return + } + reqLog.Warn("systemone.forward_failed", zap.Int64("account_id", account.ID), zap.Error(forwardErr)) + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe upstream request failed") + return + } + + c.Data(result.StatusCode, result.ContentType, result.Body) + h.recordSystemOneUsage(c, apiKey, account, subscription, channelMapping, model, body, result, subject.UserID, pricingAt) + return + } +} + +func (h *GatewayHandler) acquireSystemOneAccountSlot(c *gin.Context, selection *service.AccountSelectionResult) (func(), error) { + if selection.Acquired { + return selection.ReleaseFunc, nil + } + if selection.WaitPlan == nil { + return nil, errors.New("account concurrency unavailable") + } + return h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(c, selection.Account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, new(bool)) +} + +func (h *GatewayHandler) recordSystemOneUsage(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, mapping service.ChannelMappingResult, model string, body []byte, result *service.SystemOneForwardResult, userID int64, pricingAt time.Time) { + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + sessionID := service.ExtractClientSessionID(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + + h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: &result.ForwardResult, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + PricingAt: pricingAt, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + SessionID: sessionID, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + ChannelUsageFields: clientRequestedUsageFields(c, mapping, model, result.UpstreamModel), + }); err != nil { + logger.L().With( + zap.String("component", "handler.gateway.systemone"), + zap.Int64("user_id", userID), + zap.Int64("api_key_id", apiKey.ID), + zap.Int64("account_id", account.ID), + ).Error("systemone.record_usage_failed", zap.Error(err)) + } + }) +} diff --git a/backend/internal/handler/gateway_systemone_test.go b/backend/internal/handler/gateway_systemone_test.go new file mode 100644 index 000000000000..bf60bf499081 --- /dev/null +++ b/backend/internal/handler/gateway_systemone_test.go @@ -0,0 +1,43 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const validSystemOneHandlerBody = `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":"Evaluate"}}}` + +func newSystemOneHandlerContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/systemone", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, recorder +} + +func TestSystemOneRequiresAuthentication(t *testing.T) { + c, recorder := newSystemOneHandlerContext(validSystemOneHandlerBody) + (&GatewayHandler{}).SystemOne(c) + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.Contains(t, recorder.Body.String(), "authentication_error") +} + +func TestSystemOneRejectsNonTypeSafeGroupBeforeScheduling(t *testing.T) { + c, recorder := newSystemOneHandlerContext(validSystemOneHandlerBody) + groupID := int64(3) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}}) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1}) + + (&GatewayHandler{cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}}}).SystemOne(c) + require.Equal(t, http.StatusNotFound, recorder.Code) + require.Contains(t, recorder.Body.String(), "only available for TypeSafe") +} diff --git a/backend/internal/pkg/typesafe/client.go b/backend/internal/pkg/typesafe/client.go index 35783249e0ef..3c66d8c3143d 100644 --- a/backend/internal/pkg/typesafe/client.go +++ b/backend/internal/pkg/typesafe/client.go @@ -13,6 +13,12 @@ import ( "strings" ) +const ( + DefaultBaseURL = "https://api.typesafe.ai" + SystemOnePath = "/v1/systemone" + JevLatestModel = "jev-latest" +) + type Question struct { Type string `json:"type"` Instructions string `json:"instructions"` @@ -35,22 +41,51 @@ type Usage struct { OutputTokens int `json:"output_tokens"` } -// Evaluate performs one attempt. The caller owns timeouts, retries and key rotation. -func Evaluate(ctx context.Context, client *http.Client, baseURL, key string, input Request) (*Result, int, error) { - endpoint, err := url.JoinPath(strings.TrimRight(baseURL, "/"), "/v1/systemone") +type SystemOneResponse struct { + Body []byte + Model string + Usage Usage +} + +func NewSystemOneRequest(ctx context.Context, baseURL, key string, body []byte) (*http.Request, error) { + endpoint, err := url.JoinPath(strings.TrimRight(baseURL, "/"), SystemOnePath) if err != nil { - return nil, 0, errors.New("typesafe invalid endpoint") + return nil, errors.New("typesafe invalid endpoint") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, errors.New("typesafe invalid request") + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) { + body, err := io.ReadAll(io.LimitReader(r, 4<<20)) + if err != nil || !json.Valid(body) { + return nil, errors.New("typesafe invalid response") + } + var envelope struct { + Model string `json:"model"` + Usage Usage `json:"usage"` } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, errors.New("typesafe invalid response") + } + return &SystemOneResponse{Body: body, Model: envelope.Model, Usage: envelope.Usage}, nil +} + +// Evaluate performs one attempt. The caller owns timeouts, retries and key rotation. +func Evaluate(ctx context.Context, client *http.Client, baseURL, key string, input Request) (*Result, int, error) { body, err := json.Marshal(input) if err != nil { return nil, 0, errors.New("typesafe invalid request") } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + req, err := NewSystemOneRequest(ctx, baseURL, key, body) if err != nil { - return nil, 0, errors.New("typesafe invalid request") + return nil, 0, err } - req.Header.Set("Authorization", "Bearer "+key) - req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { if ctx.Err() != nil { diff --git a/backend/internal/pkg/typesafe/systemone.go b/backend/internal/pkg/typesafe/systemone.go new file mode 100644 index 000000000000..0222289c138c --- /dev/null +++ b/backend/internal/pkg/typesafe/systemone.go @@ -0,0 +1,133 @@ +package typesafe + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" +) + +var ErrStreamingUnsupported = errors.New("typesafe system one does not support streaming") + +type systemOneEnvelope struct { + Model json.RawMessage `json:"model"` + State json.RawMessage `json:"state"` + Questions map[string]json.RawMessage `json:"questions"` + Stream json.RawMessage `json:"stream"` +} + +func ValidateSystemOneRequest(body []byte) (string, error) { + var envelope systemOneEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return "", errors.New("invalid JSON request") + } + + model, err := requiredString(envelope.Model, "model") + if err != nil { + return "", err + } + if model != JevLatestModel { + return "", fmt.Errorf("model must be %s", JevLatestModel) + } + if err := validateStringObjectOrArray(envelope.State, "state"); err != nil { + return "", err + } + if len(envelope.Questions) == 0 { + return "", errors.New("questions must be a non-empty object") + } + if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" { + var stream bool + if err := json.Unmarshal(envelope.Stream, &stream); err != nil { + return "", errors.New("stream must be a boolean") + } + if stream { + return "", ErrStreamingUnsupported + } + } + for id, raw := range envelope.Questions { + if err := validateQuestion(id, raw); err != nil { + return "", err + } + } + return model, nil +} + +func validateQuestion(id string, raw json.RawMessage) error { + raw = bytes.TrimSpace(raw) + var question struct { + Type json.RawMessage `json:"type"` + Instructions json.RawMessage `json:"instructions"` + Criteria json.RawMessage `json:"criteria"` + } + if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &question) != nil { + return fmt.Errorf("question %q must be an object", id) + } + typ, err := requiredString(question.Type, "question type") + if err != nil { + return fmt.Errorf("question %q: %w", id, err) + } + if err := validateStringObjectOrArray(question.Instructions, "instructions"); err != nil { + return fmt.Errorf("question %q: %w", id, err) + } + + switch typ { + case "noul": + criteria := bytes.TrimSpace(question.Criteria) + if len(criteria) > 0 && criteria[0] != '{' { + return fmt.Errorf("question %q: noul criteria must be an object", id) + } + case "choice": + var criteria map[string]json.RawMessage + if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) == 0 { + return fmt.Errorf("question %q: choice criteria must be a non-empty object", id) + } + for _, value := range criteria { + if string(value) != "null" && !rawString(value) { + return fmt.Errorf("question %q: choice criteria values must be strings or null", id) + } + } + case "score": + var criteria []json.RawMessage + if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) < 2 { + return fmt.Errorf("question %q: score criteria must contain at least two strings", id) + } + for _, value := range criteria { + if !rawString(value) { + return fmt.Errorf("question %q: score criteria must contain only strings", id) + } + } + default: + return fmt.Errorf("question %q: unsupported type %q", id, typ) + } + return nil +} + +func requiredString(raw json.RawMessage, name string) (string, error) { + var value string + if len(raw) == 0 || json.Unmarshal(raw, &value) != nil || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%s must be a non-empty string", name) + } + return value, nil +} + +func validateStringObjectOrArray(raw json.RawMessage, name string) error { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return fmt.Errorf("%s is required", name) + } + switch raw[0] { + case '{', '[': + return nil + case '"': + if rawString(raw) { + return nil + } + } + return fmt.Errorf("%s must be a string, object, or array", name) +} + +func rawString(raw json.RawMessage) bool { + var value string + return json.Unmarshal(raw, &value) == nil +} diff --git a/backend/internal/pkg/typesafe/systemone_test.go b/backend/internal/pkg/typesafe/systemone_test.go new file mode 100644 index 000000000000..a5056bd358a5 --- /dev/null +++ b/backend/internal/pkg/typesafe/systemone_test.go @@ -0,0 +1,59 @@ +package typesafe + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateSystemOneRequestValidQuestionTypes(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"noul string state", `{"model":"jev-latest","state":"sample","questions":{"safety":{"type":"noul","instructions":"Evaluate safety","criteria":{"safe":"No harm"}}}}`}, + {"choice object state", `{"model":"jev-latest","state":{"text":"sample"},"questions":{"label":{"type":"choice","instructions":{"task":"Classify"},"criteria":{"safe":"Allowed","unsafe":null}}},"stream":false}`}, + {"score array state", `{"model":"jev-latest","state":["sample"],"questions":{"quality":{"type":"score","instructions":["Rate quality"],"criteria":["poor","good"]}}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + model, err := ValidateSystemOneRequest([]byte(tc.body)) + require.NoError(t, err) + require.Equal(t, JevLatestModel, model) + }) + } +} + +func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { + for _, tc := range []struct { + name string + body string + want string + }{ + {"invalid json", `{`, "invalid JSON"}, + {"missing model", `{"state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`, "model"}, + {"illegal model", `{"model":"jev-old","state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`, "jev-latest"}, + {"model with whitespace", `{"model":" jev-latest ","state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`, "jev-latest"}, + {"missing state", `{"model":"jev-latest","questions":{"q":{"type":"noul","instructions":"x"}}}`, "state"}, + {"scalar state", `{"model":"jev-latest","state":42,"questions":{"q":{"type":"noul","instructions":"x"}}}`, "state"}, + {"empty questions", `{"model":"jev-latest","state":"x","questions":{}}`, "questions"}, + {"unknown question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"boolean","instructions":"x"}}}`, "unsupported type"}, + {"question type with whitespace", `{"model":"jev-latest","state":"x","questions":{"q":{"type":" noul ","instructions":"x"}}}`, "unsupported type"}, + {"noul criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":[]}}}`, "noul criteria"}, + {"noul criteria null", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":null}}}`, "noul criteria"}, + {"empty choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{}}}}`, "choice criteria"}, + {"choice numeric value", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{"one":1}}}}`, "choice criteria"}, + {"short score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","instructions":"x","criteria":["only"]}}}`, "score criteria"}, + {"non-string score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","instructions":"x","criteria":["low",2]}}}`, "score criteria"}, + {"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateSystemOneRequest([]byte(tc.body)) + require.Error(t, err) + require.Contains(t, err.Error(), tc.want) + if tc.name == "stream true" { + require.True(t, errors.Is(err, ErrStreamingUnsupported)) + } + }) + } +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 986a691a5a97..96598de2c59e 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1182,7 +1182,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 86f605993a13..18346eb219d4 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -202,6 +202,7 @@ func RegisterGatewayRoutes( } h.Gateway.Messages(c) }) + gateway.POST("/systemone", h.Gateway.SystemOne) // /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates // locally, and Anthropic-compatible platforms retain their existing path. gateway.POST("/messages/count_tokens", countTokensHandler) diff --git a/backend/internal/server/routes/gateway_model_allowlist_test.go b/backend/internal/server/routes/gateway_model_allowlist_test.go index 85c509934279..1115bd0d8d22 100644 --- a/backend/internal/server/routes/gateway_model_allowlist_test.go +++ b/backend/internal/server/routes/gateway_model_allowlist_test.go @@ -152,6 +152,7 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) { {http.MethodGet, "/realtime?model=gpt-4.1", ""}, {http.MethodPost, "/v1/responses", `{"model":"gpt-4.1"}`}, {http.MethodPost, "/v1/messages", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/v1/systemone", `{"model":"gpt-4.1","state":"x","questions":{"q":{"type":"noul","instructions":"x"}}}`}, {http.MethodPost, "/v1/messages/count_tokens", `{"model":"gpt-4.1","messages":[]}`}, {http.MethodPost, "/v1/chat/completions", `{"model":"gpt-4.1"}`}, {http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`}, diff --git a/backend/internal/server/routes/prompt_audit_route_coverage_test.go b/backend/internal/server/routes/prompt_audit_route_coverage_test.go index af4272cb897b..2d82f7c1daec 100644 --- a/backend/internal/server/routes/prompt_audit_route_coverage_test.go +++ b/backend/internal/server/routes/prompt_audit_route_coverage_test.go @@ -29,6 +29,7 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) { audited := map[string][]string{ "/messages": {"gateway_handler.go", "openai_gateway_handler.go"}, + "/systemone": {"gateway_systemone.go"}, "/responses": {"gateway_handler_responses.go", "openai_gateway_handler.go"}, "/responses/*subpath": {"gateway_handler_responses.go", "openai_gateway_handler.go"}, "/chat/completions": {"gateway_handler_chat_completions.go", "openai_chat_completions.go"}, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 278d359c6578..81ee55a6fecb 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -268,6 +269,10 @@ func (a *Account) IsGrok() bool { return a.Platform == PlatformGrok } +func (a *Account) IsTypeSafe() bool { + return a != nil && a.Platform == PlatformTypeSafe +} + func (a *Account) IsGrokOAuth() bool { return a.IsGrok() && a.Type == AccountTypeOAuth } @@ -1001,6 +1006,23 @@ func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string { return baseURL } +func (a *Account) GetTypeSafeBaseURL() string { + if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey { + return "" + } + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { + return strings.TrimRight(baseURL, "/") + } + return typesafe.DefaultBaseURL +} + +func (a *Account) GetTypeSafeAPIKey() string { + if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey { + return "" + } + return strings.TrimSpace(a.GetCredential("api_key")) +} + func (a *Account) GetExtraString(key string) string { if a.Extra == nil { return "" diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 205907abc1cd..bbf668eaffff 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "time" @@ -223,6 +224,9 @@ func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository) // Create 创建账号 func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) { + if req.Platform == PlatformTypeSafe && req.Type != AccountTypeAPIKey { + return nil, errors.New("typesafe accounts only support apikey credentials") + } // 验证分组是否存在(如果指定了分组) if len(req.GroupIDs) > 0 { if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil { @@ -515,6 +519,9 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error { case PlatformGrok: // Grok OAuth credentials are validated via token exchange/refresh and request-path probes. return nil + case PlatformTypeSafe: + // TypeSafe credentials are API keys; inference failures drive health and cooldown state. + return nil case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: // 国产 OpenAI 兼容供应商与 OpenCode:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。 return nil diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 5d778b75ce83..44f5dc01a504 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -411,6 +411,9 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat // Grok media eligibility helpers live in account_grok_media_eligibility.go. func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) { + if input.Platform == PlatformTypeSafe && input.Type != AccountTypeAPIKey { + return nil, errors.New("typesafe accounts only support apikey credentials") + } // Probe/session state is system-managed. New accounts always start with automatic refresh disabled. delete(accountExtra, UpstreamBillingProbeEnabledExtraKey) delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey) @@ -574,6 +577,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + if account.Platform == PlatformTypeSafe && input.Type != "" && input.Type != AccountTypeAPIKey { + return nil, errors.New("typesafe accounts only support apikey credentials") + } var normalizedExtra map[string]any if input.Extra != nil { normalizedExtra, err = normalizeOpenAILongContextBillingUpdateExtra(account, input) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index c779f5b7f630..2dcdfb84a58c 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -316,7 +316,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 55e4f08d9bec..544ec6a92b0b 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -637,6 +637,12 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } + // TypeSafe Jev bills input tokens only: $0.042 per million tokens. + s.fallbackPrices["jev-latest"] = &ModelPricing{ + InputPricePerToken: 0.042 / 1_000_000, + OutputPricePerToken: 0, + } + // ---- 智谱 GLM(Z.AI)---- // Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens) // 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。 @@ -920,6 +926,9 @@ func (s *BillingService) initFallbackPricing() { // getFallbackPricing 根据模型系列获取回退价格 func (s *BillingService) getFallbackPricing(model string) *ModelPricing { modelLower := strings.ToLower(model) + if modelLower == "jev-latest" { + return s.fallbackPrices["jev-latest"] + } // 按模型系列匹配 if isClaudeFable51Model(modelLower) { diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 0a68e07ad73d..89598fb10b38 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -129,6 +129,20 @@ func TestGetModelPricing_CaseInsensitive(t *testing.T) { require.Equal(t, p1.InputPricePerToken, p2.InputPricePerToken) } +func TestGetModelPricing_JevLatestInputOnlyAndChannelOverride(t *testing.T) { + svc := newTestBillingService() + pricing, err := svc.GetModelPricing("jev-latest") + require.NoError(t, err) + require.InDelta(t, 0.042/1_000_000, pricing.InputPricePerToken, 1e-15) + require.Zero(t, pricing.OutputPricePerToken) + + input, output := 0.25/1_000_000, 0.5/1_000_000 + pricing, err = svc.GetModelPricingWithChannel("jev-latest", &ChannelModelPricing{InputPrice: &input, OutputPrice: &output}) + require.NoError(t, err) + require.Equal(t, input, pricing.InputPricePerToken) + require.Equal(t, output, pricing.OutputPricePerToken) +} + // issue #3394: fallback warn 应按模型名去重,每个模型每进程最多打一条, // 避免热路径每请求刷屏 ops_system_logs。 func TestGetModelPricing_FallbackWarnLoggedOncePerModel(t *testing.T) { diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 0d34f4401471..6d63ade99949 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -367,7 +367,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool { // fallback used before a request target has been resolved. func matchingPlatforms(groupPlatform string) []string { if groupPlatform == PlatformComposite { - return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} + return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} } return []string{groupPlatform} } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 9b85aa3e00a4..62ba8e0b9a1b 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -2127,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe}}, } for _, tt := range tests { diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index 6ddb9cfa9a58..c10e335cbebe 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -114,6 +114,8 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformDeepseek, true case "minimax": return PlatformMiniMax, true + case "typesafe", "jev": + return PlatformTypeSafe, true } if rest != "" { normalized = strings.TrimPrefix(rest, "models/") @@ -155,6 +157,8 @@ func DetectModelPlatform(model string) (string, bool) { strings.HasPrefix(normalized, "abab6"), strings.HasPrefix(normalized, "abab7"): return PlatformMiniMax, true + case normalized == "jev-latest" || strings.HasPrefix(normalized, "jev-"): + return PlatformTypeSafe, true default: return "", false } @@ -202,7 +206,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou func isConcreteRequestPlatform(platform string) bool { switch platform { case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe: return true default: return false diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 20505a3985af..4b11366a2947 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -180,6 +180,8 @@ func TestDetectModelPlatform(t *testing.T) { {name: "minimax prefix", model: "minimax/MiniMax-M2.5", platform: PlatformMiniMax, ok: true}, {name: "abab legacy", model: "abab6.5-chat", platform: PlatformMiniMax, ok: true}, {name: "abab7 legacy", model: "abab7-chat-preview", platform: PlatformMiniMax, ok: true}, + {name: "jev", model: "jev-latest", platform: PlatformTypeSafe, ok: true}, + {name: "typesafe prefix", model: "typesafe/jev-latest", platform: PlatformTypeSafe, ok: true}, {name: "abab unrelated namespace", model: "abab-other", ok: false}, {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, @@ -216,13 +218,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe}, platforms, ) } func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} { require.True(t, isConcreteRequestPlatform(platform)) require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform)) } diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index cb000e2a0a25..ee162a023443 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -56,6 +56,7 @@ const ( ContentModerationProtocolOpenAIChat = "openai_chat_completions" ContentModerationProtocolGemini = "gemini" ContentModerationProtocolOpenAIImages = "openai_images" + ContentModerationProtocolTypeSafeSystemOne = "typesafe_systemone" defaultContentModerationBaseURL = "https://api.openai.com" defaultContentModerationModel = "omni-moderation-latest" diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index 013c10c13de2..70b9232c2395 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -46,6 +46,8 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders case ContentModerationProtocolOpenAIImages: collector.addModerationText(&parts, gjson.GetBytes(body, "prompt").String()) collector.collectContentValue(gjson.GetBytes(body, "images"), &parts, &images) + case ContentModerationProtocolTypeSafeSystemOne: + collector.collectSystemOneState(gjson.GetBytes(body, "state"), &parts) default: collector.collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) collector.collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) @@ -61,6 +63,20 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders return out } +func (collector moderationTextCollector) collectSystemOneState(value gjson.Result, parts *[]string) { + switch { + case !value.Exists(): + return + case value.Type == gjson.String: + collector.addModerationText(parts, value.String()) + case value.IsArray(), value.IsObject(): + value.ForEach(func(_, child gjson.Result) bool { + collector.collectSystemOneState(child, parts) + return true + }) + } +} + func (collector moderationTextCollector) collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) { if !messages.IsArray() { return diff --git a/backend/internal/service/content_moderation_systemone_test.go b/backend/internal/service/content_moderation_systemone_test.go new file mode 100644 index 000000000000..993a71fa7ea5 --- /dev/null +++ b/backend/internal/service/content_moderation_systemone_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) { + for _, tc := range []struct { + name string + body string + want string + }{ + {"string", `{"state":"plain text"}`, "plain text"}, + {"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "hello world"}, + {"array", `{"state":["first",{"text":"second"},3]}`, "first second"}, + } { + t.Run(tc.name, func(t *testing.T) { + input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body)) + require.Equal(t, tc.want, input.Text) + require.Empty(t, input.Images) + }) + } +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 883ac9f0d59f..002b2c18b8e7 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -48,6 +48,7 @@ const ( PlatformZhipu = domain.PlatformZhipu PlatformDeepseek = domain.PlatformDeepseek PlatformMiniMax = domain.PlatformMiniMax + PlatformTypeSafe = domain.PlatformTypeSafe PlatformOpenCodeGo = domain.PlatformOpenCodeGo PlatformComposite = domain.PlatformComposite // PlatformKiro is retained for unsupported-platform threshold tests and legacy @@ -135,6 +136,7 @@ var AllowedQuotaPlatforms = []string{ PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe, } // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 diff --git a/backend/internal/service/gateway_systemone.go b/backend/internal/service/gateway_systemone.go new file mode 100644 index 000000000000..f9201f91e8e2 --- /dev/null +++ b/backend/internal/service/gateway_systemone.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" + "github.com/gin-gonic/gin" +) + +type SystemOneForwardResult struct { + ForwardResult + StatusCode int + Body []byte + ContentType string +} + +type SystemOneUpstreamError struct { + StatusCode int +} + +const TypeSafeCredentialRejectedReason GatewayFailureReason = "typesafe_api_key_rejected" + +func (e *SystemOneUpstreamError) Error() string { + return fmt.Sprintf("typesafe upstream rejected request with status %d", e.StatusCode) +} + +func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, account *Account, body []byte) (*SystemOneForwardResult, error) { + started := time.Now() + if account == nil || !account.IsTypeSafe() || account.Type != AccountTypeAPIKey { + return nil, errors.New("invalid typesafe account") + } + key := account.GetTypeSafeAPIKey() + if key == "" { + return nil, errors.New("typesafe api key is missing") + } + baseURL, err := s.validateUpstreamBaseURL(account.GetTypeSafeBaseURL()) + if err != nil { + return nil, err + } + req, err := typesafe.NewSystemOneRequest(ctx, baseURL, key, body) + if err != nil { + return nil, err + } + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, s.handleUpstreamTransportError(ctx, c, account, err, OpsUpstreamErrorEvent{ + Passthrough: true, + UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path, + }) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity { + return nil, &SystemOneUpstreamError{StatusCode: resp.StatusCode} + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == 529 || resp.StatusCode >= 500 { + if s.rateLimitService != nil { + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, nil, typesafe.JevLatestModel) + } + failoverErr := &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseHeaders: resp.Header.Clone(), + ClientStatusCode: http.StatusBadGateway, + ClientMessage: "TypeSafe upstream request failed", + } + if resp.StatusCode == http.StatusUnauthorized { + failoverErr.Stage = GatewayFailureStageAccountAuth + failoverErr.Scope = GatewayFailureScopeAccount + failoverErr.Reason = TypeSafeCredentialRejectedReason + failoverErr.NextAccountAction = NextAccountRetry + } + return nil, failoverErr + } + return nil, &SystemOneUpstreamError{StatusCode: http.StatusBadGateway} + } + + decoded, err := typesafe.DecodeSystemOneResponse(resp.Body) + if err != nil { + return nil, err + } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + return &SystemOneForwardResult{ + ForwardResult: ForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header.Clone(), + Usage: ClaudeUsage{InputTokens: decoded.Usage.InputTokens, OutputTokens: decoded.Usage.OutputTokens}, + Model: typesafe.JevLatestModel, + UpstreamResponseModel: decoded.Model, + Duration: time.Since(started), + }, + StatusCode: resp.StatusCode, + Body: decoded.Body, + ContentType: contentType, + }, nil +} diff --git a/backend/internal/service/gateway_systemone_test.go b/backend/internal/service/gateway_systemone_test.go new file mode 100644 index 000000000000..a69964aa8e7e --- /dev/null +++ b/backend/internal/service/gateway_systemone_test.go @@ -0,0 +1,190 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type systemOneHTTPUpstream struct { + do func(*http.Request) (*http.Response, error) +} + +type systemOnePolicyAccountRepo struct { + AccountRepository + account *Account + rateLimitedCalls int + overloadedCalls int + errorCalls int +} + +func (r *systemOnePolicyAccountRepo) GetByID(context.Context, int64) (*Account, error) { + return r.account, nil +} + +func (r *systemOnePolicyAccountRepo) SetRateLimited(context.Context, int64, time.Time) error { + r.rateLimitedCalls++ + return nil +} + +func (r *systemOnePolicyAccountRepo) SetOverloaded(context.Context, int64, time.Time) error { + r.overloadedCalls++ + return nil +} + +func (r *systemOnePolicyAccountRepo) SetError(context.Context, int64, string) error { + r.errorCalls++ + return nil +} + +func (u *systemOneHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return u.do(req) +} + +func (u *systemOneHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.do(req) +} + +func newSystemOneTestService(upstream HTTPUpstream) *GatewayService { + return &GatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{AllowInsecureHTTP: true}}}, + httpUpstream: upstream, + } +} + +func newSystemOneTestContext() *gin.Context { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/systemone", nil) + return c +} + +func TestForwardSystemOneForwardsNativeProtocolAndUsage(t *testing.T) { + requestBody := []byte(`{"model":"jev-latest","state":{"text":"sample"},"questions":{"q":{"type":"choice","instructions":"Pick","criteria":{"a":"A","b":"B"}}}}`) + responseBody := []byte(`{"model":"jev-1.13.0","answers":{"q":{"type":"choice","choice":"a"}},"usage":{"input_tokens":123,"output_tokens":7},"provider_extension":{"kept":true}}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, typesafeSystemOnePathForTest, r.URL.Path) + require.Equal(t, "Bearer ts-secret", r.Header.Get("Authorization")) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + got, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, requestBody, got) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("x-request-id", "req-jev") + _, err = w.Write(responseBody) + require.NoError(t, err) + })) + defer server.Close() + + svc := newSystemOneTestService(&systemOneHTTPUpstream{do: server.Client().Do}) + account := &Account{ID: 7, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": server.URL, "api_key": "ts-secret"}} + result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, requestBody) + require.NoError(t, err) + require.Equal(t, responseBody, result.Body) + require.Equal(t, http.StatusOK, result.StatusCode) + require.Equal(t, "application/json; charset=utf-8", result.ContentType) + require.Equal(t, "req-jev", result.RequestID) + require.Equal(t, "jev-latest", result.Model) + require.Equal(t, "jev-1.13.0", result.UpstreamResponseModel) + require.Equal(t, 123, result.Usage.InputTokens) + require.Equal(t, 7, result.Usage.OutputTokens) +} + +func TestForwardSystemOneAllowsSuccessfulResponseWithoutModel(t *testing.T) { + responseBody := []byte(`{"answers":{"q":{"type":"noul","answer":"ok"}},"usage":{"input_tokens":12}}`) + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(responseBody)))}, nil + }} + svc := newSystemOneTestService(upstream) + account := &Account{ID: 11, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + + result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + require.NoError(t, err) + require.Equal(t, responseBody, result.Body) + require.Empty(t, result.UpstreamResponseModel) + require.Equal(t, 12, result.Usage.InputTokens) +} + +const typesafeSystemOnePathForTest = "/v1/systemone" + +func TestForwardSystemOneErrorPolicy(t *testing.T) { + for _, status := range []int{400, 422, 401, 429, 529, 500, 503} { + t.Run(http.StatusText(status), func(t *testing.T) { + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("private upstream body ts-secret"))}, nil + }} + svc := newSystemOneTestService(upstream) + account := &Account{ID: 8, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{"private":"request"}`)) + require.Nil(t, result) + require.Error(t, err) + require.NotContains(t, err.Error(), "private") + require.NotContains(t, err.Error(), "ts-secret") + if status == 400 || status == 422 { + var upstreamErr *SystemOneUpstreamError + require.ErrorAs(t, err, &upstreamErr) + require.Equal(t, status, upstreamErr.StatusCode) + return + } + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.ShouldRetryNextAccount()) + if status == http.StatusUnauthorized { + require.Equal(t, GatewayFailureStageAccountAuth, failoverErr.Stage) + require.Equal(t, GatewayFailureScopeAccount, failoverErr.Scope) + require.Equal(t, TypeSafeCredentialRejectedReason, failoverErr.Reason) + } + }) + } +} + +func TestForwardSystemOneAppliesExistingAccountStatePolicy(t *testing.T) { + for _, tc := range []struct { + name string + status int + wantRateLimited int + wantOverloaded int + wantError int + }{ + {name: "unauthorized", status: http.StatusUnauthorized, wantError: 1}, + {name: "rate limited", status: http.StatusTooManyRequests, wantRateLimited: 1}, + {name: "overloaded", status: 529, wantOverloaded: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + account := &Account{ID: 10, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + repo := &systemOnePolicyAccountRepo{account: account} + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: tc.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"private"}`))}, nil + }} + svc := newSystemOneTestService(upstream) + svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + _, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + require.Error(t, err) + require.Equal(t, tc.wantRateLimited, repo.rateLimitedCalls) + require.Equal(t, tc.wantOverloaded, repo.overloadedCalls) + require.Equal(t, tc.wantError, repo.errorCalls) + }) + } +} + +func TestForwardSystemOneTransportAndTimeoutFailOver(t *testing.T) { + for _, transportErr := range []error{errors.New("network unavailable"), context.DeadlineExceeded} { + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { return nil, transportErr }} + svc := newSystemOneTestService(upstream) + account := &Account{ID: 9, Name: "jev", Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + _, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + } +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 14d43f0c8743..6f999c189c5b 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe: addPlatformGroups(account.Platform, accountGroupIDs) case PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 @@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account return s.rebuildBuckets(ctx, buckets, reason) } -func schedulerSnapshotPlatforms() [10]string { - return [10]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} +func schedulerSnapshotPlatforms() [11]string { + return [11]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} } // 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4c8c06a7a3ea..79d836853c87 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -17,7 +17,7 @@ export interface DefaultSubscriptionSetting { } // ── 平台限额类型 ────────────────────────────────────────────────── -export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity" | "grok" +export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity" | "grok" | "typesafe" export type QuotaWindowType = "daily" | "weekly" | "monthly" /** 单平台三档限额;null = 不限制,undefined = 未填(等价 null) */ @@ -30,7 +30,7 @@ export interface PlatformQuotaLimits { /** 全平台默认限额 map(key = PlatformType) */ export type DefaultPlatformQuotasMap = Partial> -const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"] +const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok", "typesafe"] export type SchedulingThresholdPlatformType = | "openai" diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 738e0531c199..eb74fd9c50ce 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -330,7 +330,7 @@ export async function bindUserAuthIdentity( /** * Platform quota types */ -export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' +export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'typesafe' export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly' export interface PlatformQuotaItem { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c6616c679f5b..88a32c225613 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -228,6 +228,19 @@ OpenCode + @@ -4043,6 +4056,8 @@ const apiKeyBaseUrlPlaceholder = computed(() => { return 'https://generativelanguage.googleapis.com' case 'grok': return 'https://api.x.ai/v1' + case 'typesafe': + return 'https://api.typesafe.ai' default: return 'https://api.anthropic.com' } @@ -4065,6 +4080,8 @@ const apiKeyValuePlaceholder = computed(() => { case 'minimax': case 'opencode_go': return 'sk-...' + case 'typesafe': + return 'ts-...' default: return 'sk-ant-...' } @@ -4271,6 +4288,13 @@ function selectOpenCodeGoPlatform() { resetAdaptiveBaseUrls('opencode_go', openCodeAccountMode.value) openCodeGoProtocolRules.value = cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules(openCodeAccountMode.value)) } +function selectTypeSafePlatform() { + form.platform = 'typesafe' + form.type = 'apikey' + accountCategory.value = 'apikey' + apiKeyBaseUrl.value = 'https://api.typesafe.ai' + allowedModels.value = ['jev-latest'] +} // 账号类型 / 协议变更时同步默认 base url。 watch(openCodeAccountMode, (mode, previousMode) => { if (!isOpenCodeGoPlatform.value) return @@ -4844,12 +4868,18 @@ watch( ? 'https://generativelanguage.googleapis.com' : newPlatform === 'grok' ? 'https://api.x.ai/v1' + : newPlatform === 'typesafe' + ? 'https://api.typesafe.ai' : 'https://api.anthropic.com' } // Clear model-related settings allowedModels.value = [] upstreamModelsPreviewed.value = false modelMappings.value = [] + if (newPlatform === 'typesafe') { + accountCategory.value = 'apikey' + allowedModels.value = ['jev-latest'] + } // Antigravity: 默认使用映射模式并填充默认映射 if (newPlatform === 'antigravity') { antigravityModelRestrictionMode.value = 'mapping' @@ -5772,6 +5802,8 @@ const handleSubmit = async () => { ? 'https://generativelanguage.googleapis.com' : form.platform === 'grok' ? 'https://api.x.ai/v1' + : form.platform === 'typesafe' + ? 'https://api.typesafe.ai' : 'https://api.anthropic.com' // Build credentials with optional model mapping diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 4e9bbff7a12a..fba46ab55100 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3821,6 +3821,7 @@ const defaultBaseUrl = computed(() => { if (props.account?.platform === 'openai') return 'https://api.openai.com' if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com' if (props.account?.platform === 'grok') return 'https://api.x.ai/v1' + if (props.account?.platform === 'typesafe') return 'https://api.typesafe.ai' // CN 供应商:按当前模式/协议回落到官方预设(清空输入框提交时使用), // 不能落到 anthropic 默认值(会被当 CC base 拼出错误端点)。 if ( @@ -4270,6 +4271,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { ? 'https://generativelanguage.googleapis.com' : newAccount.platform === 'grok' ? 'https://api.x.ai/v1' + : newAccount.platform === 'typesafe' + ? 'https://api.typesafe.ai' : newAccount.platform === 'kimi' || newAccount.platform === 'zhipu' || newAccount.platform === 'deepseek' || diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue index 57cb4b394def..2e5d47be6722 100644 --- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue +++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue @@ -128,7 +128,7 @@ const emit = defineEmits(['close', 'success']) const { t } = useI18n() const appStore = useAppStore() -const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] +const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'typesafe'] interface QuotaRow { platform: PlatformQuotaPlatform diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index 50740b7cef7b..34936f07b54d 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -75,7 +75,7 @@ beforeEach(() => { }) describe('UserPlatformQuotaModal', () => { - it.each([0, 4, 14])('does not turn a negative limit in input %s into unlimited', async (index) => { + it.each([0, 4, 14, 17])('does not turn a negative limit in input %s into unlimited', async (index) => { const w = await mountAndOpen() await w.findAll('input[type=number]')[index].setValue('-1') await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') @@ -101,7 +101,7 @@ describe('UserPlatformQuotaModal', () => { expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99) }) - it('空数据渲染 5 个 platform 行', async () => { + it('空数据渲染 6 个 platform 行', async () => { const w = await mountAndOpen() const html = w.html() expect(html).toContain('anthropic') @@ -109,6 +109,7 @@ describe('UserPlatformQuotaModal', () => { expect(html).toContain('gemini') expect(html).toContain('antigravity') expect(html).toContain('grok') + expect(html).toContain('typesafe') }) it('已有数据正确填充 limit input', async () => { @@ -120,13 +121,13 @@ describe('UserPlatformQuotaModal', () => { }) const w = await mountAndOpen() const inputs = w.findAll('input[type=number]') - // 5 platforms × 3 windows = 15 inputs - expect(inputs.length).toBe(15) + // 6 platforms x 3 windows = 18 inputs + expect(inputs.length).toBe(18) // 第一个 input 是 anthropic.daily = 10 expect((inputs[0].element as HTMLInputElement).value).toBe('10') }) - it('保存提交完整 5 platform payload', async () => { + it('保存提交完整 6 platform payload', async () => { apiMocks.getPlatformQuotas.mockResolvedValueOnce({ platform_quotas: [ { platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null, @@ -143,7 +144,7 @@ describe('UserPlatformQuotaModal', () => { expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0] expect(uid).toBe(99) - expect(payload).toHaveLength(5) // 5 platforms always submitted + expect(payload).toHaveLength(6) // 6 platforms always submitted const openai = payload.find((p: any) => p.platform === 'openai') expect(openai.weekly_limit_usd).toBe(20) }) @@ -208,7 +209,7 @@ describe('UserPlatformQuotaModal', () => { it('未配置限额的平台重置按钮禁用并提示不可用', async () => { const w = await mountAndOpen() const resetBtns = w.findAll('button').filter((b) => b.text() === '↻') - expect(resetBtns.length).toBe(15) // 5 平台 × 3 窗口 + expect(resetBtns.length).toBe(18) // 6 platforms x 3 windows for (const b of resetBtns) { expect((b.element as HTMLButtonElement).disabled).toBe(true) expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable') diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 18308b49008a..520a3f005545 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -341,6 +341,8 @@ const defaultClientTab = computed(() => { return 'gemini' case 'antigravity': return 'claude' + case 'typesafe': + return 'systemone' default: return 'claude' } @@ -468,6 +470,10 @@ const clientTabs = computed((): TabConfig[] => { { id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon }, { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } ] + case 'typesafe': + return [ + { id: 'systemone', label: t('keys.useKeyModal.cliTabs.systemOne'), icon: TerminalIcon } + ] case 'deepseek': case 'minimax': case 'composite': @@ -552,6 +558,8 @@ const platformDescription = computed(() => { return activeClientTab.value === 'codex' ? t('keys.useKeyModal.composite.codexDescription') : t('keys.useKeyModal.composite.description') + case 'typesafe': + return t('keys.useKeyModal.typesafe.description') default: return t('keys.useKeyModal.description') } @@ -609,6 +617,8 @@ const platformNote = computed(() => { return activeClientTab.value === 'codex' ? t('keys.useKeyModal.composite.codexNote') : t('keys.useKeyModal.note') + case 'typesafe': + return t('keys.useKeyModal.typesafe.note') default: return t('keys.useKeyModal.note') } @@ -734,6 +744,8 @@ const currentFiles = computed((): FileConfig[] => { } switch (props.platform) { + case 'typesafe': + return [generateSystemOneCurl(baseRoot, apiKey)] case 'openai': if (activeClientTab.value === 'claude') { return generateAnthropicFiles(baseUrl, apiKey) @@ -786,6 +798,46 @@ const currentFiles = computed((): FileConfig[] => { } }) +function generateSystemOneCurl(baseUrl: string, apiKey: string): FileConfig { + const endpoint = `${baseUrl}/v1/systemone` + const payload = `{ + "model": "jev-latest", + "state": "Text to evaluate", + "questions": { + "safety": { + "type": "noul", + "instructions": "Evaluate whether the text is unsafe" + } + } +}` + if (activeTab.value === 'powershell') { + return { + path: 'PowerShell', + content: `$headers = @{ Authorization = "Bearer ${apiKey}" } +$body = @' +${payload} +'@ +Invoke-RestMethod -Method Post -Uri "${endpoint}" -Headers $headers -ContentType "application/json" -Body $body` + } + } + if (activeTab.value === 'cmd') { + return { + path: 'Command Prompt', + content: `curl -X POST "${endpoint}" ^ + -H "Authorization: Bearer ${apiKey}" ^ + -H "Content-Type: application/json" ^ + --data "{\"model\":\"jev-latest\",\"state\":\"Text to evaluate\",\"questions\":{\"safety\":{\"type\":\"noul\",\"instructions\":\"Evaluate whether the text is unsafe\"}}}"` + } + } + return { + path: 'Terminal', + content: `curl -X POST "${endpoint}" \\ + -H "Authorization: Bearer ${apiKey}" \\ + -H "Content-Type: application/json" \\ + --data '${payload}'` + } +} + function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] { let path: string let content: string @@ -1252,6 +1304,7 @@ function generateRoutedCodexFiles( deepseek: 'DeepSeek', minimax: 'MiniMax', opencode_go: 'OpenCode', + typesafe: 'TypeSafe / Jev', composite: 'Composite' } const label = labels[platform] diff --git a/frontend/src/components/user/UserPlatformQuotaCell.vue b/frontend/src/components/user/UserPlatformQuotaCell.vue index cf3b4d0eda6c..f59d26290439 100644 --- a/frontend/src/components/user/UserPlatformQuotaCell.vue +++ b/frontend/src/components/user/UserPlatformQuotaCell.vue @@ -34,7 +34,7 @@ import type { PlatformQuotaItem, PlatformQuotaPlatform } from '@/api/admin/users const props = defineProps<{ quotas?: PlatformQuotaItem[] }>() const { t } = useI18n() -const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] +const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'typesafe'] // 仅展示「至少一档限额非空」的平台(配额列,非用量列) const configured = computed(() => { diff --git a/frontend/src/components/user/__tests__/UserPlatformQuotaCell.spec.ts b/frontend/src/components/user/__tests__/UserPlatformQuotaCell.spec.ts index 533d1a322582..9a9928a6b37a 100644 --- a/frontend/src/components/user/__tests__/UserPlatformQuotaCell.spec.ts +++ b/frontend/src/components/user/__tests__/UserPlatformQuotaCell.spec.ts @@ -57,11 +57,12 @@ describe('UserPlatformQuotaCell', () => { expect(html).toContain('90.5/2000') }) - it('多平台按 anthropic→openai→gemini→antigravity 顺序,仅展示有限额的', () => { + it('多平台按固定顺序展示 TypeSafe,且仅展示有限额的平台', () => { const w = mount(UserPlatformQuotaCell, { props: { quotas: [ item({ platform: 'gemini', monthly_limit_usd: 50 }), + item({ platform: 'typesafe', daily_limit_usd: 5 }), item({ platform: 'anthropic', daily_limit_usd: 10 }), item({ platform: 'openai', daily_usage_usd: 9 }), ], @@ -69,6 +70,7 @@ describe('UserPlatformQuotaCell', () => { }) const text = w.text() expect(text.indexOf('anthropic')).toBeLessThan(text.indexOf('gemini')) + expect(text.indexOf('gemini')).toBeLessThan(text.indexOf('typesafe')) expect(text).not.toContain('openai') }) }) diff --git a/frontend/src/components/user/dashboard/UserDashboardStats.vue b/frontend/src/components/user/dashboard/UserDashboardStats.vue index 293172bf8f01..e7d9f79ae9c9 100644 --- a/frontend/src/components/user/dashboard/UserDashboardStats.vue +++ b/frontend/src/components/user/dashboard/UserDashboardStats.vue @@ -259,6 +259,7 @@ const PLATFORM_LABELS: Record = { zhipu: 'Zhipu GLM', deepseek: 'DeepSeek', minimax: 'MiniMax', + typesafe: 'TypeSafe / Jev', } const platformLabel = (p: string) => PLATFORM_LABELS[p] ?? p @@ -285,7 +286,7 @@ const platformCards = computed(() => { if (hasAnyLimit(q)) platforms.add(platform) } - const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] + const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'typesafe'] const cards: FusedPlatformCard[] = [] for (const p of platforms) { diff --git a/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts b/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts index a2ab9ed5cf35..284409d56e38 100644 --- a/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts +++ b/frontend/src/components/user/dashboard/__tests__/UserDashboardStats.spec.ts @@ -132,6 +132,14 @@ describe('UserDashboardStats 按平台拆分', () => { expect(w.text()).toContain('Kimi') }) + it('TypeSafe 使用 Jev 标签并位于未知平台之前', () => { + const w = mountStats( + makeStats({ total_actual_cost: 0.5, today_actual_cost: 0, by_platform: [usage('kimi', 0.3), usage('typesafe', 0.2)] }) + ) + expect(cardPlatforms(w)).toEqual(['typesafe', 'kimi']) + expect(w.text()).toContain('TypeSafe / Jev') + }) + it('总值大于各平台之和时追加"其他"卡片,且不计入平台计数', () => { const w = mountStats( makeStats({ total_actual_cost: 1.0, today_actual_cost: 0, by_platform: [usage('anthropic', 0.4)] }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 91a847bf1952..083f7a5c8186 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -465,6 +465,7 @@ export function getModelsByPlatform(platform: string): string[] { 'qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'hy4-preview', 'hy3', 'omen-alpha' ] + case 'typesafe': return ['jev-latest'] case 'doubao': return doubaoModels case 'minimax': return minimaxModels case 'baidu': return baiduModels diff --git a/frontend/src/constants/__tests__/platforms.spec.ts b/frontend/src/constants/__tests__/platforms.spec.ts index af4fc9ab498e..c4c1b8ec7245 100644 --- a/frontend/src/constants/__tests__/platforms.spec.ts +++ b/frontend/src/constants/__tests__/platforms.spec.ts @@ -11,7 +11,8 @@ const concretePlatforms = [ 'zhipu', 'deepseek', 'minimax', - 'opencode_go' + 'opencode_go', + 'typesafe' ] describe('platform option catalogs', () => { diff --git a/frontend/src/constants/platforms.ts b/frontend/src/constants/platforms.ts index 1b46eb1c63f1..048ecfc57b59 100644 --- a/frontend/src/constants/platforms.ts +++ b/frontend/src/constants/platforms.ts @@ -20,7 +20,8 @@ export const CONCRETE_PLATFORM_OPTIONS = [ { value: 'zhipu', label: 'Zhipu GLM' }, { value: 'deepseek', label: 'DeepSeek' }, { value: 'minimax', label: 'MiniMax' }, - { value: 'opencode_go', label: 'OpenCode' } + { value: 'opencode_go', label: 'OpenCode' }, + { value: 'typesafe', label: 'TypeSafe / Jev' } ] as const satisfies readonly PlatformOption[] /** Platforms that can own a group. */ diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index b25f0f8c8700..1844d8e66a0a 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -109,6 +109,7 @@ export default { deepseek: 'DeepSeek', minimax: 'MiniMax', opencode_go: 'OpenCode', + typesafe: 'TypeSafe / Jev', }, cnProviders: { accountMode: { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 725cdada283d..2eb6c30b57fa 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -984,6 +984,7 @@ export default { deepseek: 'DeepSeek', minimax: 'MiniMax', opencode_go: 'OpenCode', + typesafe: 'TypeSafe / Jev', composite: 'Composite', }, deleteConfirm: diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 76be95b4d2f0..e792a43f6c58 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -182,8 +182,13 @@ export default { codexCli: 'Codex CLI', codexCliWs: 'Codex CLI (WebSocket)', grokCli: 'Grok CLI', + systemOne: 'System One', opencode: 'OpenCode', }, + typesafe: { + description: 'Call Jev through the native TypeSafe System One endpoint.', + note: 'System One is non-streaming and is not compatible with Chat Completions, Responses, Claude Code, or Codex clients.', + }, antigravity: { description: 'Configure API access for Antigravity group. Select the configuration method based on your client.', claudeCode: 'Claude Code', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index fc4bb944269c..0321dc59fda2 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -312,6 +312,7 @@ export default { deepseek: 'DeepSeek', minimax: 'MiniMax', opencode_go: 'OpenCode', + typesafe: 'TypeSafe / Jev', }, cnProviders: { accountMode: { diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index b3b3e706d264..2cdd3d0ccc70 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -917,6 +917,7 @@ export default { deepseek: 'DeepSeek', minimax: 'MiniMax', opencode_go: 'OpenCode', + typesafe: 'TypeSafe / Jev', composite: 'Composite', }, saving: '保存中...', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index d74ff78d9905..cbe1e3896d76 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -183,8 +183,13 @@ export default { codexCli: 'Codex CLI', codexCliWs: 'Codex CLI (WebSocket)', grokCli: 'Grok CLI', + systemOne: 'System One', opencode: 'OpenCode' }, + typesafe: { + description: '通过 TypeSafe 原生 System One 端点调用 Jev。', + note: 'System One 不支持流式请求,也不兼容 Chat Completions、Responses、Claude Code 或 Codex 客户端。' + }, antigravity: { description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。', claudeCode: 'Claude Code', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 80cef707bd91..5c2ada799d8c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -538,7 +538,7 @@ export interface PaginationConfig { // ==================== API Key & Group Types ==================== -export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'composite' +export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'typesafe' | 'composite' export type VideoModelPrices = Record> @@ -918,7 +918,7 @@ export interface UpdateGroupRequest { // ==================== Account & Proxy Types ==================== -export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' +export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'kimi' | 'zhipu' | 'deepseek' | 'minimax' | 'opencode_go' | 'typesafe' export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'service_account' export type OAuthAddMethod = 'oauth' | 'setup-token' export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h' diff --git a/frontend/src/utils/keyGroupProviders.ts b/frontend/src/utils/keyGroupProviders.ts index f2c80c6be139..87010a0101fe 100644 --- a/frontend/src/utils/keyGroupProviders.ts +++ b/frontend/src/utils/keyGroupProviders.ts @@ -16,7 +16,8 @@ const PROVIDER_BY_PLATFORM: Record = { grok: 'other', antigravity: 'other', composite: 'other', - opencode_go: 'other' + opencode_go: 'other', + typesafe: 'other' } export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider { diff --git a/frontend/src/utils/platformColors.ts b/frontend/src/utils/platformColors.ts index ceb8f3a2e2b6..7c206ee46d97 100644 --- a/frontend/src/utils/platformColors.ts +++ b/frontend/src/utils/platformColors.ts @@ -16,6 +16,7 @@ export type Platform = | 'deepseek' | 'minimax' | 'opencode_go' + | 'typesafe' | 'composite' // ── Badge (bg + text + border, for inline badges with border) ─────── @@ -30,6 +31,7 @@ const BADGE: Record = { deepseek: 'bg-teal-500/10 text-teal-600 border-teal-500/30 dark:text-teal-400', minimax: 'bg-rose-500/10 text-rose-600 border-rose-500/30 dark:text-rose-400', opencode_go: 'bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-300', + typesafe: 'bg-sky-500/10 text-sky-700 border-sky-500/30 dark:text-sky-300', composite: 'bg-cyan-500/10 text-cyan-700 border-cyan-500/30 dark:text-cyan-300', } const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400' @@ -46,6 +48,7 @@ const BADGE_LIGHT: Record = { deepseek: 'bg-teal-500/10 text-teal-600 dark:bg-teal-500/10 dark:text-teal-300', minimax: 'bg-rose-500/10 text-rose-600 dark:bg-rose-500/10 dark:text-rose-300', opencode_go: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300', + typesafe: 'bg-sky-500/10 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300', composite: 'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-500/10 dark:text-cyan-300', } @@ -61,6 +64,7 @@ const BORDER: Record = { deepseek: 'border-teal-500/20 dark:border-teal-500/20', minimax: 'border-rose-500/20 dark:border-rose-500/20', opencode_go: 'border-amber-500/20 dark:border-amber-500/20', + typesafe: 'border-sky-500/20 dark:border-sky-500/20', composite: 'border-cyan-500/20 dark:border-cyan-500/20', } const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700' @@ -77,6 +81,7 @@ const BORDER_STRONG: Record = { deepseek: 'border-teal-500/35 dark:border-teal-500/30', minimax: 'border-rose-500/35 dark:border-rose-500/30', opencode_go: 'border-amber-500/35 dark:border-amber-500/30', + typesafe: 'border-sky-500/35 dark:border-sky-500/30', composite: 'border-cyan-500/35 dark:border-cyan-500/30', } const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600' @@ -94,6 +99,7 @@ const ACCENT: Record = { deepseek: '#14b8a6', // teal-500 minimax: '#f43f5e', // rose-500 opencode_go: '#f59e0b', // amber-500 + typesafe: '#0ea5e9', // sky-500 composite: '#06b6d4', // cyan-500 } const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal) @@ -110,6 +116,7 @@ const ACCENT_BAR: Record = { deepseek: 'bg-gradient-to-r from-teal-400 to-teal-500', minimax: 'bg-gradient-to-r from-rose-400 to-rose-500', opencode_go: 'bg-gradient-to-r from-amber-400 to-amber-500', + typesafe: 'bg-gradient-to-r from-sky-400 to-sky-500', composite: 'bg-gradient-to-r from-slate-500 to-cyan-500', } const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500' @@ -126,6 +133,7 @@ const TEXT: Record = { deepseek: 'text-teal-600 dark:text-teal-400', minimax: 'text-rose-600 dark:text-rose-400', opencode_go: 'text-amber-700 dark:text-amber-300', + typesafe: 'text-sky-700 dark:text-sky-300', composite: 'text-cyan-700 dark:text-cyan-300', } const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400' @@ -142,6 +150,7 @@ const ICON: Record = { deepseek: 'text-teal-500 dark:text-teal-400', minimax: 'text-rose-500 dark:text-rose-400', opencode_go: 'text-amber-500 dark:text-amber-300', + typesafe: 'text-sky-500 dark:text-sky-300', composite: 'text-cyan-600 dark:text-cyan-300', } const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400' @@ -158,6 +167,7 @@ const BUTTON: Record = { deepseek: 'bg-teal-500 text-white hover:bg-teal-600 active:bg-teal-700 dark:bg-teal-500/80 dark:hover:bg-teal-500', minimax: 'bg-rose-500 text-white hover:bg-rose-600 active:bg-rose-700 dark:bg-rose-500/80 dark:hover:bg-rose-500', opencode_go: 'bg-amber-500 text-white hover:bg-amber-600 active:bg-amber-700 dark:bg-amber-500/80 dark:hover:bg-amber-500', + typesafe: 'bg-sky-600 text-white hover:bg-sky-700 active:bg-sky-800 dark:bg-sky-600/80 dark:hover:bg-sky-600', composite: 'bg-cyan-700 text-white hover:bg-cyan-800 active:bg-cyan-900 dark:bg-cyan-600 dark:hover:bg-cyan-500', } const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500' @@ -174,6 +184,7 @@ const DISCOUNT: Record = { deepseek: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300', minimax: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300', opencode_go: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + typesafe: 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300', composite: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300', } const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' @@ -190,6 +201,7 @@ const GRADIENT: Record = { deepseek: 'from-teal-500 to-teal-600', minimax: 'from-rose-500 to-rose-600', opencode_go: 'from-amber-500 to-amber-600', + typesafe: 'from-sky-500 to-sky-600', composite: 'from-slate-600 to-cyan-600', } const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600' @@ -206,6 +218,7 @@ const GRADIENT_TEXT: Record = { deepseek: 'text-teal-100', minimax: 'text-rose-100', opencode_go: 'text-amber-100', + typesafe: 'text-sky-100', composite: 'text-cyan-100', } const GRADIENT_TEXT_DEFAULT = 'text-primary-100' @@ -221,6 +234,7 @@ const GRADIENT_SUBTEXT: Record = { deepseek: 'text-teal-200', minimax: 'text-rose-200', opencode_go: 'text-amber-200', + typesafe: 'text-sky-200', composite: 'text-cyan-200', } const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200' @@ -239,6 +253,7 @@ function isPlatform(p: string): p is Platform { p === 'deepseek' || p === 'minimax' || p === 'opencode_go' || + p === 'typesafe' || p === 'composite' ) } @@ -307,6 +322,7 @@ export function platformLabel(p: string): string { case 'deepseek': return 'DeepSeek' case 'minimax': return 'MiniMax' case 'opencode_go': return 'OpenCode' + case 'typesafe': return 'TypeSafe / Jev' case 'composite': return 'Composite' default: return p || 'API' } diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index ace4b1451974..c255267407e1 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -763,9 +763,9 @@ const form = reactive({ let abortController: AbortController | null = null // ── Platform config ── -const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] +const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'] // Composite pricing/mapping may target every concrete schedulable provider. -const compositePlatforms: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] +const compositePlatforms: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'] // ── Helpers ── function formatDate(value: string): string { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c25b06eb3bd5..a468ce59599c 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4055,7 +4055,7 @@ - + {{ p }} @@ -4390,7 +4390,7 @@ - + {{ p }} diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 8491ff9e3078..2a7834766839 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -1870,7 +1870,7 @@ describe("admin SettingsView platform quota matrix", () => { getProviders.mockResolvedValue({ data: [] }); }); - it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 5 平台行", async () => { + it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 6 平台行", async () => { const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); @@ -1883,9 +1883,10 @@ describe("admin SettingsView platform quota matrix", () => { expect(html).toContain("openai"); expect(html).toContain("gemini"); expect(html).toContain("antigravity"); + expect(html).toContain("typesafe"); }); - it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 5 平台)", async () => { + it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 6 平台)", async () => { const wrapper = mountView(); await flushPromises(); await openUsersTab(wrapper); @@ -1901,7 +1902,7 @@ describe("admin SettingsView platform quota matrix", () => { // 应携带嵌套对象,而非扁平字段 expect(payload).toHaveProperty("default_platform_quotas"); const quotas = payload["default_platform_quotas"] as Record; - const platforms = ["anthropic", "openai", "gemini", "antigravity", "grok"]; + const platforms = ["anthropic", "openai", "gemini", "antigravity", "grok", "typesafe"]; for (const p of platforms) { expect(quotas).toHaveProperty(p); const pq = quotas[p] as Record; @@ -1915,7 +1916,7 @@ describe("admin SettingsView platform quota matrix", () => { expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly"); }); - it("加载后 form.default_platform_quotas 含全 5 平台,从嵌套 JSON 正确读取数值", async () => { + it("加载后 form.default_platform_quotas 含全 6 平台,从嵌套 JSON 正确读取数值", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, default_platform_quotas: { @@ -1940,6 +1941,7 @@ describe("admin SettingsView platform quota matrix", () => { // 缺失平台应补全为 null expect(quotas["gemini"]).toEqual({ daily: null, weekly: null, monthly: null }); expect(quotas["antigravity"]).toEqual({ daily: null, weekly: null, monthly: null }); + expect(quotas["typesafe"]).toEqual({ daily: null, weekly: null, monthly: null }); }); it("空输入(v-model.number 产出 \"\")在提交时清洗为 null 而非空字符串", async () => { diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts index d02356f10482..4d26fbcd1e78 100644 --- a/frontend/src/views/user/__tests__/KeysView.spec.ts +++ b/frontend/src/views/user/__tests__/KeysView.spec.ts @@ -548,7 +548,7 @@ describe('user KeysView column settings', () => { }) describe('create provider selection', () => { - const platforms = ['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'gemini', 'grok', 'antigravity', 'composite', 'opencode_go'] + const platforms = ['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'gemini', 'grok', 'antigravity', 'composite', 'opencode_go', 'typesafe'] const availableGroups = platforms.map((platform, index) => ({ id: index + 1, // Deliberately ambiguous names: classification must follow the platform. @@ -579,8 +579,8 @@ describe('user KeysView column settings', () => { await chooseProvider(wrapper, 'domestic') expect(optionIds(wrapper)).toEqual([3, 4, 5, 6]) await chooseProvider(wrapper, 'other') - expect(optionIds(wrapper)).toEqual([7, 8, 9, 10, 11]) - expect(wrapper.findAllComponents({ name: 'Select' })[0].props('options')).toHaveLength(13) + expect(optionIds(wrapper)).toEqual([7, 8, 9, 10, 11, 12]) + expect(wrapper.findAllComponents({ name: 'Select' })[0].props('options')).toHaveLength(14) }) it('clears the previous group on provider change and submits only the newly selected group', async () => { @@ -638,7 +638,7 @@ describe('user KeysView column settings', () => { await wrapper.get('[data-test="close-dialog"]').trigger('click') await getButtonByText(wrapper, 'common.edit').trigger('click') expect(wrapper.find('[data-tour="key-form-provider"]').exists()).toBe(false) - expect(optionIds(wrapper)).toHaveLength(11) + expect(optionIds(wrapper)).toHaveLength(12) }) }) }) From b13200d7a18486797f77b7833f4b2e35c00be7ff Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 19 Sep 2026 21:26:18 +0800 Subject: [PATCH 02/70] fix(gateway): forward received chat stream usage --- .../anthropic_chat_stream_usage_test.go | 111 ++++++++++++++++++ .../gateway_forward_as_chat_completions.go | 22 +++- ...ateway_forward_as_chat_completions_test.go | 4 +- ...enai_gateway_anthropic_native_pump_test.go | 4 +- ...teway_chat_completions_anthropic_native.go | 9 +- 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/anthropic_chat_stream_usage_test.go 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..99ce4d278954 --- /dev/null +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -0,0 +1,111 @@ +//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 string + want bool + prompt, 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, 49, 241, 17}, + {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0}, + {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, true, 0, 0, 0, 0}, + {"absent", "", "", false, 0, 0, 0, 0}, + {"null", `,"usage":null`, `,"usage":null`, false, 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 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.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.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/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 32d38cf40da8..687e31cc6f8c 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) @@ -185,7 +184,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) } @@ -358,7 +357,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mappedModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -376,7 +374,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -467,6 +464,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(), nil } @@ -512,3 +513,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/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go index 83bbd4eeff7e..e5b9d0dd84bb 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() @@ -220,7 +220,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 bc865af81c31..40b00c81e6ce 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -54,7 +54,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) @@ -136,7 +135,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) 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) } @@ -303,7 +302,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( upstreamModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -320,7 +318,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -461,6 +458,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(), nil } From ece1ecccca842585c99bc738bb93115bd1455e3e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 12:36:32 +0800 Subject: [PATCH 03/70] fix(gateway): normalize streamed Anthropic usage --- .../anthropic_chat_stream_usage_test.go | 27 +++-- .../gateway_forward_as_chat_completions.go | 5 + .../service/gateway_forward_as_responses.go | 67 ++++++++--- .../gateway_forward_as_responses_test.go | 110 ++++++++++++++++++ ...teway_chat_completions_anthropic_native.go | 5 + .../service/openai_gateway_cn_fixes_test.go | 80 +++++++++++++ ...enai_gateway_responses_anthropic_native.go | 6 + 7 files changed, 277 insertions(+), 23 deletions(-) diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go index 99ce4d278954..ff1558b073be 100644 --- a/backend/internal/service/anthropic_chat_stream_usage_test.go +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -22,19 +22,28 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { 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 string - want bool - prompt, output, cached, created int + 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, 49, 241, 17}, - {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0}, - {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, true, 0, 0, 0, 0}, - {"absent", "", "", false, 0, 0, 0, 0}, - {"null", `,"usage":null`, `,"usage":null`, false, 0, 0, 0, 0}, + {"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_cache_bucket", `,"usage":{"input_tokens":1200}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, + {"kimi_full_cache", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"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" @@ -52,6 +61,7 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { 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) @@ -60,6 +70,7 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { 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) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 687e31cc6f8c..0936af2b4605 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -430,6 +430,11 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // 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 { diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 1b97a739616d..6903e659ad17 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -279,22 +279,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 { @@ -303,13 +303,21 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { dst.CacheReadInputTokens = cacheReadTokens dst.CacheCreationInputTokens = src.CacheCreationInputTokens } else { + previousCacheReadTokens := dst.CacheReadInputTokens + previousCacheCreationTokens := dst.CacheCreationInputTokens 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 src.InputTokens == 0 && dst.InputTokens > 0 && (cacheReadTokens > 0 || src.CacheCreationInputTokens > 0) { + // Some compatible streams put the total prompt count in message_start, + // then provide only cumulative cache buckets in message_delta. Subtract + // only newly observed buckets so repeated deltas are idempotent. + newCacheReadTokens := max(cacheReadTokens-previousCacheReadTokens, 0) + newCacheCreationTokens := max(src.CacheCreationInputTokens-previousCacheCreationTokens, 0) + dst.InputTokens = max(dst.InputTokens-newCacheReadTokens-newCacheCreationTokens, 0) + } + if cacheReadTokens > 0 { + dst.CacheReadInputTokens = cacheReadTokens } if src.CacheCreationInputTokens > 0 { dst.CacheCreationInputTokens = src.CacheCreationInputTokens @@ -320,6 +328,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. @@ -545,6 +576,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 972c6bc1a60e..353eeb88468f 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -307,6 +307,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`, + deltaUsage: `"output_tokens":8,"cache_read_input_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "cached_tokens full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"cached_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "prompt_tokens_details full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"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", + startUsage: `"input_tokens":1200`, + deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, + wantInput: 400, + 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() 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 40b00c81e6ce..03c8b8e72971 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -407,6 +407,11 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // 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 { diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 0dfb7c35b6f0..470c789c7459 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`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, + {name: "partial cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, + {name: "cache creation", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, + {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"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_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index fa2e190b9704..c5f8ef6887c6 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -397,6 +397,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 From 0416c9ede47e8fbab4a705dc3eeb8b21bf928d73 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 15:27:53 +0800 Subject: [PATCH 04/70] fix(gateway): avoid ambiguous late-cache input subtraction --- .../anthropic_chat_stream_usage_test.go | 9 +++++---- .../service/gateway_forward_as_responses.go | 15 +++++---------- .../gateway_forward_as_responses_test.go | 18 +++++++++--------- .../service/openai_gateway_cn_fixes_test.go | 8 ++++---- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go index ff1558b073be..2397a5802dda 100644 --- a/backend/internal/service/anthropic_chat_stream_usage_test.go +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -29,10 +29,11 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { {"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_cache_bucket", `,"usage":{"input_tokens":1200}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, - {"kimi_full_cache", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, - {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, - {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"prompt_tokens_details":{"cached_tokens":173306}}`, "", true, 173306, 0, 173306, 49, 173306, 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}, diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 6903e659ad17..0c976838c7b9 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -303,19 +303,14 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { dst.CacheReadInputTokens = cacheReadTokens dst.CacheCreationInputTokens = src.CacheCreationInputTokens } else { - previousCacheReadTokens := dst.CacheReadInputTokens - previousCacheCreationTokens := dst.CacheCreationInputTokens + // 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.InputTokens == 0 && dst.InputTokens > 0 && (cacheReadTokens > 0 || src.CacheCreationInputTokens > 0) { - // Some compatible streams put the total prompt count in message_start, - // then provide only cumulative cache buckets in message_delta. Subtract - // only newly observed buckets so repeated deltas are idempotent. - newCacheReadTokens := max(cacheReadTokens-previousCacheReadTokens, 0) - newCacheCreationTokens := max(src.CacheCreationInputTokens-previousCacheCreationTokens, 0) - dst.InputTokens = max(dst.InputTokens-newCacheReadTokens-newCacheCreationTokens, 0) - } if cacheReadTokens > 0 { dst.CacheReadInputTokens = cacheReadTokens } diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 353eeb88468f..cafee5a71b0c 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -321,22 +321,22 @@ func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) }{ { name: "cache_read_input_tokens full cache", - startUsage: `"input_tokens":173306`, - deltaUsage: `"output_tokens":8,"cache_read_input_tokens":173306`, + 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`, - deltaUsage: `"output_tokens":8,"cached_tokens":173306`, + 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`, - deltaUsage: `"output_tokens":8,"prompt_tokens_details":{"cached_tokens":173306}`, + 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, }, @@ -357,10 +357,10 @@ func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) wantCached: 800, }, { - name: "cache creation only without prompt total or miss bucket", + name: "cache creation only without prompt total or miss bucket keeps independent input", startUsage: `"input_tokens":1200`, - deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, - wantInput: 400, + deltaUsage: `"input_tokens":0,"output_tokens":30,"cache_creation_input_tokens":800`, + wantInput: 1200, wantOutput: 30, wantCacheCreation: 800, }, diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 470c789c7459..da532536b179 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -125,10 +125,10 @@ func TestResponsesStreamingFromNativeAnthropic_NormalizesTerminalUsage(t *testin wantCached int wantCacheCreation int }{ - {name: "full cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, - {name: "partial cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, - {name: "cache creation", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, - {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, repeatDelta: true, wantInput: 400, wantOutput: 30, wantCached: 800}, + {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 { From 13fcfc0f3b841198afba71141d280768b04eefb5 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 19 Sep 2026 20:50:28 +0800 Subject: [PATCH 05/70] fix(gateway): resolve composite routes for websocket aliases --- backend/cmd/server/wire_gen.go | 2 +- .../handler/openai_gateway_handler.go | 34 +++- .../handler/openai_gateway_handler_test.go | 43 ++++- .../openai_gateway_ws_composite_test.go | 159 ++++++++++++++++++ backend/internal/handler/wire.go | 2 + .../service/openai_ws_forwarder_ingress.go | 7 +- docs/COMPOSITE_GROUPS.md | 7 + 7 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handler/openai_gateway_ws_composite_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 37390f92cff7..7adddf9ad453 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -294,7 +294,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { 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, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index bf4563f2f207..bfb447593142 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -34,6 +34,7 @@ import ( // OpenAIGatewayHandler handles OpenAI API gateway requests type OpenAIGatewayHandler struct { + compositeResolver *service.CompositeRouteResolver gatewayService *service.OpenAIGatewayService billingCacheService *service.BillingCacheService apiKeyService *service.APIKeyService @@ -2391,7 +2392,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } // 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。 - // 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。 + // 必须在合成路由解析和上游模型映射之前执行。 // 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定, // 全部候选值逐一校验,任一未命中即拒绝。 if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" { @@ -2400,7 +2401,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked)) return } - 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) @@ -2464,8 +2479,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } // 解析渠道级模型映射 - channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) - wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel) + channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, wsRouteModel) + wsForwardModel := openAIChannelForwardModel(channelMappingWS, wsRouteModel) var currentUserRelease func() var currentAccountRelease func() @@ -2856,7 +2871,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { model = reqModel } setOpsRequestContext(c, model, true) - mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) + 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, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, routeModel) mappedModelUnchanged := false if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index d13ac015892c..cf9f1d9e7c65 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1931,7 +1931,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,客户端按普通事件读取。 @@ -2878,6 +2881,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, }) @@ -2945,6 +2959,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 } @@ -3000,7 +3017,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU service.NewBillingService(cfg, nil), nil, billingCacheSvc, - nil, + &compositeWSHTTPUpstream{}, &service.DeferredService{}, nil, nil, @@ -3021,6 +3038,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } h := &OpenAIGatewayHandler{ cfg: cfg, + compositeResolver: tc.compositeResolver, gatewayService: gatewaySvc, billingCacheService: billingCacheSvc, apiKeyService: &service.APIKeyService{}, @@ -3069,6 +3087,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) @@ -3076,12 +3100,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{} } @@ -3115,12 +3144,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, turnCount-1, "rejected turn must not reach upstream") _ = clientConn.CloseNow() return openAIResponsesWSUsageLogResult{} } @@ -3149,6 +3183,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/wire.go b/backend/internal/handler/wire.go index d35245ecbf95..e51b105728e8 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -130,10 +130,12 @@ func ProvideOpenAIGatewayHandler( grokQuotaService *service.GrokQuotaService, 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.compositeResolver = compositeResolver h.securityAuditCoordinator = coordinator h.grokMediaEligibilityProber = grokQuotaService return h diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index ca6135352bef..98e6327c3ce7 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -585,7 +585,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeAccountFailoverInputExists := false for turn := 1; ; turn++ { 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 } } @@ -1272,6 +1272,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 @@ -1460,7 +1462,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 } } @@ -1941,6 +1943,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } currentPayload = nextPayload.payloadRaw + currentClientPayload = nextPayload.rawForHash currentOriginalModel = nextPayload.originalModel currentImageBillingModel = nextPayload.imageBillingModel currentImageSizeTier = nextPayload.imageSizeTier 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: From e878ab710f9df9f17f84e453929c8d6ac901d41e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 11:06:21 +0800 Subject: [PATCH 06/70] fix(gateway): constrain account model route ownership --- .../service/openai_account_scheduler.go | 5 + .../service/openai_account_scheduler_test.go | 114 ++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index c6341d5294e2..ae4e2c23211e 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1776,6 +1776,11 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con 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 081cf30f9a95..7a047d6069b1 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 { @@ -1257,6 +1261,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() From 453b796df0bb2e1bf20f04f709ce8acb7f1480ba Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 15:27:26 +0800 Subject: [PATCH 07/70] fix(gateway): enforce composite model ownership in legacy scheduling --- .../service/openai_account_scheduler_test.go | 123 ++++++++++++++++++ .../service/openai_gateway_scheduling.go | 10 ++ 2 files changed, 133 insertions(+) diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 7a047d6069b1..13959d5df786 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -3927,3 +3927,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_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 2f9a750caad6..5c5f5db42a71 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -392,6 +392,16 @@ func openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx context.Con if account == nil { return "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 "account_model_not_owned" + } + } if account.Platform != platform || !account.IsOpenAICompatible() { return "platform_mismatch" } From e2c35cf394ac91c7e35f14d0fa837f630ca25e3f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 17:47:47 +0800 Subject: [PATCH 08/70] fix(account): prevent whitelist model mapping conflicts --- .../account/BulkEditAccountModal.vue | 1 + .../components/account/CreateAccountModal.vue | 3 ++ .../components/account/EditAccountModal.vue | 8 ++--- .../account/ModelWhitelistSelector.vue | 6 ++++ .../__tests__/EditAccountModal.spec.ts | 19 +++++++++++ .../__tests__/ModelWhitelistSelector.spec.ts | 33 ++++++++++++++++++- .../__tests__/useModelWhitelist.spec.ts | 13 ++++++++ .../src/i18n/locales/en/admin/accounts.ts | 1 + .../src/i18n/locales/zh/admin/accounts.ts | 1 + 9 files changed, 80 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 22f6f3e788e0..7acd3ac79b1a 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -343,6 +343,7 @@ diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c6616c679f5b..ff0f07d13882 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1518,6 +1518,7 @@
- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -779,7 +779,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -991,7 +991,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -1213,7 +1213,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ t('admin.accounts.supportsAllModels') }} diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index 5f359ccca1d9..d1e7e557cbba 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -159,6 +159,7 @@ const { t } = useI18n() const props = defineProps<{ modelValue: string[] + modelMappings?: { from: string; to: string }[] platform?: string platforms?: string[] accountId?: number @@ -274,6 +275,11 @@ const addCustom = () => { 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__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 7b104410c1bc..92274571a637 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -70,6 +70,7 @@ const BaseDialogStub = defineComponent({ const ModelWhitelistSelectorStub = defineComponent({ name: 'ModelWhitelistSelector', props: { + modelMappings: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] @@ -330,6 +331,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 97667e3ee402..576df0d0b9f3 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -24,7 +24,7 @@ vi.mock('vue-i18n', async () => { return { ...actual, useI18n: () => ({ - t: (key: string) => (key === 'common.copy' ? '复制' : key) + t: (key: string, params?: Record) => key === 'common.copy' ? '复制' : key === 'admin.accounts.modelMappingConflict' ? `Model mapping conflict: ${params?.from} → ${params?.to}` : key }) } }) @@ -91,6 +91,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/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index bb73136e0a4b..ef61e6ab4595 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -158,6 +158,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/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 40fd7b6f0b7d..685ca6c62b43 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -828,6 +828,7 @@ export default { 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', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index bc374be2939f..d4309e948590 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -933,6 +933,7 @@ export default { enterCustomModelName: '输入自定义模型名称', addModel: '填入', modelExists: '该模型已存在', + modelMappingConflict: '该模型已配置映射 {from} → {to},请在模型映射中修改或删除后再添加白名单模型', modelCount: '{count} 个模型', poolMode: '池模式', poolModeHint: '上游为账号池时启用,错误不标记本地账号状态', From b31b435091709920c87c2b5d4d66bf9e8d25134b Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 24 Sep 2026 16:20:16 +0800 Subject: [PATCH 09/70] =?UTF-8?q?fix(gateway):=20Claude=20Code=20=E9=99=90?= =?UTF-8?q?=E5=88=B6=E5=88=86=E7=BB=84=E7=9A=84=20OpenAI=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=85=A5=E5=8F=A3=E6=94=B9=E8=B5=B0=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E5=88=86=E7=BB=84,=E4=B8=8D=E5=86=8D=E7=9B=B4=E6=8E=A5=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组开启 claude_code_only 并配置降级分组时,/v1/messages 会在选号入口的 checkClaudeCodeRestriction 中把非 Claude Code 请求解析到降级分组;但 GatewayHandler.ChatCompletions / Responses 在 handler 层只要分组开了 claude_code_only 就直接返回 403,不看降级分组,请求到不了调度层:只会发 Chat Completions / Responses 的第三方客户端拿这类分组的 Key 一律 403。 - handler 层提前拒绝只在未配置降级分组时生效,拒绝的状态码与文案不变 - 配置了降级分组时请求继续,由 SelectAccountWithLoadAwareness 解析到降级分组 选号,再经已有的 Chat Completions / Responses ⇄ Anthropic Messages 转换转发; service 层无改动 - 与 /v1/messages 的降级语义一致:选号与渠道限制预检按降级分组,计费与用量 记录仍按 API Key 所属分组 新增 handler 单测覆盖两个入口在有/无降级分组时的行为。 --- .../gateway_handler_chat_completions.go | 7 +- ...teway_handler_claude_code_fallback_test.go | 181 ++++++++++++++++++ .../handler/gateway_handler_responses.go | 12 +- 3 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 backend/internal/handler/gateway_handler_claude_code_fallback_test.go diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 7a985045967d..6a5fd0160fd7 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -101,8 +101,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) - // Claude Code only restriction - if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly { + // Claude Code only restriction: /v1/chat/completions is never a Claude Code + // endpoint. With a fallback group the request continues and account selection + // (checkClaudeCodeRestriction) schedules it in the fallback group; without one + // it is rejected here. + if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly && apiKey.Group.FallbackGroupID == nil { h.chatCompletionsErrorResponse(c, http.StatusForbidden, "permission_error", "This group is restricted to Claude Code clients (/v1/messages only)") return diff --git a/backend/internal/handler/gateway_handler_claude_code_fallback_test.go b/backend/internal/handler/gateway_handler_claude_code_fallback_test.go new file mode 100644 index 000000000000..ae15cd887d80 --- /dev/null +++ b/backend/internal/handler/gateway_handler_claude_code_fallback_test.go @@ -0,0 +1,181 @@ +//go:build unit + +package handler + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// groupScopedSchedulerCache 按桶的分组只返回该分组的成员账号,并记录被查询过的分组。 +type groupScopedSchedulerCache struct { + *fakeSchedulerCache + mu sync.Mutex + groupIDs []int64 +} + +func (c *groupScopedSchedulerCache) GetSnapshot(_ context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) { + c.mu.Lock() + c.groupIDs = append(c.groupIDs, bucket.GroupID) + c.mu.Unlock() + var members []*service.Account + for _, account := range c.accounts { + for _, ag := range account.AccountGroups { + if ag.GroupID == bucket.GroupID { + members = append(members, account) + break + } + } + } + return members, true, nil +} + +func (c *groupScopedSchedulerCache) queriedGroupIDs() []int64 { + c.mu.Lock() + defer c.mu.Unlock() + return append([]int64(nil), c.groupIDs...) +} + +type groupMapRepo struct { + *fakeGroupRepo + groups map[int64]*service.Group +} + +func (r *groupMapRepo) GetByID(_ context.Context, id int64) (*service.Group, error) { + if group, ok := r.groups[id]; ok { + return group, nil + } + return nil, service.ErrGroupNotFound +} + +func (r *groupMapRepo) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) { + return r.GetByID(ctx, id) +} + +func TestGatewayOpenAICompatibleHandlersClaudeCodeOnlyFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + primaryGroupID = int64(9300) + fallbackGroupID = int64(9301) + primaryAccountID = int64(9310) + fallbackAccountID = int64(9311) + ) + + // 账号不带 api_key:转发在取令牌时失败,请求不会触达上游,断言只看选号结果。 + newAccount := func(id, groupID int64) *service.Account { + return &service.Account{ + ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Status: service.StatusActive, Schedulable: true, Concurrency: 1, + AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}}, + } + } + + endpoints := []struct { + name string + path string + body string + call func(*GatewayHandler, *gin.Context) + }{ + { + name: "responses", path: "/v1/responses", + body: `{"model":"claude-sonnet-4-5","input":"hello","stream":false}`, + call: (*GatewayHandler).Responses, + }, + { + name: "chat completions", path: "/v1/chat/completions", + body: `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}],"stream":false}`, + call: (*GatewayHandler).ChatCompletions, + }, + } + + for _, ep := range endpoints { + for _, tc := range []struct { + name string + hasFallback bool + }{ + {name: "with fallback group", hasFallback: true}, + {name: "without fallback group", hasFallback: false}, + } { + t.Run(ep.name+"/"+tc.name, func(t *testing.T) { + primary := &service.Group{ + ID: primaryGroupID, Hydrated: true, Platform: service.PlatformAnthropic, + Status: service.StatusActive, ClaudeCodeOnly: true, + } + if tc.hasFallback { + fallbackID := fallbackGroupID + primary.FallbackGroupID = &fallbackID + } + fallback := &service.Group{ + ID: fallbackGroupID, Hydrated: true, Platform: service.PlatformAnthropic, + Status: service.StatusActive, + } + + schedulerCache := &groupScopedSchedulerCache{fakeSchedulerCache: &fakeSchedulerCache{accounts: []*service.Account{ + newAccount(primaryAccountID, primaryGroupID), + newAccount(fallbackAccountID, fallbackGroupID), + }}} + gatewayService := service.NewGatewayService( + nil, &groupMapRepo{fakeGroupRepo: &fakeGroupRepo{}, groups: map[int64]*service.Group{ + primaryGroupID: primary, + fallbackGroupID: fallback, + }}, nil, nil, nil, nil, nil, nil, nil, + service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) + cfg := &config.Config{RunMode: config.RunModeSimple} + billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) + t.Cleanup(billingCacheService.Stop) + h := &GatewayHandler{ + gatewayService: gatewayService, + billingCacheService: billingCacheService, + concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatClaude, 0), + maxAccountSwitches: 1, + cfg: cfg, + } + + primaryGroupIDRef := primaryGroupID + apiKey := &service.APIKey{ + ID: 9320, UserID: 9330, GroupID: &primaryGroupIDRef, Group: primary, Status: service.StatusActive, + User: &service.User{ID: 9330, Concurrency: 10, Balance: 100}, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + ctx := context.WithValue(context.Background(), ctxkey.Group, primary) + req := httptest.NewRequest(http.MethodPost, ep.path, bytes.NewBufferString(ep.body)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + c.Request = req + c.Set(string(middleware.ContextKeyAPIKey), apiKey) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10}) + + ep.call(h, c) + + selected, reachedSelection := c.Get(opsAccountIDKey) + if !tc.hasFallback { + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "This group is restricted to Claude Code clients") + require.False(t, reachedSelection, "a Claude Code only group without fallback must be rejected before account selection") + require.Empty(t, schedulerCache.queriedGroupIDs()) + return + } + require.NotContains(t, recorder.Body.String(), "restricted to Claude Code clients") + require.True(t, reachedSelection, "a Claude Code only group with fallback must reach account selection") + require.Equal(t, fallbackAccountID, selected) + queried := schedulerCache.queriedGroupIDs() + require.Contains(t, queried, fallbackGroupID) + require.NotContains(t, queried, primaryGroupID) + }) + } + } +} diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index a1708773154b..1cfb122cc010 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -105,13 +105,11 @@ func (h *GatewayHandler) Responses(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel) - // Claude Code only restriction: - // /v1/responses is never a Claude Code endpoint. - // When claude_code_only is enabled, this endpoint is rejected. - // The existing service-layer checkClaudeCodeRestriction handles degradation - // to fallback groups when the Forward path calls SelectAccountForModelWithExclusions. - // Here we just reject at handler level since /v1/responses clients can't be Claude Code. - if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly { + // Claude Code only restriction: /v1/responses is never a Claude Code + // endpoint. With a fallback group the request continues and account selection + // (checkClaudeCodeRestriction) schedules it in the fallback group; without one + // it is rejected here. + if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly && apiKey.Group.FallbackGroupID == nil { h.responsesErrorResponse(c, http.StatusForbidden, "permission_error", "This group is restricted to Claude Code clients (/v1/messages only)") return From 0da18e4a5e4ea65180fd26e30b3539650e7fe58d Mon Sep 17 00:00:00 2001 From: William Drake Date: Sun, 27 Sep 2026 11:49:25 -0400 Subject: [PATCH 10/70] feat(admin): inline quick-adjust stepper for account priority Replace the static priority number in the accounts table with a compact stepper: hover reveals -/+ buttons, clicking the value allows typing (Enter to save, Esc to cancel, arrow keys to nudge). Rapid clicks are debounced into a single priority-only PUT, failures revert the value and surface a toast, and the row is patched in place without a full reload. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../account/AccountPriorityCell.vue | 182 ++++++++++++++++++ .../__tests__/AccountPriorityCell.spec.ts | 89 +++++++++ .../src/i18n/locales/en/admin/accounts.ts | 6 + .../src/i18n/locales/zh/admin/accounts.ts | 6 + frontend/src/views/admin/AccountsView.vue | 9 +- 5 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/account/AccountPriorityCell.vue create mode 100644 frontend/src/components/account/__tests__/AccountPriorityCell.spec.ts diff --git a/frontend/src/components/account/AccountPriorityCell.vue b/frontend/src/components/account/AccountPriorityCell.vue new file mode 100644 index 000000000000..e9b84fb378e2 --- /dev/null +++ b/frontend/src/components/account/AccountPriorityCell.vue @@ -0,0 +1,182 @@ + + + diff --git a/frontend/src/components/account/__tests__/AccountPriorityCell.spec.ts b/frontend/src/components/account/__tests__/AccountPriorityCell.spec.ts new file mode 100644 index 000000000000..0656f2194e0d --- /dev/null +++ b/frontend/src/components/account/__tests__/AccountPriorityCell.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import AccountPriorityCell from '../AccountPriorityCell.vue' +import type { Account } from '@/types' +import { update } from '@/api/admin/accounts' + +vi.mock('@/api/admin/accounts', () => ({ update: vi.fn() })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) + +const account = (overrides: Partial = {}) => ({ + id: 7, name: 'Claude 1', platform: 'anthropic', type: 'oauth', priority: 3, + ...overrides, +}) as Account + +const mountCell = (value = account()) => mount(AccountPriorityCell, { props: { account: value } }) + +beforeEach(() => { + vi.useFakeTimers() + vi.mocked(update).mockReset().mockImplementation(async (id, req) => account({ id, priority: req.priority })) +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe('AccountPriorityCell', () => { + it('batches rapid +/- clicks into a single priority-only update', async () => { + const wrapper = mountCell() + await wrapper.get('[data-testid="account-priority-increment"]').trigger('click') + await wrapper.get('[data-testid="account-priority-increment"]').trigger('click') + await wrapper.get('[data-testid="account-priority-decrement"]').trigger('click') + expect(wrapper.get('[data-testid="account-priority-value"]').text()).toBe('4') + expect(update).not.toHaveBeenCalled() + + await vi.runAllTimersAsync() + await flushPromises() + + expect(update).toHaveBeenCalledTimes(1) + expect(update).toHaveBeenCalledWith(7, { priority: 4 }) + expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ id: 7, priority: 4 }) + }) + + it('does not go below 1', async () => { + const wrapper = mountCell(account({ priority: 1 })) + const dec = wrapper.get('[data-testid="account-priority-decrement"]') + expect(dec.attributes('disabled')).toBeDefined() + await dec.trigger('click') + await vi.runAllTimersAsync() + expect(update).not.toHaveBeenCalled() + }) + + it('saves a typed value on Enter and ignores unchanged input', async () => { + const wrapper = mountCell() + await wrapper.get('[data-testid="account-priority-value"]').trigger('click') + const input = wrapper.get('[data-testid="account-priority-input"]') + await input.setValue('12') + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + expect(update).toHaveBeenCalledWith(7, { priority: 12 }) + + vi.mocked(update).mockClear() + await wrapper.setProps({ account: account({ priority: 12 }) }) + await wrapper.get('[data-testid="account-priority-value"]').trigger('click') + await wrapper.get('[data-testid="account-priority-input"]').trigger('keydown', { key: 'Enter' }) + await flushPromises() + expect(update).not.toHaveBeenCalled() + }) + + it('Escape cancels typing without saving', async () => { + const wrapper = mountCell() + await wrapper.get('[data-testid="account-priority-value"]').trigger('click') + const input = wrapper.get('[data-testid="account-priority-input"]') + await input.setValue('50') + await input.trigger('keydown', { key: 'Escape' }) + await flushPromises() + expect(update).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="account-priority-value"]').text()).toBe('3') + }) + + it('reverts and emits an error when the update fails', async () => { + vi.mocked(update).mockRejectedValueOnce(new Error('boom')) + const wrapper = mountCell() + await wrapper.get('[data-testid="account-priority-increment"]').trigger('click') + await vi.runAllTimersAsync() + await flushPromises() + expect(wrapper.get('[data-testid="account-priority-value"]').text()).toBe('3') + expect(wrapper.emitted('error')).toHaveLength(1) + expect(wrapper.emitted('updated')).toBeUndefined() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 40fd7b6f0b7d..856b571e5bf5 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -96,6 +96,12 @@ export default { schedulableEnabled: 'Scheduling enabled', schedulableDisabled: 'Scheduling disabled', failedToToggleSchedulable: 'Failed to toggle scheduling status', + priorityQuick: { + raise: 'Raise priority (value -1)', + lower: 'Lower priority (value +1)', + editHint: 'Click to type a value; lower is used first', + failed: 'Failed to update priority' + }, groupCountTotal: '{count} groups total', platforms: { anthropic: 'Anthropic', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index bc374be2939f..ba3b6becab51 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -94,6 +94,12 @@ export default { schedulableEnabled: '调度已开启', schedulableDisabled: '调度已关闭', failedToToggleSchedulable: '切换调度状态失败', + priorityQuick: { + raise: '提高优先级(数值 -1)', + lower: '降低优先级(数值 +1)', + editHint: '点击直接输入;数值越小越优先', + failed: '更新优先级失败' + }, groupCountTotal: '共 {count} 个分组', columns: { name: '名称', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 5df2c8afaab5..2502f2db8cfa 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -378,8 +378,12 @@ @probe="handleProbeUpstreamBilling(row)" /> - +

-
- + diff --git a/frontend/src/components/account/ClaudeResetCreditsCell.vue b/frontend/src/components/account/ClaudeResetCreditsCell.vue index f462b8e7203d..248b630745e9 100644 --- a/frontend/src/components/account/ClaudeResetCreditsCell.vue +++ b/frontend/src/components/account/ClaudeResetCreditsCell.vue @@ -1,28 +1,66 @@ diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue index 0e0c9d339ad3..601193ad4a9e 100644 --- a/frontend/src/components/payment/AmountInput.vue +++ b/frontend/src/components/payment/AmountInput.vue @@ -5,20 +5,36 @@ -
+
@@ -48,16 +64,24 @@ diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue index 601193ad4a9e..a16029e94bf2 100644 --- a/frontend/src/components/payment/AmountInput.vue +++ b/frontend/src/components/payment/AmountInput.vue @@ -5,7 +5,7 @@ -
+
@@ -65,23 +74,30 @@ import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import type { RechargeBonusTier } from '@/types/payment' -import { formatRechargeBonusNumber, quoteRechargeBonus } from '@/utils/rechargeBonus' +import { formatRechargeBonusNumber, quoteRechargeBonus, type RechargeBonusMode } from '@/utils/rechargeBonus' +import { formatPaymentAmount } from './currency' const props = withDefaults(defineProps<{ amounts?: number[] modelValue: number | null min?: number max?: number - /** 充值赠送阶梯(按 min_amount 升序);为空时不显示角标与到账行 */ + /** 充值优惠阶梯(按 min_amount 升序);为空时不显示价签与第二行 */ bonusTiers?: RechargeBonusTier[] + /** 阶梯模式:bonus 赠金 / discount 折扣 */ + bonusMode?: RechargeBonusMode /** 充值倍率(1 支付币种 = multiplier USD),用于计算到账金额 */ multiplier?: number + /** 支付币种(折扣模式第二行实付金额的币种与精度) */ + currency?: string }>(), { amounts: () => [10, 20, 50, 100, 200, 500, 1000, 2000, 5000], min: 0, max: 0, bonusTiers: () => [], + bonusMode: 'bonus', multiplier: 1, + currency: undefined, }) const emit = defineEmits<{ @@ -97,10 +113,37 @@ const filteredAmounts = computed(() => props.amounts.filter((a) => (props.min <= 0 || a >= props.min) && (props.max <= 0 || a <= props.max)) ) -const showCredited = computed(() => props.bonusTiers.length > 0) +const showSecondLine = computed(() => props.bonusTiers.length > 0) + +function currencyDigits(): number { + if (!props.currency) return 2 + try { + return new Intl.NumberFormat(undefined, { style: 'currency', currency: props.currency }).resolvedOptions().maximumFractionDigits ?? 2 + } catch { + return 2 + } +} function quoteFor(amt: number) { - return quoteRechargeBonus(props.bonusTiers, amt, props.multiplier) + return quoteRechargeBonus(props.bonusTiers, amt, { + multiplier: props.multiplier, + mode: props.bonusMode, + currencyDigits: currencyDigits(), + }) +} + +// 价签文案:赠金「+20%」,折扣「20% OFF」 +function badgeText(amt: number): string { + const percent = formatRechargeBonusNumber(quoteFor(amt).percent) + return props.bonusMode === 'discount' ? `${percent}% OFF` : `+${percent}%` +} + +function secondLine(amt: number): string { + const quote = quoteFor(amt) + if (props.bonusMode === 'discount') { + return t('payment.rechargeBonus.payShort', { amount: formatPaymentAmount(quote.payBase, props.currency) }) + } + return t('payment.rechargeBonus.creditedShort', { amount: '$' + quote.credited.toFixed(2) }) } const placeholderText = computed(() => { diff --git a/frontend/src/components/payment/__tests__/AmountInput.spec.ts b/frontend/src/components/payment/__tests__/AmountInput.spec.ts index ec569499a245..44e6144fd1bb 100644 --- a/frontend/src/components/payment/__tests__/AmountInput.spec.ts +++ b/frontend/src/components/payment/__tests__/AmountInput.spec.ts @@ -47,13 +47,13 @@ describe('recharge bonus hints on quick amounts', () => { { min_amount: 500, bonus_percent: 30 }, ] - it('renders no badge or credited line when no tiers are configured', () => { + it('renders no badge or second line when no tiers are configured', () => { const wrapper = mount(AmountInput, { props: { modelValue: null, amounts: [50, 100, 500] } }) expect(wrapper.find('[data-testid="quick-amount-bonus-badge"]').exists()).toBe(false) expect(wrapper.find('[data-testid="quick-amount-credited"]').exists()).toBe(false) }) - it('shows a badge only on amounts that hit a tier and credited totals on every button', () => { + it('bonus mode: price tag only on amounts that hit a tier, credited totals on every button', () => { const wrapper = mount(AmountInput, { props: { modelValue: null, amounts: [50, 100, 500], bonusTiers: tiers } }) const below = wrapper.get('[data-testid="quick-amount-50"]') const first = wrapper.get('[data-testid="quick-amount-100"]') @@ -67,7 +67,7 @@ describe('recharge bonus hints on quick amounts', () => { expect(second.get('[data-testid="quick-amount-credited"]').text()).toContain('$650.00') }) - it('matches tiers by the entered amount but credits by the multiplier', () => { + it('bonus mode: matches tiers by the entered amount but credits by the multiplier', () => { const wrapper = mount(AmountInput, { props: { modelValue: null, amounts: [1000], bonusTiers: tiers, multiplier: 0.14 }, }) @@ -76,4 +76,17 @@ describe('recharge bonus hints on quick amounts', () => { // 1000 × 0.14 = 140 base, +30% = 182 expect(button.get('[data-testid="quick-amount-credited"]').text()).toContain('$182.00') }) + + it('discount mode: tag reads N% OFF and the second line shows the discounted payment', () => { + const wrapper = mount(AmountInput, { + props: { modelValue: null, amounts: [50, 500], bonusTiers: tiers, bonusMode: 'discount', currency: 'USD' }, + }) + const below = wrapper.get('[data-testid="quick-amount-50"]') + const hit = wrapper.get('[data-testid="quick-amount-500"]') + expect(below.find('[data-testid="quick-amount-bonus-badge"]').exists()).toBe(false) + expect(below.get('[data-testid="quick-amount-credited"]').text()).toContain('50.00') + expect(hit.get('[data-testid="quick-amount-bonus-badge"]').text()).toBe('30% OFF') + // 500 × (1 − 30%) = 350 + expect(hit.get('[data-testid="quick-amount-credited"]').text()).toContain('350.00') + }) }) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index cc09fb78c4ce..a3075fc6014e 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -748,21 +748,30 @@ export default { rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee', rechargeFeePreview: 'Preview: Recharge 100, fee {fee}', rechargeBonus: { - label: 'Recharge Bonus Tiers', - hint: 'Balance top-ups match a tier by the amount the user enters (highest threshold not above it) and receive that percentage on top of the credited base. Leave empty for no bonus. Subscriptions are not affected.', + label: 'Recharge Promotion Tiers', + hint: 'Balance top-ups match a tier by the amount the user enters (highest threshold not above it). Leave empty for no promotion. Subscriptions are not affected.', + modeLabel: 'Promotion type', + modeBonus: 'Bonus', + modeDiscount: 'Discount OFF', + modeBonusHint: 'Bonus: the payment stays the same and the credited base (amount × multiplier) gets the percentage added on top.', + modeDiscountHint: 'Discount: the credit stays the same (amount × multiplier) and the payment is reduced by the percentage; must be below 100.', addTier: 'Add Tier', - empty: 'No bonus tiers configured; top-ups receive no bonus.', + empty: 'No promotion tiers configured; top-ups are credited at face value.', minAmountLabel: 'Amount ≥', percentLabel: 'Bonus', + percentLabelDiscount: 'Discount', removeTier: 'Remove tier', invalidMinAmount: 'Amount must be a number ≥ 0 with at most 2 decimals', invalidPercent: 'Percent must be between 0 and 1000 with at most 2 decimals', + invalidDiscountPercent: 'Discount percent must be below 100', duplicateMinAmount: 'This threshold already exists', incompleteRow: 'Fill in both amount and percent for this tier to take effect', previewTitle: 'Range preview', previewRange: '{from} ~ {to}: {percent}% bonus', previewRangeNone: '{from} ~ {to}: no bonus', + previewRangeDiscount: '{from} ~ {to}: {percent}% OFF', previewOpen: '≥ {from}: {percent}% bonus', + previewOpenDiscount: '≥ {from}: {percent}% OFF', previewOpenNone: '≥ {from}: no bonus', noticeLabel: 'Recharge Bonus Notice', noticeHint: 'Markdown supported. Shown at the top of the amount picker on the recharge page; leave empty to hide.', diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index 27aaa9b31406..0888751a0039 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -293,8 +293,10 @@ export default { creditedBalance: 'Credited Balance', rechargeBonus: { creditedShort: 'Get {amount}', + payShort: 'Pay {amount}', amountLabel: 'Bonus', amountLabelWithPercent: 'Bonus (+{percent}%)', + discountLabelWithPercent: 'Discount ({percent}% OFF)', }, quickAmounts: 'Quick Amounts', customAmount: 'Custom Amount', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 71ea637aaeb4..14317bff8907 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -743,21 +743,30 @@ export default { rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费', rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元', rechargeBonus: { - label: '充值赠送阶梯', - hint: '余额充值按用户输入的支付金额命中档位(取不超过该金额的最大档),在到账基数上额外赠送对应百分比;不配置则不赠送。订阅订单不参与。', + label: '充值优惠阶梯', + hint: '余额充值按用户输入的金额命中档位(取不超过该金额的最大档);不配置则无优惠。订阅订单不参与。', + modeLabel: '优惠方式', + modeBonus: '赠金', + modeDiscount: '折扣 OFF', + modeBonusHint: '赠金:实付不变,在到账基数(输入金额 × 充值倍率)之上额外赠送对应百分比的余额。', + modeDiscountHint: '折扣:到账不变(输入金额 × 充值倍率),实付金额按对应百分比打折;百分比必须小于 100。', addTier: '添加档位', - empty: '尚未配置赠送档位,充值不赠送额度。', - minAmountLabel: '支付金额 ≥', + empty: '尚未配置优惠档位,充值按原价到账。', + minAmountLabel: '充值金额 ≥', percentLabel: '赠送', + percentLabelDiscount: '优惠', removeTier: '删除档位', invalidMinAmount: '金额需为 ≥ 0 且最多两位小数的数字', invalidPercent: '百分比需在 0 ~ 1000 之间,最多两位小数', + invalidDiscountPercent: '折扣模式下百分比必须小于 100', duplicateMinAmount: '该金额档位已存在', incompleteRow: '金额与百分比都填写后该档位才会生效', previewTitle: '区间预览', previewRange: '{from} ~ {to}:赠送 {percent}%', previewRangeNone: '{from} ~ {to}:不赠送', + previewRangeDiscount: '{from} ~ {to}:{percent}% OFF', previewOpen: '≥ {from}:赠送 {percent}%', + previewOpenDiscount: '≥ {from}:{percent}% OFF', previewOpenNone: '≥ {from}:不赠送', noticeLabel: '充值赠送活动文案', noticeHint: '支持 Markdown,展示在充值页金额选择区顶部;留空则不展示。', diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index ee37c5a48356..2c6152473154 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -317,8 +317,10 @@ export default { creditedBalance: '到账余额', rechargeBonus: { creditedShort: '到账 {amount}', + payShort: '实付 {amount}', amountLabel: '赠送额度', amountLabelWithPercent: '赠送额度 (+{percent}%)', + discountLabelWithPercent: '优惠 ({percent}% OFF)', }, quickAmounts: '快捷金额', customAmount: '自定义金额', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 68b12d648b71..6a4103098812 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -79,6 +79,8 @@ export interface CheckoutInfoResponse { recharge_fee_rate: number /** 充值赠送阶梯(按 min_amount 升序);缺失/空数组 = 不赠送 */ recharge_bonus_tiers?: RechargeBonusTier[] + /** 阶梯模式:bonus 赠金 / discount 折扣;缺失按 bonus */ + recharge_bonus_mode?: string /** 充值页金额区顶部的 Markdown 活动文案;空 = 不展示 */ recharge_bonus_notice?: string help_text: string diff --git a/frontend/src/utils/__tests__/rechargeBonus.spec.ts b/frontend/src/utils/__tests__/rechargeBonus.spec.ts index a9d5c9e082dc..25561b50ec2c 100644 --- a/frontend/src/utils/__tests__/rechargeBonus.spec.ts +++ b/frontend/src/utils/__tests__/rechargeBonus.spec.ts @@ -5,7 +5,9 @@ import { describeRechargeBonusIntervals, formatRechargeBonusNumber, isDuplicateRechargeBonusMinAmount, + isRechargeBonusPercentValidForMode, matchRechargeBonusTier, + normalizeRechargeBonusMode, normalizeRechargeBonusTiers, quoteRechargeBonus, sanitizeRechargeBonusTiersForSubmit, @@ -90,6 +92,34 @@ describe('rechargeBonus helpers', () => { expect(quoteRechargeBonus([], 100)).toMatchObject({ percent: 0, bonus: 0, credited: 100 }) }) + it('quotes discount mode: credit stays, pay base shrinks, free part recorded as bonus', () => { + expect(quoteRechargeBonus(tiers, 500, { mode: 'discount' })).toMatchObject({ + mode: 'discount', percent: 30, payBase: 350, base: 500, bonus: 150, credited: 500, + }) + // 倍率 0.14:1000 CNY 到账 140 USD,35% off 实付 650 CNY,免费部分 140 − 91 = 49 USD + expect(quoteRechargeBonus(tiers, 1000, { mode: 'discount', multiplier: 0.14 })).toMatchObject({ + percent: 35, payBase: 650, base: 140, bonus: 49, credited: 140, + }) + // 币种精度:JPY 无小数 + expect(quoteRechargeBonus([{ min_amount: 1, bonus_percent: 15 }], 101, { mode: 'discount' }).payBase).toBe(85.85) + expect(quoteRechargeBonus([{ min_amount: 1, bonus_percent: 15 }], 101, { mode: 'discount', currencyDigits: 0 }).payBase).toBe(86) + // 折扣 ≥ 100% 视为无优惠(fail-safe) + expect(quoteRechargeBonus([{ min_amount: 1, bonus_percent: 100 }], 100, { mode: 'discount' })).toMatchObject({ percent: 0, payBase: 100, credited: 100 }) + // 未命中 + expect(quoteRechargeBonus(tiers, 50, { mode: 'discount' })).toMatchObject({ percent: 0, payBase: 50, credited: 50, bonus: 0 }) + }) + + it('normalizes mode and validates percent per mode', () => { + expect(normalizeRechargeBonusMode('discount')).toBe('discount') + expect(normalizeRechargeBonusMode(' Discount ')).toBe('discount') + expect(normalizeRechargeBonusMode('bonus')).toBe('bonus') + expect(normalizeRechargeBonusMode(undefined)).toBe('bonus') + expect(normalizeRechargeBonusMode('junk')).toBe('bonus') + expect(isRechargeBonusPercentValidForMode(100, 'bonus')).toBe(true) + expect(isRechargeBonusPercentValidForMode(100, 'discount')).toBe(false) + expect(isRechargeBonusPercentValidForMode(99.99, 'discount')).toBe(true) + }) + it('describes intervals with an implicit no-bonus head segment', () => { expect(describeRechargeBonusIntervals(tiers)).toEqual([ { from: 0, to: 100, percent: 0 }, diff --git a/frontend/src/utils/rechargeBonus.ts b/frontend/src/utils/rechargeBonus.ts index c268cbc1361e..25f1b97fb638 100644 --- a/frontend/src/utils/rechargeBonus.ts +++ b/frontend/src/utils/rechargeBonus.ts @@ -2,6 +2,8 @@ import type { RechargeBonusTier } from '@/types/payment' export type { RechargeBonusTier } +export type RechargeBonusMode = 'bonus' | 'discount' + export const MAX_RECHARGE_BONUS_TIERS = 20 export const MAX_RECHARGE_BONUS_PERCENT = 1000 @@ -21,26 +23,34 @@ export interface RechargeBonusInterval { } export interface RechargeBonusQuote { - /** 命中档位的百分比;未命中或赠送为 0 时为 0 */ + mode: RechargeBonusMode + /** 命中档位的百分比;未命中或未产生优惠时为 0 */ percent: number - /** 到账基数(支付金额 × 倍率,USD) */ + /** 网关收款基数(支付币种,不含手续费);赠金模式 = 输入金额,折扣模式 = 折后金额 */ + payBase: number + /** 到账基数(输入金额 × 倍率,USD),不含赠送 */ base: number - /** 赠送额度(USD) */ + /** 免费额度(USD):赠金模式为额外赠送,折扣模式为未付费却到账的部分 */ bonus: number - /** 到账总额 = base + bonus */ + /** 到账总额(USD) */ credited: number tier: RechargeBonusTier | null } -export function roundRechargeAmount(value: number): number { +export function roundRechargeAmount(value: number, digits = 2): number { if (!Number.isFinite(value)) return 0 - return Math.round((value + Number.EPSILON) * 100) / 100 + const factor = 10 ** digits + return Math.round((value + Number.EPSILON) * factor) / factor } function hasAtMostTwoDecimals(value: number): boolean { return Math.abs(roundRechargeAmount(value) - value) < AMOUNT_EPSILON } +export function normalizeRechargeBonusMode(raw: unknown): RechargeBonusMode { + return String(raw ?? '').trim().toLowerCase() === 'discount' ? 'discount' : 'bonus' +} + export function isRechargeBonusMinAmountValid(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 && hasAtMostTwoDecimals(value) } @@ -55,6 +65,12 @@ export function isRechargeBonusPercentValid(value: unknown): value is number { ) } +// 折扣模式下百分比必须 < 100,否则实付为 0 或负数;与后端 ValidateRechargeBonusTiersForMode 一致。 +export function isRechargeBonusPercentValidForMode(value: unknown, mode: RechargeBonusMode): value is number { + if (!isRechargeBonusPercentValid(value)) return false + return mode !== 'discount' || value < 100 +} + function minAmountKey(value: number): string { return roundRechargeAmount(value).toFixed(2) } @@ -128,27 +144,49 @@ export function calculateRechargeBonus(baseCredited: number, percent: number): n return roundRechargeAmount((baseCredited * percent) / 100) } -// 充值页报价:阈值按支付金额比较,赠送按到账基数(支付金额 × 倍率)计算。 +export interface RechargeBonusQuoteOptions { + multiplier?: number + mode?: RechargeBonusMode + /** 支付币种小数位,折扣模式实付基数按此精度四舍五入 */ + currencyDigits?: number +} + +// 充值页报价:阈值按支付金额比较;赠金模式按到账基数加赠送,折扣模式按百分比减实付。 +// 与后端 quoteRechargeBonus 一致(含折扣 ≥ 100% 的 fail-safe)。 export function quoteRechargeBonus( tiers: RechargeBonusTier[], paymentAmount: number, - multiplier = 1, + options: RechargeBonusQuoteOptions | number = {}, ): RechargeBonusQuote { + const opts: RechargeBonusQuoteOptions = typeof options === 'number' ? { multiplier: options } : options + const mode = opts.mode ?? 'bonus' const amount = Number.isFinite(paymentAmount) && paymentAmount > 0 ? paymentAmount : 0 - const rate = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1 + const rate = typeof opts.multiplier === 'number' && Number.isFinite(opts.multiplier) && opts.multiplier > 0 ? opts.multiplier : 1 + const digits = typeof opts.currencyDigits === 'number' && Number.isInteger(opts.currencyDigits) && opts.currencyDigits >= 0 ? opts.currencyDigits : 2 const base = roundRechargeAmount(amount * rate) + const quote: RechargeBonusQuote = { mode, percent: 0, payBase: amount, base, bonus: 0, credited: base, tier: null } const tier = matchRechargeBonusTier(tiers, amount) - const bonus = tier ? calculateRechargeBonus(base, tier.bonus_percent) : 0 - return { - percent: bonus > 0 && tier ? tier.bonus_percent : 0, - base, - bonus, - credited: roundRechargeAmount(base + bonus), - tier, + if (!tier || tier.bonus_percent <= 0) return quote + quote.tier = tier + if (mode === 'discount') { + if (tier.bonus_percent >= 100) return quote + const payBase = roundRechargeAmount((amount * (100 - tier.bonus_percent)) / 100, digits) + if (payBase <= 0 || payBase >= amount) return quote + const paidCredit = roundRechargeAmount(payBase * rate) + quote.payBase = payBase + quote.bonus = Math.max(0, roundRechargeAmount(base - paidCredit)) + quote.percent = tier.bonus_percent + return quote } + const bonus = calculateRechargeBonus(base, tier.bonus_percent) + if (bonus <= 0) return quote + quote.bonus = bonus + quote.credited = roundRechargeAmount(base + bonus) + quote.percent = tier.bonus_percent + return quote } -// 区间预览:把升序阈值列表展开为 [from, to) 区间;首档阈值 > 0 时补一段「不赠送」。 +// 区间预览:把升序阈值列表展开为 [from, to) 区间;首档阈值 > 0 时补一段「无优惠」。 export function describeRechargeBonusIntervals(tiers: RechargeBonusTier[]): RechargeBonusInterval[] { const sorted = [...tiers].sort((a, b) => a.min_amount - b.min_amount) const out: RechargeBonusInterval[] = [] diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index d1cce74c28cd..793867a3e222 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8369,27 +8369,6 @@
- - -
- - -

- {{ t("admin.settings.payment.rechargeBonus.noticeHint") }} -

-
+ + + ({ payment_subscription_usd_to_cny_rate: 0, payment_recharge_fee_rate: 0, payment_recharge_bonus_tiers: [], + payment_recharge_bonus_mode: "bonus", payment_recharge_bonus_notice: "", payment_enabled_types: [], payment_help_image_url: "", @@ -11108,6 +11099,9 @@ async function loadSettings() { form.payment_recharge_bonus_tiers = normalizeRechargeBonusTiers( settings.payment_recharge_bonus_tiers, ); + form.payment_recharge_bonus_mode = normalizeRechargeBonusMode( + settings.payment_recharge_bonus_mode, + ); form.payment_recharge_bonus_notice = settings.payment_recharge_bonus_notice || ""; form.default_platform_quotas = normalizePlatformQuotasMap(settings.default_platform_quotas); form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap( @@ -11719,6 +11713,7 @@ async function saveSettings() { payment_recharge_bonus_tiers: sanitizeRechargeBonusTiersForSubmit( form.payment_recharge_bonus_tiers, ), + payment_recharge_bonus_mode: form.payment_recharge_bonus_mode, payment_recharge_bonus_notice: form.payment_recharge_bonus_notice, payment_enabled_types: form.payment_enabled_types, payment_load_balance_strategy: form.payment_load_balance_strategy, diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 398a9edd804f..415dab9ab1cb 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -489,6 +489,7 @@ const baseSettingsResponse = { payment_subscription_usd_to_cny_rate: 0, payment_recharge_fee_rate: 0, payment_recharge_bonus_tiers: [], + payment_recharge_bonus_mode: "bonus", payment_recharge_bonus_notice: "", payment_load_balance_strategy: "round-robin", payment_product_name_prefix: "", @@ -1102,6 +1103,19 @@ describe("admin SettingsView payment visible method controls", () => { rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); expect(rows[2]!.find('[data-testid="recharge-bonus-tier-error"]').exists()).toBe(false); + // 切到折扣模式:百分比 ≥ 100 行内报错,改回 < 100 后消失 + await wrapper.get('[data-testid="recharge-bonus-mode-discount"]').trigger("click"); + rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); + await rows[2]!.get('[data-testid="recharge-bonus-tier-percent-input"]').setValue("100"); + rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); + expect(rows[2]!.get('[data-testid="recharge-bonus-tier-error"]').text()).toContain("invalidDiscountPercent"); + await rows[2]!.get('[data-testid="recharge-bonus-tier-percent-input"]').setValue("25"); + rows = wrapper.findAll('[data-testid="recharge-bonus-tier-row"]'); + expect(rows[2]!.find('[data-testid="recharge-bonus-tier-error"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="recharge-bonus-tier-preview"]').text()).toContain( + "admin.settings.payment.rechargeBonus.previewRangeDiscount", + ); + const notice = wrapper.get('[data-testid="recharge-bonus-notice-input"]'); expect((notice.element as HTMLTextAreaElement).value).toBe("满 100 送 20%"); await notice.setValue("**新活动**"); @@ -1116,6 +1130,7 @@ describe("admin SettingsView payment visible method controls", () => { { min_amount: 500, bonus_percent: 30 }, { min_amount: 1000, bonus_percent: 25 }, ], + payment_recharge_bonus_mode: "discount", payment_recharge_bonus_notice: "**新活动**", }), ); diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 2924be0593a9..4727e1602ba1 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -63,7 +63,9 @@ :min="globalMinAmount" :max="globalMaxAmount" :bonus-tiers="rechargeBonusTiers" + :bonus-mode="rechargeBonusMode" :multiplier="balanceRechargeMultiplier" + :currency="selectedCurrency" />

{{ amountError }}

@@ -78,23 +80,27 @@
{{ t('payment.paymentAmount') }} - {{ formatSelectedPaymentAmount(validAmount) }} + {{ formatSelectedPaymentAmount(validAmount) }} +
+
+ {{ t('payment.rechargeBonus.discountLabelWithPercent', { percent: formatRechargeBonusNumber(bonusQuote.percent) }) }} + -{{ formatSelectedPaymentAmount(discountAmount) }}
{{ t('payment.fee') }} ({{ feeRate }}%) {{ formatSelectedPaymentAmount(feeAmount) }}
-
+
{{ t('payment.actualPay') }} {{ formatSelectedPaymentAmount(totalAmount) }}
-
+
{{ t('payment.rechargeBonus.amountLabelWithPercent', { percent: formatRechargeBonusNumber(bonusQuote.percent) }) }} - +${{ bonusQuote.bonus.toFixed(2) }} + +${{ bonusQuote.bonus.toFixed(2) }}
-
+
{{ t('payment.creditedBalance') }} - ${{ creditedAmount.toFixed(2) }} + ${{ creditedAmount.toFixed(2) }}

{{ t('payment.rechargeRatePreview', { currency: selectedCurrency, usd: balanceRechargeMultiplier.toFixed(2) }) }} @@ -289,7 +295,7 @@ import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiErro import { isMobileDevice } from '@/utils/device' import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel, type PeakRateFields } from '@/utils/peak-rate' import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderType } from '@/types/payment' -import { formatRechargeBonusNumber, normalizeRechargeBonusTiers, quoteRechargeBonus } from '@/utils/rechargeBonus' +import { formatRechargeBonusNumber, normalizeRechargeBonusMode, normalizeRechargeBonusTiers, quoteRechargeBonus } from '@/utils/rechargeBonus' import AppLayout from '@/components/layout/AppLayout.vue' import AmountInput from '@/components/payment/AmountInput.vue' import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' @@ -525,7 +531,7 @@ function onPaymentSettled() { const checkout = ref({ methods: {}, global_min: 0, global_max: 0, plans: [], balance_disabled: false, balance_recharge_multiplier: 1, subscription_usd_to_cny_rate: 0, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '', - recharge_bonus_tiers: [], recharge_bonus_notice: '', + recharge_bonus_tiers: [], recharge_bonus_mode: 'bonus', recharge_bonus_notice: '', }) const renderedHelpText = computed(() => DOMPurify.sanitize( @@ -571,11 +577,8 @@ const subscriptionUsdToCnyRate = computed(() => { const rate = checkout.value.subscription_usd_to_cny_rate return Number.isFinite(rate) && rate > 0 ? rate : 0 }) -// 充值赠送阶梯:阈值按输入金额命中,赠送按到账基数(输入 × 倍率)计算;与后端 resolveRechargeBonus 一致 const rechargeBonusTiers = computed(() => normalizeRechargeBonusTiers(checkout.value.recharge_bonus_tiers)) -const bonusQuote = computed(() => quoteRechargeBonus(rechargeBonusTiers.value, validAmount.value, balanceRechargeMultiplier.value)) -const creditedAmount = computed(() => bonusQuote.value.credited) -const showCreditedBalance = computed(() => balanceRechargeMultiplier.value !== 1 || bonusQuote.value.bonus > 0) +const rechargeBonusMode = computed(() => normalizeRechargeBonusMode(checkout.value.recharge_bonus_mode)) // Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+ const planGridClass = computed(() => { @@ -657,6 +660,19 @@ function formatSelectedSubscriptionPaymentAmount(value: number): string { return formatSelectedPaymentAmount(subscriptionPaymentAmountForCurrency(value, selectedCurrency.value)) } +// 充值优惠:阈值按输入金额命中;赠金模式按到账基数(输入 × 倍率)加赠送,折扣模式按百分比减实付。 +// 与后端 quoteRechargeBonus 一致;渠道限额、手续费、实付都按折后基数(payBaseAmount)计算,提交仍发送输入金额。 +const bonusQuote = computed(() => quoteRechargeBonus(rechargeBonusTiers.value, validAmount.value, { + multiplier: balanceRechargeMultiplier.value, + mode: rechargeBonusMode.value, + currencyDigits: currencyFractionDigits(selectedCurrency.value), +})) +const payBaseAmount = computed(() => bonusQuote.value.payBase) +const discountAmount = computed(() => roundPaymentAmount(validAmount.value - payBaseAmount.value, selectedCurrency.value)) +const creditedAmount = computed(() => bonusQuote.value.credited) +const showBonusRow = computed(() => bonusQuote.value.mode !== 'discount' && bonusQuote.value.bonus > 0) +const showCreditedBalance = computed(() => balanceRechargeMultiplier.value !== 1 || bonusQuote.value.percent > 0) + const methodOptions = computed(() => enabledMethods.value.map((type) => { const ml = visibleMethods.value[type] @@ -664,41 +680,42 @@ const methodOptions = computed(() => type, display_name: ml?.display_name, fee_rate: ml?.fee_rate ?? 0, - available: ml?.available !== false && amountFitsMethod(validAmount.value, type), + available: ml?.available !== false && amountFitsMethod(payBaseAmount.value, type), } }) ) const feeRate = computed(() => checkout.value?.recharge_fee_rate ?? 0) const feeAmount = computed(() => - feeRate.value > 0 && validAmount.value > 0 - ? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100 + feeRate.value > 0 && payBaseAmount.value > 0 + ? Math.ceil(((payBaseAmount.value * feeRate.value) / 100) * 100) / 100 : 0 ) const totalAmount = computed(() => - feeRate.value > 0 && validAmount.value > 0 - ? Math.round((validAmount.value + feeAmount.value) * 100) / 100 - : validAmount.value + feeRate.value > 0 && payBaseAmount.value > 0 + ? Math.round((payBaseAmount.value + feeAmount.value) * 100) / 100 + : payBaseAmount.value ) +const showActualPay = computed(() => feeRate.value > 0 || discountAmount.value > 0) const amountError = computed(() => { if (validAmount.value <= 0) return '' // No method can handle this amount - if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) { + if (!enabledMethods.value.some((m) => amountFitsMethod(payBaseAmount.value, m))) { return t('payment.amountNoMethod') } // Selected method can't handle this amount (but others can) const ml = selectedLimit.value if (ml) { - if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: formatSelectedPaymentAmount(ml.single_min) }) - if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: formatSelectedPaymentAmount(ml.single_max) }) + if (ml.single_min > 0 && payBaseAmount.value < ml.single_min) return t('payment.amountTooLow', { min: formatSelectedPaymentAmount(ml.single_min) }) + if (ml.single_max > 0 && payBaseAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: formatSelectedPaymentAmount(ml.single_max) }) } return '' }) const canSubmit = computed(() => validAmount.value > 0 - && amountFitsMethod(validAmount.value, selectedMethod.value) + && amountFitsMethod(payBaseAmount.value, selectedMethod.value) && selectedLimit.value?.available !== false ) @@ -746,7 +763,7 @@ const canSubmitSubscription = computed(() => ) // Auto-switch to first available method when current selection can't handle the amount -watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => { +watch(() => [payBaseAmount.value, selectedMethod.value] as const, ([amt, method]) => { if (amt <= 0 || amountFitsMethod(amt, method)) return const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m)) if (available) selectedMethod.value = available From 0cecf3c367524dba153a2573c09f1bade076667e Mon Sep 17 00:00:00 2001 From: William Date: Thu, 1 Oct 2026 13:01:01 -0400 Subject: [PATCH 54/70] feat(keys): support sorting API keys by group name --- backend/internal/repository/api_key_repo.go | 11 +++ .../repository/api_key_repo_sort_test.go | 78 +++++++++++++++++++ frontend/src/views/user/KeysView.vue | 2 +- .../src/views/user/__tests__/KeysView.spec.ts | 21 ++++- 4 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 backend/internal/repository/api_key_repo_sort_test.go diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index dd2d1dbc71e1..dd831283b729 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -652,6 +652,17 @@ func apiKeyListOrder(params pagination.PaginationParams) []func(*entsql.Selector sortBy := strings.ToLower(strings.TrimSpace(params.SortBy)) sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc) + if sortBy == "group" { + // Sort before pagination, keeping ungrouped keys last in either direction. + opts := []entsql.OrderTermOption{entsql.OrderNullsLast()} + tieOrder := dbent.Asc(apikey.FieldID) + if sortOrder == pagination.SortOrderDesc { + opts = append(opts, entsql.OrderDesc()) + tieOrder = dbent.Desc(apikey.FieldID) + } + return []func(*entsql.Selector){apikey.ByGroupField(group.FieldName, opts...), tieOrder} + } + var field string switch sortBy { case "name": diff --git a/backend/internal/repository/api_key_repo_sort_test.go b/backend/internal/repository/api_key_repo_sort_test.go new file mode 100644 index 000000000000..369b6a59003e --- /dev/null +++ b/backend/internal/repository/api_key_repo_sort_test.go @@ -0,0 +1,78 @@ +package repository + +import ( + "context" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestAPIKeyRepositoryListByUserIDSortByGroup(t *testing.T) { + repo, client := newAPIKeyRepoSQLite(t) + ctx := context.Background() + user := mustCreateAPIKeyRepoUser(t, ctx, client, "group-sort@test.com") + otherUser := mustCreateAPIKeyRepoUser(t, ctx, client, "other-group-sort@test.com") + createGroup := func(name string) *dbent.Group { + g, err := client.Group.Create().SetName(name).Save(ctx) + require.NoError(t, err) + return g + } + // Create groups in reverse name order so sorting by group ID cannot pass. + zulu := createGroup("Zulu") + alpha := createGroup("Alpha") + createKey := func(userID int64, name string, groupID *int64, status string) int64 { + key := &service.APIKey{ + UserID: userID, Key: "sk-" + name, Name: name, GroupID: groupID, Status: status, + } + require.NoError(t, repo.Create(ctx, key)) + return key.ID + } + zuluFirst := createKey(user.ID, "match-zulu-first", &zulu.ID, service.StatusActive) + ungroupedFirst := createKey(user.ID, "match-ungrouped-first", nil, service.StatusActive) + alphaFirst := createKey(user.ID, "match-alpha-first", &alpha.ID, service.StatusActive) + zuluSecond := createKey(user.ID, "match-zulu-second", &zulu.ID, service.StatusActive) + alphaSecond := createKey(user.ID, "match-alpha-second", &alpha.ID, service.StatusActive) + ungroupedSecond := createKey(user.ID, "match-ungrouped-second", nil, service.StatusActive) + createKey(otherUser.ID, "match-other-user", &alpha.ID, service.StatusActive) + createKey(user.ID, "excluded-search", &alpha.ID, service.StatusActive) + createKey(user.ID, "match-inactive", &alpha.ID, service.StatusDisabled) + deleted := createKey(user.ID, "match-deleted", &alpha.ID, service.StatusActive) + require.NoError(t, repo.Delete(ctx, deleted)) + + ungroupedID := int64(0) + for _, tc := range []struct { + name string + order string + groupID *int64 + want []int64 + }{ + {"ascending", "asc", nil, []int64{alphaFirst, alphaSecond, zuluFirst, zuluSecond, ungroupedFirst, ungroupedSecond}}, + {"descending", "desc", nil, []int64{zuluSecond, zuluFirst, alphaSecond, alphaFirst, ungroupedSecond, ungroupedFirst}}, + {"group filter", "asc", &alpha.ID, []int64{alphaFirst, alphaSecond}}, + {"ungrouped filter", "desc", &ungroupedID, []int64{ungroupedSecond, ungroupedFirst}}, + } { + t.Run(tc.name, func(t *testing.T) { + var got []int64 + const pageSize = 3 + pages := (len(tc.want) + pageSize - 1) / pageSize + for page := 1; page <= pages; page++ { + keys, result, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{ + Page: page, PageSize: pageSize, SortBy: "group", SortOrder: tc.order, + }, service.APIKeyListFilters{Search: "match", Status: service.StatusActive, GroupID: tc.groupID}) + require.NoError(t, err) + require.EqualValues(t, len(tc.want), result.Total) + require.Equal(t, pages, result.Pages) + for _, key := range keys { + got = append(got, key.ID) + if key.GroupID != nil { + require.NotNil(t, key.Group, "sorting must preserve group preloading") + } + } + } + require.Equal(t, tc.want, got) + }) + } +} diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 66ac097cb1b8..dd779a0a8d02 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -1266,7 +1266,7 @@ const allColumns = computed(() => [ { key: 'name', label: t('common.name'), sortable: true }, { key: 'id', label: t('keys.id'), sortable: true }, { key: 'key', label: t('keys.apiKey'), sortable: false }, - { key: 'group', label: t('keys.group'), sortable: false }, + { key: 'group', label: t('keys.group'), sortable: true }, { key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: true }, { key: 'usage', label: t('keys.usage'), sortable: false }, { key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false }, diff --git a/frontend/src/views/user/__tests__/KeysView.spec.ts b/frontend/src/views/user/__tests__/KeysView.spec.ts index d02356f10482..f8da9df1eaf3 100644 --- a/frontend/src/views/user/__tests__/KeysView.spec.ts +++ b/frontend/src/views/user/__tests__/KeysView.spec.ts @@ -210,6 +210,7 @@ const PaginationStub = { template: `

+
`, } @@ -511,7 +512,11 @@ describe('user KeysView column settings', () => { expect(currentConcurrencyColumn?.sortable).toBe(true) }) - it('keeps filters and selected page size when sorting by current concurrency', async () => { + it.each([ + { key: 'current_concurrency', order: 'asc' }, + { key: 'group', order: 'asc' }, + { key: 'group', order: 'desc' }, + ] as const)('keeps filters and resets pagination and selection when sorting $key $order', async ({ key, order }) => { getAvailableGroups.mockResolvedValue([{ id: 42, name: 'OpenAI' }]) const wrapper = await mountView() @@ -528,11 +533,19 @@ describe('user KeysView column settings', () => { await selects[1].vm.$emit('update:modelValue', 'active') await flushPromises() + await wrapper.get('[data-test="page-2"]').trigger('click') + await flushPromises() + const table = wrapper.findComponent({ name: 'DataTable' }) + table.vm.$emit('update:selectedKeys', [1]) + await nextTick() + expect(table.props('selectedKeys')).toEqual([1]) + expect(visibleColumnMeta(wrapper).find((column) => column.key === key)?.sortable).toBe(true) listKeys.mockClear() - await wrapper.get('[data-test="sort-current-concurrency"]').trigger('click') + table.vm.$emit('sort', key, order) await flushPromises() + expect(table.props('selectedKeys')).toEqual([]) expect(listKeys).toHaveBeenLastCalledWith( 1, 50, @@ -540,8 +553,8 @@ describe('user KeysView column settings', () => { search: 'target', status: 'active', group_id: 42, - sort_by: 'current_concurrency', - sort_order: 'asc', + sort_by: key, + sort_order: order, }, expect.objectContaining({ signal: expect.any(AbortSignal) }) ) From bb97ab364af010cb1720ec175af02d8303d8f74d Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:44:50 +0800 Subject: [PATCH 55/70] fix(typesafe): align System One validation with upstream schema --- README.md | 2 + README_CN.md | 2 + backend/internal/pkg/typesafe/systemone.go | 39 ++++++++++++----- .../internal/pkg/typesafe/systemone_test.go | 26 +++++++++-- .../service/gateway_systemone_test.go | 43 +++++++++++++++++++ 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9b915ca5e39c..48a6f0cdb3d7 100644 --- a/README.md +++ b/README.md @@ -839,6 +839,8 @@ Sub2API supports TypeSafe API-key accounts through Jev's native, non-streaming S Requests and successful responses retain the native System One JSON structure. This endpoint is not compatible with Chat Completions, Responses, Anthropic Messages, or streaming clients. +Question validation follows the TypeSafe OpenAPI wire schema (also used by SDK v0.5.7). `instructions` may be omitted or `null` for all question types. Noul `criteria` may be omitted or `null`; its `true`/`false` descriptions and Choice descriptions accept strings, objects, arrays, or `null`. Score `criteria` must be a non-empty array of string, object, or array descriptions; a single level is valid. SDK integer-keyed Score maps are normalized to arrays by the SDK before sending. + ```bash curl https://your-sub2api.example.com/v1/systemone \ -H 'Authorization: Bearer sk-your-sub2api-key' \ diff --git a/README_CN.md b/README_CN.md index 6c7d834dbb41..4680298f82c9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -755,6 +755,8 @@ Sub2API 支持使用 TypeSafe API Key 账户,通过 Jev 原生、非流式的 请求和成功响应保持 System One 原生 JSON 结构。该端点不兼容 Chat Completions、Responses、Anthropic Messages 或流式客户端。 +问题校验遵循 TypeSafe OpenAPI 的线上协议 schema(SDK v0.5.7 也使用该 schema)。所有问题的 `instructions` 都可以省略或为 `null`。Noul 的 `criteria` 可以省略或为 `null`,其中 `true`/`false` 的描述和 Choice 描述支持字符串、对象、数组或 `null`。Score 的 `criteria` 必须是至少包含一档描述的数组,每档支持字符串、对象或数组;单档也合法。SDK 的整数键 Score 映射会由 SDK 在发送前转换为数组。 + ```bash curl https://your-sub2api.example.com/v1/systemone \ -H 'Authorization: Bearer sk-your-sub2api-key' \ diff --git a/backend/internal/pkg/typesafe/systemone.go b/backend/internal/pkg/typesafe/systemone.go index 0222289c138c..898a0e5c246f 100644 --- a/backend/internal/pkg/typesafe/systemone.go +++ b/backend/internal/pkg/typesafe/systemone.go @@ -67,34 +67,45 @@ func validateQuestion(id string, raw json.RawMessage) error { if err != nil { return fmt.Errorf("question %q: %w", id, err) } - if err := validateStringObjectOrArray(question.Instructions, "instructions"); err != nil { + // Mirror the wire schema generated from https://api.typesafe.ai/openapi.json + // in TypeSafe SDK v0.5.7: instructions are optional and nullable for all types. + if err := validateOptionalDescription(question.Instructions, "instructions"); err != nil { return fmt.Errorf("question %q: %w", id, err) } switch typ { case "noul": criteria := bytes.TrimSpace(question.Criteria) - if len(criteria) > 0 && criteria[0] != '{' { + if len(criteria) == 0 || bytes.Equal(criteria, []byte("null")) { + return nil + } + var descriptions map[string]json.RawMessage + if criteria[0] != '{' || json.Unmarshal(criteria, &descriptions) != nil { return fmt.Errorf("question %q: noul criteria must be an object", id) } + for _, outcome := range []string{"true", "false"} { + if err := validateOptionalDescription(descriptions[outcome], "noul criteria "+outcome); err != nil { + return fmt.Errorf("question %q: %w", id, err) + } + } case "choice": var criteria map[string]json.RawMessage - if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) == 0 { - return fmt.Errorf("question %q: choice criteria must be a non-empty object", id) + if json.Unmarshal(question.Criteria, &criteria) != nil || criteria == nil { + return fmt.Errorf("question %q: choice criteria must be an object", id) } for _, value := range criteria { - if string(value) != "null" && !rawString(value) { - return fmt.Errorf("question %q: choice criteria values must be strings or null", id) + if err := validateOptionalDescription(value, "choice criteria value"); err != nil { + return fmt.Errorf("question %q: %w", id, err) } } case "score": var criteria []json.RawMessage - if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) < 2 { - return fmt.Errorf("question %q: score criteria must contain at least two strings", id) + if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) == 0 { + return fmt.Errorf("question %q: score criteria must contain at least one level", id) } for _, value := range criteria { - if !rawString(value) { - return fmt.Errorf("question %q: score criteria must contain only strings", id) + if err := validateStringObjectOrArray(value, "score criteria value"); err != nil { + return fmt.Errorf("question %q: %w", id, err) } } default: @@ -103,6 +114,14 @@ func validateQuestion(id string, raw json.RawMessage) error { return nil } +func validateOptionalDescription(raw json.RawMessage, name string) error { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil + } + return validateStringObjectOrArray(raw, name) +} + func requiredString(raw json.RawMessage, name string) (string, error) { var value string if len(raw) == 0 || json.Unmarshal(raw, &value) != nil || strings.TrimSpace(value) == "" { diff --git a/backend/internal/pkg/typesafe/systemone_test.go b/backend/internal/pkg/typesafe/systemone_test.go index a5056bd358a5..c4233a97f0ca 100644 --- a/backend/internal/pkg/typesafe/systemone_test.go +++ b/backend/internal/pkg/typesafe/systemone_test.go @@ -15,6 +15,15 @@ func TestValidateSystemOneRequestValidQuestionTypes(t *testing.T) { {"noul string state", `{"model":"jev-latest","state":"sample","questions":{"safety":{"type":"noul","instructions":"Evaluate safety","criteria":{"safe":"No harm"}}}}`}, {"choice object state", `{"model":"jev-latest","state":{"text":"sample"},"questions":{"label":{"type":"choice","instructions":{"task":"Classify"},"criteria":{"safe":"Allowed","unsafe":null}}},"stream":false}`}, {"score array state", `{"model":"jev-latest","state":["sample"],"questions":{"quality":{"type":"score","instructions":["Rate quality"],"criteria":["poor","good"]}}}`}, + {"noul omitted instructions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul"}}}`}, + {"noul nullable instructions and criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":null,"criteria":null}}}`}, + {"noul structured descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","criteria":{"true":{"examples":["yes",null]},"false":["no",null],"extension":42}}}}`}, + {"choice structured descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","instructions":null,"criteria":{"a":{"description":"A","extra":null},"b":["B",null],"c":null}}}}`}, + {"choice empty criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{}}}}`}, + {"score one level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":["only"]}}}`}, + {"score object level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","instructions":null,"criteria":[{"description":"only","extra":null}]}}}`}, + {"score array level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":[["only",null]]}}}`}, + {"native extensions preserved", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","extension":{"kept":true}}},"extension":[1,null]}`}, } { t.Run(tc.name, func(t *testing.T) { model, err := ValidateSystemOneRequest([]byte(tc.body)) @@ -40,11 +49,20 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { {"unknown question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"boolean","instructions":"x"}}}`, "unsupported type"}, {"question type with whitespace", `{"model":"jev-latest","state":"x","questions":{"q":{"type":" noul ","instructions":"x"}}}`, "unsupported type"}, {"noul criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":[]}}}`, "noul criteria"}, - {"noul criteria null", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":null}}}`, "noul criteria"}, - {"empty choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{}}}}`, "choice criteria"}, + {"noul numeric description", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","criteria":{"true":1}}}}`, "noul criteria"}, + {"noul boolean description", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","criteria":{"false":false}}}}`, "noul criteria"}, + {"missing choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice"}}}`, "choice criteria"}, + {"null choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","criteria":null}}}`, "choice criteria"}, + {"choice criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","criteria":[]}}}`, "choice criteria"}, {"choice numeric value", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{"one":1}}}}`, "choice criteria"}, - {"short score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","instructions":"x","criteria":["only"]}}}`, "score criteria"}, - {"non-string score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","instructions":"x","criteria":["low",2]}}}`, "score criteria"}, + {"missing score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score"}}}`, "score criteria"}, + {"empty score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":[]}}}`, "score criteria"}, + {"null score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":null}}}`, "score criteria"}, + {"score map is not wire protocol", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":{"0":"low"}}}}`, "score criteria"}, + {"numeric score level", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":["low",2]}}}`, "score criteria"}, + {"null score level", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":[null]}}}`, "score criteria"}, + {"numeric instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":1}}}`, "instructions"}, + {"boolean instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":true,"criteria":{}}}}`, "instructions"}, {"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/backend/internal/service/gateway_systemone_test.go b/backend/internal/service/gateway_systemone_test.go index a69964aa8e7e..c7a6606b540d 100644 --- a/backend/internal/service/gateway_systemone_test.go +++ b/backend/internal/service/gateway_systemone_test.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -117,6 +118,48 @@ func TestForwardSystemOneAllowsSuccessfulResponseWithoutModel(t *testing.T) { const typesafeSystemOnePathForTest = "/v1/systemone" +func TestForwardSystemOneSchemaConformancePassthrough(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"omitted noul instructions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","extension":{"kept":true}}}}`}, + {"nullable noul fields", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":null,"criteria":null}}}`}, + {"structured noul descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","criteria":{"true":{"reason":"Yes"},"false":["No",null]}}}}`}, + {"object choice description", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{"a":{"description":"A","extra":null}}}}}`}, + {"array choice description", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","instructions":null,"criteria":{"a":["A",null],"b":null}}}}`}, + {"empty choice criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{}}}}`}, + {"one-level score", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":["only"]}}}`}, + {"object score level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","instructions":null,"criteria":[{"description":"only","extra":null}]}}}`}, + {"array score level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":[["only",null]]}}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + requestBody := []byte(tc.body) + _, err := typesafe.ValidateSystemOneRequest(requestBody) + require.NoError(t, err) + responseBody := []byte(`{"answers":{},"usage":{"input_tokens":12}}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/systemone", r.URL.Path) + require.Equal(t, "Bearer ts-mock-key", r.Header.Get("Authorization")) + got, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, requestBody, got) + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(responseBody) + require.NoError(t, err) + })) + defer server.Close() + + svc := newSystemOneTestService(&systemOneHTTPUpstream{do: server.Client().Do}) + account := &Account{ID: 12, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": server.URL, "api_key": "ts-mock-key"}} + result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, requestBody) + require.NoError(t, err) + require.Equal(t, responseBody, result.Body) + require.Equal(t, 12, result.Usage.InputTokens) + }) + } +} + func TestForwardSystemOneErrorPolicy(t *testing.T) { for _, status := range []int{400, 422, 401, 429, 529, 500, 503} { t.Run(http.StatusText(status), func(t *testing.T) { From 2d8a3868110d81c5e91119a504b7d0537a284cde Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 13:08:40 +0800 Subject: [PATCH 56/70] =?UTF-8?q?fix(typesafe):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E8=BF=81=E7=A7=BB=E3=80=81=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E3=80=81=E7=AB=AF=E7=82=B9=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 241 迁移:user_platform_quotas / composite_model_routes 的 CHECK 约束 加入 typesafe。此前设置 typesafe 默认配额会让注册时的多行配额快照整体 违约(fail-open 后新用户所有平台配额丢失),单用户配额与 Composite 路由保存 500。 - Prompt 审计新增 typesafe_systemone 协议提取(state + 各问题 instructions/ criteria/选项标签,键排序保证哈希稳定);此前提取为空,阻断与异步审计均被放行。 旧内容审核同样覆盖问题文本,且不再丢弃含 的 System One 文本。 - TypeSafe 分组及路由到 TypeSafe 的 Composite 请求访问 Messages / count_tokens / Chat Completions / Responses 时返回 404,避免以 x-api-key 打到错误上游路径并 污染账号状态;TypeSafe 账号 base_url 为空时不再回退 api.anthropic.com。 - SystemOne 补在途余额预留、利润控制准入终检、wrapReleaseOnDone,失败切换改用 共享 FailoverState(同账号重试 / 池模式 / 临时封禁)。 - 上游错误沿用共享账号错误策略:402/403、自定义错误码、临时不可调度规则生效, 记录 ops 上游错误;400/422 仍不切换且永不改变账号状态。 - 账号测试改走原生 System One 探测,可用模型只返回 jev-latest; 响应超限显式报错,响应 Content-Type 仅透传 JSON 类型。 --- README.md | 2 +- README_CN.md | 2 +- .../internal/handler/admin/account_handler.go | 7 + .../account_handler_available_models_test.go | 33 ++++ backend/internal/handler/gateway_handler.go | 6 + .../gateway_handler_chat_completions.go | 3 + .../handler/gateway_handler_responses.go | 3 + backend/internal/handler/gateway_systemone.go | 171 +++++++++++++----- .../handler/gateway_systemone_test.go | 66 +++++++ backend/internal/pkg/typesafe/client.go | 16 +- backend/internal/pkg/typesafe/systemone.go | 14 +- .../internal/pkg/typesafe/systemone_test.go | 2 + .../internal/securityaudit/prompt_snapshot.go | 60 ++++++ .../securityaudit/prompt_snapshot_test.go | 39 ++++ backend/internal/service/account.go | 4 + .../internal/service/account_test_service.go | 4 + .../service/account_test_service_typesafe.go | 95 ++++++++++ .../account_test_service_typesafe_test.go | 92 ++++++++++ .../service/content_moderation_input.go | 29 ++- .../content_moderation_systemone_test.go | 8 + backend/internal/service/gateway_systemone.go | 105 ++++++++--- .../service/gateway_systemone_test.go | 137 ++++++++++++++ .../migrations/241_add_typesafe_platform.sql | 26 +++ .../typesafe_platform_migration_test.go | 21 +++ 24 files changed, 854 insertions(+), 91 deletions(-) create mode 100644 backend/internal/service/account_test_service_typesafe.go create mode 100644 backend/internal/service/account_test_service_typesafe_test.go create mode 100644 backend/migrations/241_add_typesafe_platform.sql create mode 100644 backend/migrations/typesafe_platform_migration_test.go diff --git a/README.md b/README.md index 48a6f0cdb3d7..d602bf3950e3 100644 --- a/README.md +++ b/README.md @@ -848,7 +848,7 @@ curl https://your-sub2api.example.com/v1/systemone \ --data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}' ``` -The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, rate-limit, overload, server, and network failures use the existing account cooldown and failover path; request errors (`400` and `422`) are returned without retrying another account. +The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400` and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`. --- diff --git a/README_CN.md b/README_CN.md index 4680298f82c9..8fe4bc5d8309 100644 --- a/README_CN.md +++ b/README_CN.md @@ -764,7 +764,7 @@ curl https://your-sub2api.example.com/v1/systemone \ --data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}' ``` -`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、限流、过载、服务端和网络错误沿用现有账号冷却与故障切换链路;请求错误(`400`、`422`)不会切换账号重试。 +`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。 --- diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 3d0d2ec510a2..6a0ef4fdf4ec 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -27,6 +27,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" @@ -2950,6 +2951,12 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { return } + // TypeSafe accounts serve only the native System One model. + if account.IsTypeSafe() { + response.Success(c, []claude.Model{{ID: typesafe.JevLatestModel, Type: "model", DisplayName: typesafe.JevLatestModel}}) + return + } + // Handle Claude/Anthropic accounts // For OAuth and Setup-Token accounts: return default models if account.IsOAuth() { diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index 28dc6d1492a4..3bcfce11d02b 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -526,3 +526,36 @@ func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarnin require.Len(t, resp.Data.Warnings, 1) require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code) } + +func TestAccountHandlerGetAvailableModels_TypeSafeOnlyReturnsJev(t *testing.T) { + for _, credentials := range []map[string]any{ + {"api_key": "ts-secret"}, + {"api_key": "ts-secret", "model_mapping": map[string]any{"jev-latest": "jev-latest"}}, + } { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 46, + Name: "typesafe", + Platform: service.PlatformTypeSafe, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Credentials: credentials, + }, + } + router := setupAvailableModelsRouter(svc) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + require.Equal(t, "jev-latest", resp.Data[0].ID) + } +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a25a65b576ca..afd77297f331 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -223,6 +223,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups") return } + if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) { + return + } if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage { h.anthropicSecurityAuditError(c, decision) @@ -2169,6 +2172,9 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups") return } + if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) { + return + } setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false))) diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 7ede3bd3ee8b..561ed1bca0a1 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -82,6 +82,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups") return } + if rejectSystemOneOnlyPlatform(c, apiKey, h.chatCompletionsErrorResponse) { + return + } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 7fdcd8e9d93f..5ef6bace91cb 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -82,6 +82,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) { h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups") return } + if rejectSystemOneOnlyPlatform(c, apiKey, h.responsesErrorResponse) { + return + } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) diff --git a/backend/internal/handler/gateway_systemone.go b/backend/internal/handler/gateway_systemone.go index 7c2713e79d4f..77ade84e9508 100644 --- a/backend/internal/handler/gateway_systemone.go +++ b/backend/internal/handler/gateway_systemone.go @@ -16,6 +16,25 @@ import ( "go.uber.org/zap" ) +const systemOneOnlyPlatformMessage = "TypeSafe models are only available through POST /v1/systemone" + +// rejectSystemOneOnlyPlatform stops TypeSafe traffic from entering a non System +// One protocol chain. TypeSafe accounts only speak the native System One +// protocol; letting them reach the Anthropic/OpenAI converters would send +// foreign payloads (and the account key) to the wrong upstream path and feed +// the resulting auth failures back into account state. +func rejectSystemOneOnlyPlatform(c *gin.Context, apiKey *service.APIKey, writeError func(*gin.Context, int, string, string)) bool { + if _, forced := middleware2.GetForcePlatformFromContext(c); forced { + return false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformTypeSafe { + return false + } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + writeError(c, http.StatusNotFound, "not_found_error", systemOneOnlyPlatformMessage) + return true +} + // SystemOne proxies TypeSafe's native, non-streaming System One protocol. func (h *GatewayHandler) SystemOne(c *gin.Context) { requestStart := time.Now() @@ -71,13 +90,17 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { streamStarted := false userRelease, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, false, &streamStarted) if err != nil { + reqLog.Warn("systemone.user_slot_acquire_failed", zap.Error(err)) h.handleConcurrencyError(c, err, "user", false) return } + // 在请求结束或 Context 取消时确保释放槽位,避免客户端断开造成泄漏。 + userRelease = wrapReleaseOnDone(c.Request.Context(), userRelease) if userRelease != nil { defer userRelease() } if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + reqLog.Info("systemone.billing_eligibility_check_failed", zap.Error(err)) status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) @@ -85,64 +108,122 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { h.errorResponse(c, status, code, message) return } - - failedAccountIDs := make(map[int64]struct{}) - var lastFailoverErr *service.UpstreamFailoverError - maxSwitches := h.maxAccountSwitches - if maxSwitches <= 0 { - maxSwitches = 3 + // 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。 + inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(model, body)) + if err != nil { + reqLog.Info("systemone.inflight_reservation_rejected", zap.Error(err)) + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return } - for switchCount := 0; ; { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", model, failedAccountIDs, "", subject.UserID) - if err != nil || selection == nil || selection.Account == nil { + defer inflightRelease() + + fs := NewFailoverState(h.maxAccountSwitches, false) + for { + if failoverClientGone(c) { + return + } + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", model, fs.FailedAccountIDs, "", subject.UserID) + if err == nil && (selection == nil || selection.Account == nil) { + err = service.ErrNoAvailableAccounts + } + if err != nil { if failoverClientGone(c) { + reqLog.Info("systemone.account_select_aborted_client_disconnected", zap.Error(err)) return } - if lastFailoverErr != nil { - h.handleFailoverExhausted(c, lastFailoverErr, service.PlatformTypeSafe, false) + if len(fs.FailedAccountIDs) == 0 { + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, model, model, service.PlatformTypeSafe) + cls = classifySelectionFailureError(err, cls) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimitedIfNoAvailable(c, err) + } + reqLog.Warn("systemone.select_account_no_available", zap.Bool("model_not_found", cls.ModelNotFound), zap.Error(err)) + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + switch fs.HandleSelectionExhausted(c.Request.Context()) { + case FailoverContinue: + continue + case FailoverCanceled: + failoverClientGone(c) + return + default: + if fs.LastFailoverErr != nil { + h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false) + } else { + h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, false) + } return } - cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, model, model, service.PlatformTypeSafe) - h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) - return } account := selection.Account - setOpsSelectedAccount(c, account.ID, account.Platform) - service.SetOpsUpstreamModel(c, model) - accountRelease, err := h.acquireSystemOneAccountSlot(c, selection) - if err != nil { - h.handleConcurrencyError(c, err, "account", false) - return + accountRelease := selection.ReleaseFunc + if !selection.Acquired { + if selection.WaitPlan == nil { + markOpsRoutingCapacityLimited(c) + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts") + return + } + accountRelease, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted) + if err != nil { + reqLog.Warn("systemone.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + h.handleConcurrencyError(c, err, "account", false) + return + } } - forwardStart := time.Now() - result, forwardErr := func() (*service.SystemOneForwardResult, error) { + // 准入终检:与其他网关入口一致,利润控制否决的账号不得承接本次请求。 + admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { if accountRelease != nil { - defer accountRelease() + accountRelease() } - return h.gatewayService.ForwardSystemOne(c.Request.Context(), c, account, body) - }() + reqLog.Debug("systemone.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if fs.RecordProfitVeto(account.ID) == FailoverExhausted { + reqLog.Warn("systemone.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage) + return + } + continue + } + account = latest + accountRelease = wrapReleaseOnDone(c.Request.Context(), accountRelease) + setOpsSelectedAccount(c, account.ID, account.Platform) + service.SetOpsUpstreamModel(c, model) + + forwardStart := time.Now() + result, forwardErr := h.gatewayService.ForwardSystemOne(c.Request.Context(), c, account, body) + if accountRelease != nil { + accountRelease() + } service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds()) if forwardErr != nil { var failoverErr *service.UpstreamFailoverError if errors.As(forwardErr, &failoverErr) { - if failoverClientGone(c) { + switch fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) { + case FailoverContinue: + reqLog.Warn("systemone.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", fs.SwitchCount), + ) + continue + case FailoverExhausted: + h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false) return - } - failedAccountIDs[account.ID] = struct{}{} - lastFailoverErr = failoverErr - if switchCount >= maxSwitches { - h.handleFailoverExhausted(c, failoverErr, service.PlatformTypeSafe, false) + case FailoverCanceled: + failoverClientGone(c) return } - switchCount++ - reqLog.Warn("systemone.upstream_failover_switching", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("switch_count", switchCount), - ) - continue + } + if failoverClientGone(c) { + return } var upstreamErr *service.SystemOneUpstreamError if errors.As(forwardErr, &upstreamErr) { @@ -154,6 +235,10 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { return } reqLog.Warn("systemone.forward_failed", zap.Int64("account_id", account.ID), zap.Error(forwardErr)) + if errors.Is(forwardErr, typesafe.ErrSystemOneResponseTooLarge) { + h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe response exceeds the gateway size limit") + return + } h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe upstream request failed") return } @@ -164,16 +249,6 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { } } -func (h *GatewayHandler) acquireSystemOneAccountSlot(c *gin.Context, selection *service.AccountSelectionResult) (func(), error) { - if selection.Acquired { - return selection.ReleaseFunc, nil - } - if selection.WaitPlan == nil { - return nil, errors.New("account concurrency unavailable") - } - return h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(c, selection.Account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, new(bool)) -} - func (h *GatewayHandler) recordSystemOneUsage(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, mapping service.ChannelMappingResult, model string, body []byte, result *service.SystemOneForwardResult, userID int64, pricingAt time.Time) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) diff --git a/backend/internal/handler/gateway_systemone_test.go b/backend/internal/handler/gateway_systemone_test.go index bf60bf499081..084b37ed9fe9 100644 --- a/backend/internal/handler/gateway_systemone_test.go +++ b/backend/internal/handler/gateway_systemone_test.go @@ -41,3 +41,69 @@ func TestSystemOneRejectsNonTypeSafeGroupBeforeScheduling(t *testing.T) { require.Equal(t, http.StatusNotFound, recorder.Code) require.Contains(t, recorder.Body.String(), "only available for TypeSafe") } + +func newTypeSafeGroupContext(t *testing.T, path, body, groupPlatform string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + c, recorder := newSystemOneHandlerContext(body) + c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + groupID := int64(9) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: groupPlatform}}) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1}) + return c, recorder +} + +func TestRejectSystemOneOnlyPlatform(t *testing.T) { + write := func(c *gin.Context, status int, errType, message string) { + c.JSON(status, gin.H{"type": errType, "message": message}) + } + + c, recorder := newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformTypeSafe) + require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write)) + require.Equal(t, http.StatusNotFound, recorder.Code) + require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage) + + c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformComposite) + require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write)) + c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformTypeSafe)) + require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write)) + + c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformAnthropic) + require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write)) + + c, _ = newTypeSafeGroupContext(t, "/antigravity/v1/messages", `{}`, service.PlatformTypeSafe) + c.Set(string(middleware2.ContextKeyForcePlatform), service.PlatformAntigravity) + require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write)) +} + +func mustAPIKey(t *testing.T, c *gin.Context) *service.APIKey { + t.Helper() + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + require.True(t, ok) + return apiKey +} + +func TestTypeSafeGroupsRejectNonSystemOneProtocolsBeforeScheduling(t *testing.T) { + h := &GatewayHandler{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}}, + gatewayService: &service.GatewayService{}, + } + for _, tc := range []struct { + name string + path string + body string + handler func(*gin.Context) + }{ + {"messages", "/v1/messages", `{"model":"jev-latest","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`, h.Messages}, + {"count_tokens", "/v1/messages/count_tokens", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.CountTokens}, + {"chat_completions", "/v1/chat/completions", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.ChatCompletions}, + {"responses", "/v1/responses", `{"model":"jev-latest","input":"hi"}`, h.Responses}, + } { + t.Run(tc.name, func(t *testing.T) { + c, recorder := newTypeSafeGroupContext(t, tc.path, tc.body, service.PlatformTypeSafe) + tc.handler(c) + require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage) + }) + } +} diff --git a/backend/internal/pkg/typesafe/client.go b/backend/internal/pkg/typesafe/client.go index 3c66d8c3143d..7acbd9b4a477 100644 --- a/backend/internal/pkg/typesafe/client.go +++ b/backend/internal/pkg/typesafe/client.go @@ -61,9 +61,21 @@ func NewSystemOneRequest(ctx context.Context, baseURL, key string, body []byte) return req, nil } +// MaxSystemOneResponseBytes bounds a buffered System One response body. +const MaxSystemOneResponseBytes = 4 << 20 + +var ErrSystemOneResponseTooLarge = errors.New("typesafe response exceeds size limit") + func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) { - body, err := io.ReadAll(io.LimitReader(r, 4<<20)) - if err != nil || !json.Valid(body) { + body, err := io.ReadAll(io.LimitReader(r, MaxSystemOneResponseBytes+1)) + if err != nil { + return nil, errors.New("typesafe invalid response") + } + // A truncated body would otherwise surface as a misleading "invalid JSON". + if len(body) > MaxSystemOneResponseBytes { + return nil, ErrSystemOneResponseTooLarge + } + if !json.Valid(body) { return nil, errors.New("typesafe invalid response") } var envelope struct { diff --git a/backend/internal/pkg/typesafe/systemone.go b/backend/internal/pkg/typesafe/systemone.go index 898a0e5c246f..14093b2e24ad 100644 --- a/backend/internal/pkg/typesafe/systemone.go +++ b/backend/internal/pkg/typesafe/systemone.go @@ -11,10 +11,10 @@ import ( var ErrStreamingUnsupported = errors.New("typesafe system one does not support streaming") type systemOneEnvelope struct { - Model json.RawMessage `json:"model"` - State json.RawMessage `json:"state"` - Questions map[string]json.RawMessage `json:"questions"` - Stream json.RawMessage `json:"stream"` + Model json.RawMessage `json:"model"` + State json.RawMessage `json:"state"` + Questions json.RawMessage `json:"questions"` + Stream json.RawMessage `json:"stream"` } func ValidateSystemOneRequest(body []byte) (string, error) { @@ -33,7 +33,9 @@ func ValidateSystemOneRequest(body []byte) (string, error) { if err := validateStringObjectOrArray(envelope.State, "state"); err != nil { return "", err } - if len(envelope.Questions) == 0 { + questionsRaw := bytes.TrimSpace(envelope.Questions) + var questions map[string]json.RawMessage + if len(questionsRaw) == 0 || questionsRaw[0] != '{' || json.Unmarshal(questionsRaw, &questions) != nil || len(questions) == 0 { return "", errors.New("questions must be a non-empty object") } if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" { @@ -45,7 +47,7 @@ func ValidateSystemOneRequest(body []byte) (string, error) { return "", ErrStreamingUnsupported } } - for id, raw := range envelope.Questions { + for id, raw := range questions { if err := validateQuestion(id, raw); err != nil { return "", err } diff --git a/backend/internal/pkg/typesafe/systemone_test.go b/backend/internal/pkg/typesafe/systemone_test.go index c4233a97f0ca..3f6d3d9c00a5 100644 --- a/backend/internal/pkg/typesafe/systemone_test.go +++ b/backend/internal/pkg/typesafe/systemone_test.go @@ -46,6 +46,8 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { {"missing state", `{"model":"jev-latest","questions":{"q":{"type":"noul","instructions":"x"}}}`, "state"}, {"scalar state", `{"model":"jev-latest","state":42,"questions":{"q":{"type":"noul","instructions":"x"}}}`, "state"}, {"empty questions", `{"model":"jev-latest","state":"x","questions":{}}`, "questions"}, + {"array questions", `{"model":"jev-latest","state":"x","questions":[{"type":"noul"}]}`, "questions must be a non-empty object"}, + {"null questions", `{"model":"jev-latest","state":"x","questions":null}`, "questions must be a non-empty object"}, {"unknown question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"boolean","instructions":"x"}}}`, "unsupported type"}, {"question type with whitespace", `{"model":"jev-latest","state":"x","questions":{"q":{"type":" noul ","instructions":"x"}}}`, "unsupported type"}, {"noul criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":[]}}}`, "noul criteria"}, diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index b47e8467417e..12c1630b5089 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -106,6 +106,8 @@ func extractProtocolSegments(protocol string, document any) []promptSegment { return append(extractInstructions(root["instructions"]), extractResponses(root["input"])...) case "openai_images", "grok_media", "media", "images": return userPromptSegments(extractMediaPrompts(root)) + case "typesafe_systemone": + return extractSystemOneSegments(root) default: if segments := extractChatLikeSegments(root); len(segments) > 0 { return segments @@ -120,6 +122,64 @@ func extractProtocolSegments(protocol string, document any) []promptSegment { } } +// extractSystemOneSegments collects every client-controlled text of a TypeSafe +// System One request. Question instructions and criteria come first and the +// evaluated state last, so the state is the prioritized segment. Object keys are +// visited in sorted order to keep the prompt hash stable across requests. +func extractSystemOneSegments(root map[string]any) []promptSegment { + if root == nil { + return nil + } + texts := make([]string, 0, 4) + questions, _ := root["questions"].(map[string]any) + for _, id := range sortedJSONKeys(questions) { + question, ok := questions[id].(map[string]any) + if !ok { + continue + } + texts = appendJSONStringLeaves(texts, question["instructions"]) + criteria, isObject := question["criteria"].(map[string]any) + if !isObject || stringValue(question["type"]) != "choice" { + texts = appendJSONStringLeaves(texts, question["criteria"]) + continue + } + // Choice option labels are client-chosen text sent to the model as well. + for _, label := range sortedJSONKeys(criteria) { + texts = appendJSONStringLeaves(texts, label) + texts = appendJSONStringLeaves(texts, criteria[label]) + } + } + texts = appendJSONStringLeaves(texts, root["state"]) + return userPromptSegments(texts) +} + +func appendJSONStringLeaves(texts []string, value any) []string { + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + texts = append(texts, text) + } + case []any: + for _, item := range typed { + texts = appendJSONStringLeaves(texts, item) + } + case map[string]any: + for _, key := range sortedJSONKeys(typed) { + texts = appendJSONStringLeaves(texts, typed[key]) + } + } + return texts +} + +func sortedJSONKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + // clientInstructionRoles are roles a client may freely populate. Attackers can // place jailbreak/PII text in assistant/tool turns, so blocking audit must scan // them too—not only user/system/developer instructions. diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index 70d26f925c66..317cd98df7fe 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -412,3 +412,42 @@ func mustJSON(t *testing.T, value string) []byte { func metadataTextForTest(scanText string) string { return strings.Replace(scanText, promptAuditPrioritySeparator, "\n\n", 1) } + +func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) { + body := `{"model":"jev-latest","state":{"title":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` + + `"questions":{"b":{"type":"choice","instructions":"CHOICE_INSTRUCTIONS","criteria":{"OPTION_LABEL":{"description":"OPTION_DESC"},"empty":null}},` + + `"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"}},` + + `"c":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}}}` + + snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}) + require.NoError(t, err) + for _, text := range []string{"STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC", + "NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "SCORE_LOW", "SCORE_HIGH"} { + require.Contains(t, snapshot.ScanText, text) + } + require.NotContains(t, snapshot.ScanText, "jev-latest") + require.Equal(t, 12, snapshot.MessageCount) + + // Map iteration order must not change the audited text or its hash. + for range 20 { + again, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}) + require.NoError(t, err) + require.Equal(t, snapshot.PromptHash, again.PromptHash) + require.Equal(t, snapshot.ScanText, again.ScanText) + } + + blocking, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, true) + require.NoError(t, err) + require.Contains(t, blocking.ScanText, "STATE_TITLE") + require.Contains(t, blocking.ScanText, "CHOICE_INSTRUCTIONS") +} + +func TestPromptSnapshotTypeSafeSystemOneStringStateIsAudited(t *testing.T) { + snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":"plain state","questions":{"q":{"type":"noul"}}}`)}) + require.NoError(t, err) + require.True(t, strings.HasPrefix(snapshot.ScanText, "plain state")) + require.Equal(t, 1, snapshot.MessageCount) + + _, err = ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":{"n":1},"questions":{"q":{"type":"noul"}}}`)}) + require.ErrorIs(t, err, ErrNoPromptText) +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 38efbfba867f..ca8246677e19 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -985,6 +985,10 @@ func (a *Account) GetBaseURL() string { } baseURL := a.GetCredential("base_url") if baseURL == "" { + // TypeSafe keys must never fall back to the Anthropic host. + if a.Platform == PlatformTypeSafe { + return typesafe.DefaultBaseURL + } return "https://api.anthropic.com" } if a.Platform == PlatformAntigravity { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 7d16eeaaec6e..4da30d610bb5 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -416,6 +416,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testOpenCodeGoAccountConnection(c, account, modelID, prompt) } + if account.IsTypeSafe() { + return s.testTypeSafeAccountConnection(c, account, prompt) + } + return s.testClaudeAccountConnection(c, account, modelID) } diff --git a/backend/internal/service/account_test_service_typesafe.go b/backend/internal/service/account_test_service_typesafe.go new file mode 100644 index 000000000000..6c9501d89204 --- /dev/null +++ b/backend/internal/service/account_test_service_typesafe.go @@ -0,0 +1,95 @@ +package service + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" + "github.com/gin-gonic/gin" +) + +const ( + typeSafeTestDefaultState = "Sub2API connection test" + typeSafeTestQuestionID = "connection_test" + typeSafeTestMaxPreviewBytes = 2000 +) + +// testTypeSafeAccountConnection probes a TypeSafe account with a minimal native +// System One request. TypeSafe accounts never speak the Claude protocol, so +// they must not fall through to testClaudeAccountConnection (which would send +// the key to /v1/messages and could misclassify the account). +func (s *AccountTestService) testTypeSafeAccountConnection(c *gin.Context, account *Account, prompt string) error { + ctx := c.Request.Context() + if account.Type != AccountTypeAPIKey { + return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) + } + apiKey := account.GetTypeSafeAPIKey() + if apiKey == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + baseURL, err := s.validateUpstreamBaseURL(account.GetTypeSafeBaseURL()) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) + } + + state := strings.TrimSpace(prompt) + if state == "" { + state = typeSafeTestDefaultState + } + payload, err := json.Marshal(map[string]any{ + "model": typesafe.JevLatestModel, + "state": state, + "questions": map[string]any{ + typeSafeTestQuestionID: map[string]any{ + "type": "noul", + "instructions": "Is this text a connection test?", + }, + }, + }) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create test payload") + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + + s.sendEvent(c, TestEvent{Type: "test_start", Model: typesafe.JevLatestModel}) + + req, err := typesafe.NewSystemOneRequest(ctx, baseURL, apiKey, payload) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create request") + } + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", sanitizeUpstreamErrorMessage(err.Error()))) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, truncateString(string(body), typeSafeTestMaxPreviewBytes)) + // 401/403 表示 API Key 无效或被上游拒绝,标记为 error 状态。 + if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + decoded, err := typesafe.DecodeSystemOneResponse(resp.Body) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid System One response: %s", err.Error())) + } + s.sendEvent(c, TestEvent{Type: "content", Text: truncateString(string(decoded.Body), typeSafeTestMaxPreviewBytes)}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} diff --git a/backend/internal/service/account_test_service_typesafe_test.go b/backend/internal/service/account_test_service_typesafe_test.go new file mode 100644 index 000000000000..c60abf42c3c0 --- /dev/null +++ b/backend/internal/service/account_test_service_typesafe_test.go @@ -0,0 +1,92 @@ +package service + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newTypeSafeAccountTestFixture(t *testing.T, status int, responseBody string) (*AccountTestService, *systemOnePolicyAccountRepo, *[]*http.Request, *gin.Context, *httptest.ResponseRecorder) { + t.Helper() + account := &Account{ID: 31, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}} + repo := &systemOnePolicyAccountRepo{account: account} + var requests []*http.Request + upstream := &systemOneHTTPUpstream{do: func(req *http.Request) (*http.Response, error) { + requests = append(requests, req) + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responseBody))}, nil + }} + svc := &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: &config.Config{}, + } + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/31/test", nil) + return svc, repo, &requests, c, rec +} + +func TestTypeSafeAccountTestSendsNativeSystemOneRequest(t *testing.T) { + svc, repo, requests, c, rec := newTypeSafeAccountTestFixture(t, http.StatusOK, `{"model":"jev-1.13.0","answers":{"connection_test":{"type":"noul"}},"usage":{"input_tokens":9}}`) + + // The Claude default model must be ignored: TypeSafe only serves jev-latest. + require.NoError(t, svc.TestAccountConnection(c, 31, "claude-sonnet-4-5", "", AccountTestModeDefault)) + + require.Len(t, *requests, 1) + req := (*requests)[0] + require.Equal(t, typesafe.DefaultBaseURL+typesafe.SystemOnePath, req.URL.String()) + require.Equal(t, "Bearer ts-secret", req.Header.Get("Authorization")) + require.Empty(t, req.Header.Get("x-api-key")) + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + model, err := typesafe.ValidateSystemOneRequest(body) + require.NoError(t, err) + require.Equal(t, typesafe.JevLatestModel, model) + + output := rec.Body.String() + require.Contains(t, output, `"type":"test_start"`) + require.Contains(t, output, `"model":"jev-latest"`) + require.Contains(t, output, `"type":"test_complete"`) + require.Contains(t, output, "jev-1.13.0") + require.Zero(t, repo.errorCalls) +} + +func TestTypeSafeAccountTestMarksRejectedKey(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + t.Run(http.StatusText(status), func(t *testing.T) { + svc, repo, _, c, rec := newTypeSafeAccountTestFixture(t, status, `{"detail":"invalid key"}`) + require.Error(t, svc.TestAccountConnection(c, 31, "", "", AccountTestModeDefault)) + require.Equal(t, 1, repo.errorCalls) + responseText, errMsg := parseTestSSEOutput(rec.Body.String()) + require.Empty(t, responseText) + require.Contains(t, errMsg, "API returned") + }) + } +} + +func TestTypeSafeAccountTestTransientFailureKeepsAccountState(t *testing.T) { + svc, repo, _, c, _ := newTypeSafeAccountTestFixture(t, http.StatusServiceUnavailable, `{"detail":"busy"}`) + require.Error(t, svc.TestAccountConnection(c, 31, "", "", AccountTestModeDefault)) + require.Zero(t, repo.errorCalls) +} + +func TestTypeSafeAccountTestUsesPromptAsState(t *testing.T) { + svc, _, requests, c, _ := newTypeSafeAccountTestFixture(t, http.StatusOK, `{"answers":{}}`) + require.NoError(t, svc.TestAccountConnection(c, 31, "", "custom state", AccountTestModeDefault)) + body, err := io.ReadAll((*requests)[0].Body) + require.NoError(t, err) + var payload struct { + State string `json:"state"` + } + require.NoError(t, json.Unmarshal(body, &payload)) + require.Equal(t, "custom state", payload.State) +} diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index 7a03288d4ea0..6ef090a10052 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -47,7 +47,9 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders collector.addModerationText(&parts, gjson.GetBytes(body, "prompt").String()) collector.collectContentValue(gjson.GetBytes(body, "images"), &parts, &images) case ContentModerationProtocolTypeSafeSystemOne: - collector.collectSystemOneState(gjson.GetBytes(body, "state"), &parts) + // System One carries no client-harness reminder blocks, so a literal + // is ordinary user text and must never be skipped. + moderationTextCollector{}.collectSystemOneInput(body, &parts) default: collector.collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) collector.collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) @@ -63,7 +65,28 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders return out } -func (collector moderationTextCollector) collectSystemOneState(value gjson.Result, parts *[]string) { +// collectSystemOneInput moderates the evaluated state plus every question's +// instructions and criteria: all of them are client text sent to Jev. +func (collector moderationTextCollector) collectSystemOneInput(body []byte, parts *[]string) { + collector.collectSystemOneText(gjson.GetBytes(body, "state"), parts) + gjson.GetBytes(body, "questions").ForEach(func(_, question gjson.Result) bool { + collector.collectSystemOneText(question.Get("instructions"), parts) + criteria := question.Get("criteria") + if question.Get("type").String() == "choice" && criteria.IsObject() { + // Choice option labels are client-chosen text as well. + criteria.ForEach(func(label, description gjson.Result) bool { + collector.addModerationText(parts, label.String()) + collector.collectSystemOneText(description, parts) + return true + }) + return true + } + collector.collectSystemOneText(criteria, parts) + return true + }) +} + +func (collector moderationTextCollector) collectSystemOneText(value gjson.Result, parts *[]string) { switch { case !value.Exists(): return @@ -71,7 +94,7 @@ func (collector moderationTextCollector) collectSystemOneState(value gjson.Resul collector.addModerationText(parts, value.String()) case value.IsArray(), value.IsObject(): value.ForEach(func(_, child gjson.Result) bool { - collector.collectSystemOneState(child, parts) + collector.collectSystemOneText(child, parts) return true }) } diff --git a/backend/internal/service/content_moderation_systemone_test.go b/backend/internal/service/content_moderation_systemone_test.go index 993a71fa7ea5..c9a6ae95056c 100644 --- a/backend/internal/service/content_moderation_systemone_test.go +++ b/backend/internal/service/content_moderation_systemone_test.go @@ -15,6 +15,7 @@ func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) { {"string", `{"state":"plain text"}`, "plain text"}, {"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "hello world"}, {"array", `{"state":["first",{"text":"second"},3]}`, "first second"}, + {"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"}}}}`, "state text pick one label a desc a label b low high judge yes"}, } { t.Run(tc.name, func(t *testing.T) { input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body)) @@ -23,3 +24,10 @@ func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) { }) } } + +func TestExtractContentModerationInputTypeSafeSystemOneKeepsReminderText(t *testing.T) { + body := `{"state":"hidden payload","questions":{"q":{"type":"noul","instructions":"hidden instructions"}}}` + input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(body)) + require.Contains(t, input.Text, "hidden payload") + require.Contains(t, input.Text, "hidden instructions") +} diff --git a/backend/internal/service/gateway_systemone.go b/backend/internal/service/gateway_systemone.go index f9201f91e8e2..261cb74b9efe 100644 --- a/backend/internal/service/gateway_systemone.go +++ b/backend/internal/service/gateway_systemone.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "io" + "mime" "net/http" "strings" "time" @@ -47,6 +47,7 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a if err != nil { return nil, err } + upstreamURL := req.URL.Scheme + "://" + req.URL.Host + req.URL.Path proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() @@ -55,45 +56,19 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a if err != nil { return nil, s.handleUpstreamTransportError(ctx, c, account, err, OpsUpstreamErrorEvent{ Passthrough: true, - UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path, + UpstreamURL: upstreamURL, }) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) - if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity { - return nil, &SystemOneUpstreamError{StatusCode: resp.StatusCode} - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == 529 || resp.StatusCode >= 500 { - if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, nil, typesafe.JevLatestModel) - } - failoverErr := &UpstreamFailoverError{ - StatusCode: resp.StatusCode, - ResponseHeaders: resp.Header.Clone(), - ClientStatusCode: http.StatusBadGateway, - ClientMessage: "TypeSafe upstream request failed", - } - if resp.StatusCode == http.StatusUnauthorized { - failoverErr.Stage = GatewayFailureStageAccountAuth - failoverErr.Scope = GatewayFailureScopeAccount - failoverErr.Reason = TypeSafeCredentialRejectedReason - failoverErr.NextAccountAction = NextAccountRetry - } - return nil, failoverErr - } - return nil, &SystemOneUpstreamError{StatusCode: http.StatusBadGateway} + return nil, s.handleSystemOneErrorResponse(ctx, c, account, resp, upstreamURL) } decoded, err := typesafe.DecodeSystemOneResponse(resp.Body) if err != nil { return nil, err } - contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) - if contentType == "" { - contentType = "application/json" - } return &SystemOneForwardResult{ ForwardResult: ForwardResult{ RequestID: resp.Header.Get("x-request-id"), @@ -105,6 +80,76 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a }, StatusCode: resp.StatusCode, Body: decoded.Body, - ContentType: contentType, + ContentType: systemOneResponseContentType(resp.Header.Get("Content-Type")), }, nil } + +// handleSystemOneErrorResponse applies the shared account error policy to a +// non-2xx System One response. 400/422 describe the caller's own payload, so +// they never touch account state (a tenant must not be able to disable an +// account with bad input) and are not retried elsewhere. Every other status +// goes through the account error policy (custom error codes, temporary +// unschedulable rules, pool mode) and fails over when the status is retryable +// or the policy took the account out of rotation. +func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, upstreamURL string) error { + respBody, _ := s.readUpstreamErrorBody(resp) + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "") + event := OpsUpstreamErrorEvent{ + Passthrough: true, + ProxyID: opsUpstreamProxyID(account), + ProxyName: opsUpstreamProxyName(account), + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: upstreamURL, + Kind: "http_error", + Message: upstreamMsg, + } + + if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity { + appendOpsUpstreamError(c, event) + return &SystemOneUpstreamError{StatusCode: resp.StatusCode} + } + + shouldDisable := false + if s.rateLimitService != nil { + shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, typesafe.JevLatestModel) + } + if !shouldDisable && !s.shouldFailoverUpstreamError(resp.StatusCode) { + appendOpsUpstreamError(c, event) + return &SystemOneUpstreamError{StatusCode: resp.StatusCode} + } + + event.Kind = "failover" + appendOpsUpstreamError(c, event) + failoverErr := &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: respBody, + ResponseHeaders: resp.Header.Clone(), + RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } + if resp.StatusCode == http.StatusUnauthorized { + failoverErr.Stage = GatewayFailureStageAccountAuth + failoverErr.Scope = GatewayFailureScopeAccount + failoverErr.Reason = TypeSafeCredentialRejectedReason + failoverErr.NextAccountAction = NextAccountRetry + } + return failoverErr +} + +// systemOneResponseContentType keeps the upstream JSON media type (and its +// charset) but never relays a non-JSON type for a body already validated as +// JSON, so the gateway origin cannot be made to serve it as HTML. +func systemOneResponseContentType(raw string) string { + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(raw)) + if err != nil { + return "application/json" + } + if mediaType == "application/json" || (strings.HasPrefix(mediaType, "application/") && strings.HasSuffix(mediaType, "+json")) { + return strings.TrimSpace(raw) + } + return "application/json" +} diff --git a/backend/internal/service/gateway_systemone_test.go b/backend/internal/service/gateway_systemone_test.go index c7a6606b540d..62b3051ee49c 100644 --- a/backend/internal/service/gateway_systemone_test.go +++ b/backend/internal/service/gateway_systemone_test.go @@ -231,3 +231,140 @@ func TestForwardSystemOneTransportAndTimeoutFailOver(t *testing.T) { require.ErrorAs(t, err, &failoverErr) } } + +func newSystemOneStatusUpstream(status int, body string) *systemOneHTTPUpstream { + return &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: http.Header{"X-Request-Id": []string{"req-err"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + }} +} + +func systemOneOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent { + t.Helper() + raw, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + events, ok := raw.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + return events +} + +func TestForwardSystemOneRequestErrorsNeverTouchAccountState(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity} { + t.Run(http.StatusText(status), func(t *testing.T) { + // Even a custom error-code rule must not let client input disable the account. + account := &Account{ID: 21, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "http://typesafe.test", "api_key": "ts-secret", + "custom_error_codes_enabled": true, "custom_error_codes": []any{float64(status)}, + }} + repo := &systemOnePolicyAccountRepo{account: account} + svc := newSystemOneTestService(newSystemOneStatusUpstream(status, `{"detail":"bad question"}`)) + svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + c := newSystemOneTestContext() + + _, err := svc.ForwardSystemOne(context.Background(), c, account, []byte(`{}`)) + var upstreamErr *SystemOneUpstreamError + require.ErrorAs(t, err, &upstreamErr) + require.Equal(t, status, upstreamErr.StatusCode) + require.Zero(t, repo.errorCalls+repo.rateLimitedCalls+repo.overloadedCalls) + events := systemOneOpsEvents(t, c) + require.Len(t, events, 1) + require.Equal(t, "http_error", events[0].Kind) + require.Equal(t, status, events[0].UpstreamStatusCode) + require.Equal(t, "req-err", events[0].UpstreamRequestID) + }) + } +} + +func TestForwardSystemOneAccountLevelFailuresFailOver(t *testing.T) { + for _, tc := range []struct { + name string + status int + wantError int + }{ + {name: "payment required", status: http.StatusPaymentRequired, wantError: 1}, + {name: "forbidden", status: http.StatusForbidden, wantError: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + account := &Account{ID: 22, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + repo := &systemOnePolicyAccountRepo{account: account} + svc := newSystemOneTestService(newSystemOneStatusUpstream(tc.status, `{"detail":"account problem"}`)) + svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + c := newSystemOneTestContext() + + _, err := svc.ForwardSystemOne(context.Background(), c, account, []byte(`{}`)) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.ShouldRetryNextAccount()) + require.Equal(t, tc.status, failoverErr.StatusCode) + require.Equal(t, tc.wantError, repo.errorCalls) + require.Equal(t, "failover", systemOneOpsEvents(t, c)[0].Kind) + }) + } +} + +func TestForwardSystemOneHonorsCustomErrorCodes(t *testing.T) { + account := &Account{ID: 23, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "http://typesafe.test", "api_key": "ts-secret", + "custom_error_codes_enabled": true, "custom_error_codes": []any{float64(http.StatusNotFound)}, + }} + repo := &systemOnePolicyAccountRepo{account: account} + svc := newSystemOneTestService(newSystemOneStatusUpstream(http.StatusNotFound, `{"detail":"missing"}`)) + svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + _, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, 1, repo.errorCalls) +} + +func TestForwardSystemOneUnhandledStatusDoesNotFailOver(t *testing.T) { + account := &Account{ID: 24, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + repo := &systemOnePolicyAccountRepo{account: account} + svc := newSystemOneTestService(newSystemOneStatusUpstream(http.StatusConflict, `{"detail":"conflict"}`)) + svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + + _, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + var upstreamErr *SystemOneUpstreamError + require.ErrorAs(t, err, &upstreamErr) + require.Equal(t, http.StatusConflict, upstreamErr.StatusCode) + require.Zero(t, repo.errorCalls) +} + +func TestForwardSystemOneNormalizesResponseContentType(t *testing.T) { + for _, tc := range []struct{ upstream, want string }{ + {"application/json; charset=utf-8", "application/json; charset=utf-8"}, + {"application/problem+json", "application/problem+json"}, + {"text/html; charset=utf-8", "application/json"}, + {"", "application/json"}, + {"not a media type;;", "application/json"}, + } { + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + header := make(http.Header) + if tc.upstream != "" { + header.Set("Content-Type", tc.upstream) + } + return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`{"answers":{}}`))}, nil + }} + account := &Account{ID: 25, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + require.NoError(t, err) + require.Equal(t, tc.want, result.ContentType, tc.upstream) + } +} + +func TestForwardSystemOneRejectsOversizedResponse(t *testing.T) { + oversized := `{"pad":"` + strings.Repeat("a", typesafe.MaxSystemOneResponseBytes) + `"}` + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(oversized))}, nil + }} + account := &Account{ID: 26, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + _, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + require.ErrorIs(t, err, typesafe.ErrSystemOneResponseTooLarge) +} + +func TestTypeSafeAccountBaseURLNeverFallsBackToAnthropic(t *testing.T) { + account := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}} + require.Equal(t, typesafe.DefaultBaseURL, account.GetBaseURL()) + require.Equal(t, typesafe.DefaultBaseURL, account.GetTypeSafeBaseURL()) + anthropic := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}} + require.Equal(t, "https://api.anthropic.com", anthropic.GetBaseURL()) +} diff --git a/backend/migrations/241_add_typesafe_platform.sql b/backend/migrations/241_add_typesafe_platform.sql new file mode 100644 index 000000000000..82a81de70d38 --- /dev/null +++ b/backend/migrations/241_add_typesafe_platform.sql @@ -0,0 +1,26 @@ +-- Add TypeSafe (Jev System One) as a first-class platform. +-- +-- 1. user_platform_quotas.platform CHECK +-- 2. composite_model_routes.target_platform CHECK +-- +-- TypeSafe 不是对话模型,不进入渠道监控 provider,因此 channel_monitors / +-- channel_monitor_request_templates 的约束保持不变。 +-- +-- Runs after 238_opencode_go_platform.sql. DROP ... IF EXISTS 保证可重入; +-- 新约束是 238 的超集,存量行瞬时校验通过。 + +ALTER TABLE user_platform_quotas + DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check; + +ALTER TABLE user_platform_quotas + ADD CONSTRAINT user_platform_quotas_platform_check + CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe')); + +ALTER TABLE composite_model_routes + DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check; + +ALTER TABLE composite_model_routes + ADD CONSTRAINT composite_model_routes_target_platform_check + CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe')); diff --git a/backend/migrations/typesafe_platform_migration_test.go b/backend/migrations/typesafe_platform_migration_test.go new file mode 100644 index 000000000000..7eb2380918cc --- /dev/null +++ b/backend/migrations/typesafe_platform_migration_test.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTypeSafePlatformMigration(t *testing.T) { + content, err := FS.ReadFile("241_add_typesafe_platform.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check") + require.Contains(t, sql, + "CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'))") + require.Contains(t, sql, + "CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'))") +} From 9eb394e3e98c9fc33f9c42191985e845876044bb Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 13:51:58 +0800 Subject: [PATCH 57/70] =?UTF-8?q?fix(typesafe):=20=E5=A0=B5=E4=BD=8F?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E8=A7=A3=E6=9E=90=E5=B7=AE=E5=BC=82=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E7=9B=B2=E5=8C=BA=EF=BC=8C=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - System One 校验拒绝重复键与大小写/Unicode 折叠变体键(请求顶层、 questions、问题对象):encoding/json 大小写不敏感且取最后一个重复键, 而原始 body 原样转发,此前 {"model":"x","MODEL":"jev-latest"} 可绕过 jev-latest 限制、模型白名单与流式限制。 - Prompt 审计与旧审核同时收集对象键名、问题 ID 与未知扩展字段(不含规范 字段名与已校验的 type),此前把内容放进键名即可完全绕过审计。 - 上游成功响应的 usage/model 宽松解码(浮点、数字字符串),避免上游已 计费而网关整体 502 不记账;解码失败记录 response_error ops 事件。 - 上游 413 与 400/422 同视为请求错误:透传状态码、不切换、不改账号状态。 - TypeSafe base_url 末尾的 /v1 自动剥离,避免拼出 /v1/v1/systemone; /v1/systemone 改挂文本请求体上限。 - Composite 静态兜底模型列表、管理端候选列表恢复为不含 jev-latest; Codex 清单永不列出 TypeSafe 模型;TypeSafe 分组候选默认 jev-latest。 - 创建账号从 Grok 切到 TypeSafe 时重置为白名单模式;错误透传平台列表补 typesafe。 --- README.md | 2 +- README_CN.md | 2 +- backend/internal/handler/gateway_handler.go | 17 ++++-- .../internal/handler/gateway_models_test.go | 44 +++++++++++++++ backend/internal/handler/gateway_systemone.go | 2 +- .../internal/model/error_passthrough_rule.go | 2 + backend/internal/pkg/typesafe/client.go | 52 +++++++++++++++--- backend/internal/pkg/typesafe/systemone.go | 48 +++++++++++++++++ .../internal/pkg/typesafe/systemone_test.go | 45 ++++++++++++++++ .../internal/securityaudit/prompt_snapshot.go | 41 +++++++++----- .../securityaudit/prompt_snapshot_test.go | 33 ++++++++---- backend/internal/server/routes/gateway.go | 3 +- backend/internal/service/account.go | 11 ++-- backend/internal/service/admin_group.go | 8 ++- .../service/content_moderation_input.go | 54 ++++++++++++++----- .../content_moderation_systemone_test.go | 7 +-- backend/internal/service/gateway_systemone.go | 31 ++++++++++- .../service/gateway_systemone_test.go | 36 ++++++++++++- .../components/account/CreateAccountModal.vue | 2 + 19 files changed, 377 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index d602bf3950e3..8ec0b7f59c50 100644 --- a/README.md +++ b/README.md @@ -848,7 +848,7 @@ curl https://your-sub2api.example.com/v1/systemone \ --data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}' ``` -The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400` and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`. +The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400`, `413`, and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`. --- diff --git a/README_CN.md b/README_CN.md index 8fe4bc5d8309..70eef1990ddc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -764,7 +764,7 @@ curl https://your-sub2api.example.com/v1/systemone \ --data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}' ``` -`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。 +`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`413`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。 --- diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index afd77297f331..c12f281ff956 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1166,7 +1166,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) + availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, true) if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { source := availableModels if len(source) == 0 { @@ -1265,7 +1265,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi platform = group.Platform } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(ctx, groupID) + availableModels := h.compositeAvailableModels(ctx, groupID, false) fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) if group.ModelAllowlistEnabled() { source := availableModels @@ -1291,7 +1291,10 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi return fallbackModels } -func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { +// compositeAvailableModels lists the models the composite group can serve. +// includeSystemOne adds TypeSafe models, which only work through /v1/systemone; +// LLM client catalogs (Codex) must exclude them. +func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, includeSystemOne bool) []string { if h == nil || h.gatewayService == nil { return nil } @@ -1299,6 +1302,9 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { + if platform == service.PlatformTypeSafe && !includeSystemOne { + continue + } platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 @@ -1487,7 +1493,10 @@ func defaultModelIDsForPlatform(platform string) []string { case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} { + // TypeSafe is deliberately absent: jev-latest only works through + // /v1/systemone, so the static fallback never advertises it to LLM + // clients. compositeAvailableModels lists it when the group can serve it. + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index e0bec9431b74..d2fdb5cfa084 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1526,3 +1526,47 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t }) } } + +// Scenario: jev-latest only works through /v1/systemone, so Composite groups list +// it in /v1/models only when they can serve it, and never in the Codex manifest. +func TestGatewayModels_CompositeTypeSafeListingScope(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(66) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAnthropic}, {ID: 2, Platform: service.PlatformTypeSafe, Type: service.AccountTypeAPIKey}}, + }, + }) + newContext := func(path string) (*gin.Context, *httptest.ResponseRecorder) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + return c, rec + } + + c, rec := newContext("/v1/models") + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var models gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models)) + require.Contains(t, modelIDsForTest(models.Data), "jev-latest") + require.Contains(t, modelIDsForTest(models.Data), "claude-opus-4-6") + + c, rec = newContext("/models?client_version=0.147.0") + h.CodexModels(c) + require.Equal(t, http.StatusOK, rec.Code) + var manifest codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest)) + slugs := codexModelSlugsForTest(manifest.Models) + require.Contains(t, slugs, "claude-opus-4-6") + require.NotContains(t, slugs, "jev-latest") +} + +func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing.T) { + require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest") + require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest") +} diff --git a/backend/internal/handler/gateway_systemone.go b/backend/internal/handler/gateway_systemone.go index 77ade84e9508..f9cc753c79dc 100644 --- a/backend/internal/handler/gateway_systemone.go +++ b/backend/internal/handler/gateway_systemone.go @@ -228,7 +228,7 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { var upstreamErr *service.SystemOneUpstreamError if errors.As(forwardErr, &upstreamErr) { status := upstreamErr.StatusCode - if status != http.StatusBadRequest && status != http.StatusUnprocessableEntity { + if !service.IsSystemOneRequestErrorStatus(status) { status = http.StatusBadGateway } h.errorResponse(c, status, "upstream_error", "TypeSafe rejected the request") diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index a0c81934d7d7..da928125f29b 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -46,6 +46,7 @@ const ( PlatformDeepseek = domain.PlatformDeepseek PlatformMiniMax = domain.PlatformMiniMax PlatformOpenCodeGo = domain.PlatformOpenCodeGo + PlatformTypeSafe = domain.PlatformTypeSafe ) // AllPlatforms 返回所有支持的平台列表 @@ -61,6 +62,7 @@ func AllPlatforms() []string { PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe, } } diff --git a/backend/internal/pkg/typesafe/client.go b/backend/internal/pkg/typesafe/client.go index 7acbd9b4a477..0974204e2a4f 100644 --- a/backend/internal/pkg/typesafe/client.go +++ b/backend/internal/pkg/typesafe/client.go @@ -78,14 +78,54 @@ func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) { if !json.Valid(body) { return nil, errors.New("typesafe invalid response") } - var envelope struct { - Model string `json:"model"` - Usage Usage `json:"usage"` - } - if err := json.Unmarshal(body, &envelope); err != nil { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil { return nil, errors.New("typesafe invalid response") } - return &SystemOneResponse{Body: body, Model: envelope.Model, Usage: envelope.Usage}, nil + // The upstream already answered (and charged); an unexpected model or usage + // shape must not discard the answer, so both are decoded leniently. + var model string + _ = json.Unmarshal(envelope["model"], &model) + var usage map[string]json.RawMessage + _ = json.Unmarshal(envelope["usage"], &usage) + return &SystemOneResponse{ + Body: body, + Model: model, + Usage: Usage{ + InputTokens: systemOneTokenCount(usage["input_tokens"]), + OutputTokens: systemOneTokenCount(usage["output_tokens"]), + }, + }, nil +} + +// maxSystemOneTokenCount bounds a reported token count before int conversion. +const maxSystemOneTokenCount = 1 << 40 + +// systemOneTokenCount accepts integer, float, or numeric-string token counts. +func systemOneTokenCount(raw json.RawMessage) int { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return 0 + } + if raw[0] == '"' { + var text string + if json.Unmarshal(raw, &text) != nil { + return 0 + } + raw = json.RawMessage(strings.TrimSpace(text)) + } + var number json.Number + if json.Unmarshal(raw, &number) != nil { + return 0 + } + value, err := number.Float64() + if err != nil || math.IsNaN(value) || value <= 0 { + return 0 + } + if value > maxSystemOneTokenCount { + value = maxSystemOneTokenCount + } + return int(math.Round(value)) } // Evaluate performs one attempt. The caller owns timeouts, retries and key rotation. diff --git a/backend/internal/pkg/typesafe/systemone.go b/backend/internal/pkg/typesafe/systemone.go index 14093b2e24ad..4fab1f1d695e 100644 --- a/backend/internal/pkg/typesafe/systemone.go +++ b/backend/internal/pkg/typesafe/systemone.go @@ -17,11 +17,22 @@ type systemOneEnvelope struct { Stream json.RawMessage `json:"stream"` } +var ( + systemOneRequestFields = []string{"model", "state", "questions", "stream"} + systemOneQuestionFields = []string{"type", "instructions", "criteria"} +) + func ValidateSystemOneRequest(body []byte) (string, error) { var envelope systemOneEnvelope if err := json.Unmarshal(body, &envelope); err != nil { return "", errors.New("invalid JSON request") } + // encoding/json matches struct fields case-insensitively and keeps the last + // duplicate, while the raw body is forwarded upstream unchanged. Reject any + // spelling the upstream could read differently from this validator. + if err := checkSystemOneObjectKeys(body, "request", systemOneRequestFields); err != nil { + return "", err + } model, err := requiredString(envelope.Model, "model") if err != nil { @@ -38,6 +49,9 @@ func ValidateSystemOneRequest(body []byte) (string, error) { if len(questionsRaw) == 0 || questionsRaw[0] != '{' || json.Unmarshal(questionsRaw, &questions) != nil || len(questions) == 0 { return "", errors.New("questions must be a non-empty object") } + if err := checkSystemOneObjectKeys(questionsRaw, "questions", nil); err != nil { + return "", err + } if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" { var stream bool if err := json.Unmarshal(envelope.Stream, &stream); err != nil { @@ -65,6 +79,9 @@ func validateQuestion(id string, raw json.RawMessage) error { if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &question) != nil { return fmt.Errorf("question %q must be an object", id) } + if err := checkSystemOneObjectKeys(raw, fmt.Sprintf("question %q", id), systemOneQuestionFields); err != nil { + return err + } typ, err := requiredString(question.Type, "question type") if err != nil { return fmt.Errorf("question %q: %w", id, err) @@ -152,3 +169,34 @@ func rawString(raw json.RawMessage) bool { var value string return json.Unmarshal(raw, &value) == nil } + +// checkSystemOneObjectKeys rejects duplicate keys and non-canonical spellings +// (any case variant) of the known fields in one JSON object level. +func checkSystemOneObjectKeys(raw []byte, scope string, canonical []string) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return fmt.Errorf("%s must be an object", scope) + } + seen := make(map[string]struct{}) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return errors.New("invalid JSON request") + } + key, _ := token.(string) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("%s contains duplicate field %q", scope, key) + } + seen[key] = struct{}{} + for _, field := range canonical { + if key != field && strings.EqualFold(key, field) { + return fmt.Errorf("%s field %q must be written as %q", scope, key, field) + } + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return errors.New("invalid JSON request") + } + } + return nil +} diff --git a/backend/internal/pkg/typesafe/systemone_test.go b/backend/internal/pkg/typesafe/systemone_test.go index 3f6d3d9c00a5..65991cf14367 100644 --- a/backend/internal/pkg/typesafe/systemone_test.go +++ b/backend/internal/pkg/typesafe/systemone_test.go @@ -2,6 +2,7 @@ package typesafe import ( "errors" + "strings" "testing" "github.com/stretchr/testify/require" @@ -66,6 +67,15 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { {"numeric instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":1}}}`, "instructions"}, {"boolean instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":true,"criteria":{}}}}`, "instructions"}, {"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"}, + {"case variant model smuggles upstream model", `{"model":"jev-pro","MODEL":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `must be written as "model"`}, + {"case variant stream hides streaming", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}},"stream":true,"Stream":false}`, `must be written as "stream"`}, + {"unicode fold variant state", `{"model":"jev-latest","state":"x","ſtate":"y","questions":{"q":{"type":"noul"}}}`, `must be written as "state"`}, + {"duplicate model", `{"model":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`}, + {"escaped duplicate model", `{"\u006dodel":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`}, + {"duplicate state", `{"model":"jev-latest","state":"benign","state":"payload","questions":{"q":{"type":"noul"}}}`, `duplicate field "state"`}, + {"duplicate question id", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"},"q":{"type":"noul"}}}`, `duplicate field "q"`}, + {"duplicate question instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"a","instructions":"b"}}}`, `duplicate field "instructions"`}, + {"case variant question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","Type":"choice"}}}`, `must be written as "type"`}, } { t.Run(tc.name, func(t *testing.T) { _, err := ValidateSystemOneRequest([]byte(tc.body)) @@ -77,3 +87,38 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) { }) } } + +func TestDecodeSystemOneResponseToleratesUsageShapes(t *testing.T) { + for _, tc := range []struct { + name string + body string + model string + input, output int + }{ + {"integers", `{"model":"jev-1","usage":{"input_tokens":12,"output_tokens":3}}`, "jev-1", 12, 3}, + {"floats", `{"model":"jev-1","usage":{"input_tokens":12.0,"output_tokens":2.6}}`, "jev-1", 12, 3}, + {"numeric strings", `{"usage":{"input_tokens":"15","output_tokens":" 4 "}}`, "", 15, 4}, + {"non numeric usage", `{"model":7,"usage":{"input_tokens":"many","output_tokens":null}}`, "", 0, 0}, + {"negative usage", `{"usage":{"input_tokens":-5}}`, "", 0, 0}, + {"usage not object", `{"model":"jev-1","usage":"none","answers":{}}`, "jev-1", 0, 0}, + {"missing usage", `{"answers":{}}`, "", 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + decoded, err := DecodeSystemOneResponse(strings.NewReader(tc.body)) + require.NoError(t, err) + require.Equal(t, []byte(tc.body), decoded.Body) + require.Equal(t, tc.model, decoded.Model) + require.Equal(t, tc.input, decoded.Usage.InputTokens) + require.Equal(t, tc.output, decoded.Usage.OutputTokens) + }) + } +} + +func TestDecodeSystemOneResponseRejectsNonObjects(t *testing.T) { + for _, body := range []string{`[]`, `"text"`, `null`, `{`, `data: {}`} { + _, err := DecodeSystemOneResponse(strings.NewReader(body)) + require.Error(t, err, body) + } + _, err := DecodeSystemOneResponse(strings.NewReader(`{"pad":"` + strings.Repeat("a", MaxSystemOneResponseBytes) + `"}`)) + require.ErrorIs(t, err, ErrSystemOneResponseTooLarge) +} diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index 12c1630b5089..74a0d2597436 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -123,31 +123,45 @@ func extractProtocolSegments(protocol string, document any) []promptSegment { } // extractSystemOneSegments collects every client-controlled text of a TypeSafe -// System One request. Question instructions and criteria come first and the -// evaluated state last, so the state is the prioritized segment. Object keys are -// visited in sorted order to keep the prompt hash stable across requests. +// System One request: question IDs, every question field except the validated +// type, unknown top-level extension fields, and the evaluated state. Object keys +// are text too (Jev reads the whole JSON), so they are collected with values. +// The state comes last so it is the prioritized segment, and keys are visited +// in sorted order to keep the prompt hash stable across requests. func extractSystemOneSegments(root map[string]any) []promptSegment { if root == nil { return nil } texts := make([]string, 0, 4) - questions, _ := root["questions"].(map[string]any) + questions, isObject := root["questions"].(map[string]any) + if !isObject { + texts = appendJSONStringLeaves(texts, root["questions"]) + } for _, id := range sortedJSONKeys(questions) { + texts = appendJSONStringLeaves(texts, id) question, ok := questions[id].(map[string]any) if !ok { + texts = appendJSONStringLeaves(texts, questions[id]) continue } - texts = appendJSONStringLeaves(texts, question["instructions"]) - criteria, isObject := question["criteria"].(map[string]any) - if !isObject || stringValue(question["type"]) != "choice" { - texts = appendJSONStringLeaves(texts, question["criteria"]) - continue + for _, field := range sortedJSONKeys(question) { + switch field { + case "type": + continue + case "instructions", "criteria": + default: + texts = appendJSONStringLeaves(texts, field) + } + texts = appendJSONStringLeaves(texts, question[field]) } - // Choice option labels are client-chosen text sent to the model as well. - for _, label := range sortedJSONKeys(criteria) { - texts = appendJSONStringLeaves(texts, label) - texts = appendJSONStringLeaves(texts, criteria[label]) + } + for _, field := range sortedJSONKeys(root) { + switch field { + case "model", "stream", "state", "questions": + continue } + texts = appendJSONStringLeaves(texts, field) + texts = appendJSONStringLeaves(texts, root[field]) } texts = appendJSONStringLeaves(texts, root["state"]) return userPromptSegments(texts) @@ -165,6 +179,7 @@ func appendJSONStringLeaves(texts []string, value any) []string { } case map[string]any: for _, key := range sortedJSONKeys(typed) { + texts = appendJSONStringLeaves(texts, key) texts = appendJSONStringLeaves(texts, typed[key]) } } diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index 317cd98df7fe..751043f550ee 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -414,19 +414,22 @@ func metadataTextForTest(scanText string) string { } func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) { - body := `{"model":"jev-latest","state":{"title":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` + + body := `{"model":"jev-latest","state":{"STATE_KEY":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` + `"questions":{"b":{"type":"choice","instructions":"CHOICE_INSTRUCTIONS","criteria":{"OPTION_LABEL":{"description":"OPTION_DESC"},"empty":null}},` + - `"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"}},` + - `"c":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}}}` + `"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"},"EXTENSION_KEY":"EXTENSION_VALUE"},` + + `"QUESTION_ID":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}},"TOP_EXTENSION":{"x":"TOP_VALUE"}}` snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}) require.NoError(t, err) - for _, text := range []string{"STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC", - "NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "SCORE_LOW", "SCORE_HIGH"} { + for _, text := range []string{"STATE_KEY", "STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC", + "NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "EXTENSION_KEY", "EXTENSION_VALUE", "QUESTION_ID", "SCORE_LOW", "SCORE_HIGH", + "TOP_EXTENSION", "TOP_VALUE"} { require.Contains(t, snapshot.ScanText, text) } - require.NotContains(t, snapshot.ScanText, "jev-latest") - require.Equal(t, 12, snapshot.MessageCount) + // Canonical field names and validated enum values are not client text. + for _, text := range []string{"jev-latest", "instructions", "criteria", "questions", "choice", "score"} { + require.NotContains(t, snapshot.ScanText, text) + } // Map iteration order must not change the audited text or its hash. for range 20 { @@ -440,14 +443,22 @@ func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) require.NoError(t, err) require.Contains(t, blocking.ScanText, "STATE_TITLE") require.Contains(t, blocking.ScanText, "CHOICE_INSTRUCTIONS") + require.Contains(t, blocking.ScanText, "QUESTION_ID") } -func TestPromptSnapshotTypeSafeSystemOneStringStateIsAudited(t *testing.T) { +func TestPromptSnapshotTypeSafeSystemOneStringStateIsPrioritized(t *testing.T) { snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":"plain state","questions":{"q":{"type":"noul"}}}`)}) require.NoError(t, err) require.True(t, strings.HasPrefix(snapshot.ScanText, "plain state")) - require.Equal(t, 1, snapshot.MessageCount) + require.Equal(t, 2, snapshot.MessageCount) +} - _, err = ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":{"n":1},"questions":{"q":{"type":"noul"}}}`)}) - require.ErrorIs(t, err, ErrNoPromptText) +func TestPromptSnapshotTypeSafeSystemOneAuditsKeyOnlyPayloads(t *testing.T) { + body := `{"model":"jev-latest","state":{"HIDDEN_STATE_KEY":1},"questions":{"HIDDEN_QUESTION_ID":{"type":"noul"}}}` + for _, latestTurnOnly := range []bool{false, true} { + snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, latestTurnOnly) + require.NoError(t, err) + require.Contains(t, snapshot.ScanText, "HIDDEN_STATE_KEY") + require.Contains(t, snapshot.ScanText, "HIDDEN_QUESTION_ID") + } } diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 18346eb219d4..870ae14c5c6b 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -202,7 +202,8 @@ func RegisterGatewayRoutes( } h.Gateway.Messages(c) }) - gateway.POST("/systemone", h.Gateway.SystemOne) + // System One carries only JSON text, so it uses the text body limit. + gateway.POST("/systemone", textBodyLimit, h.Gateway.SystemOne) // /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates // locally, and Anthropic-compatible platforms retain their existing path. gateway.POST("/messages/count_tokens", countTokensHandler) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ca8246677e19..5262134a2b04 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1014,10 +1014,15 @@ func (a *Account) GetTypeSafeBaseURL() string { if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey { return "" } - if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { - return strings.TrimRight(baseURL, "/") + baseURL := strings.TrimRight(strings.TrimSpace(a.GetCredential("base_url")), "/") + // The System One path already carries /v1; accept a base URL pasted with it. + if len(baseURL) >= 3 && strings.EqualFold(baseURL[len(baseURL)-3:], "/v1") { + baseURL = strings.TrimRight(baseURL[:len(baseURL)-3], "/") + } + if baseURL == "" { + return typesafe.DefaultBaseURL } - return typesafe.DefaultBaseURL + return baseURL } func (a *Account) GetTypeSafeAPIKey() string { diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 2dcdfb84a58c..103c9edc25bc 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -296,6 +297,8 @@ func defaultModelsListCandidateIDs(platform string) []string { return xai.DefaultModelIDs() case PlatformOpenCodeGo: return DefaultOpenCodeGoModelIDs() + case PlatformTypeSafe: + return []string{typesafe.JevLatestModel} case PlatformComposite: return compositeDefaultModelsListCandidateIDs() default: @@ -316,7 +319,10 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} { + // TypeSafe stays out of the static composite candidates (jev-latest only works + // through /v1/systemone); groups with TypeSafe accounts still get it from the + // account model mappings collected by GetGroupModelsListCandidates. + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index 6ef090a10052..b52adfaa21a1 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -65,25 +65,45 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders return out } -// collectSystemOneInput moderates the evaluated state plus every question's -// instructions and criteria: all of them are client text sent to Jev. +// collectSystemOneInput moderates every client-controlled text of a System One +// request: question IDs, every question field except the validated type, +// unknown top-level extension fields, and the evaluated state. Object keys are +// sent to Jev as part of the JSON, so they are moderated like values. func (collector moderationTextCollector) collectSystemOneInput(body []byte, parts *[]string) { - collector.collectSystemOneText(gjson.GetBytes(body, "state"), parts) - gjson.GetBytes(body, "questions").ForEach(func(_, question gjson.Result) bool { - collector.collectSystemOneText(question.Get("instructions"), parts) - criteria := question.Get("criteria") - if question.Get("type").String() == "choice" && criteria.IsObject() { - // Choice option labels are client-chosen text as well. - criteria.ForEach(func(label, description gjson.Result) bool { - collector.addModerationText(parts, label.String()) - collector.collectSystemOneText(description, parts) + root := gjson.ParseBytes(body) + questions := root.Get("questions") + if !questions.IsObject() { + collector.collectSystemOneText(questions, parts) + } + questions.ForEach(func(id, question gjson.Result) bool { + collector.addModerationText(parts, id.String()) + if !question.IsObject() { + collector.collectSystemOneText(question, parts) + return true + } + question.ForEach(func(field, value gjson.Result) bool { + switch field.String() { + case "type": return true - }) + case "instructions", "criteria": + default: + collector.addModerationText(parts, field.String()) + } + collector.collectSystemOneText(value, parts) + return true + }) + return true + }) + root.ForEach(func(field, value gjson.Result) bool { + switch field.String() { + case "model", "stream", "state", "questions": return true } - collector.collectSystemOneText(criteria, parts) + collector.addModerationText(parts, field.String()) + collector.collectSystemOneText(value, parts) return true }) + collector.collectSystemOneText(root.Get("state"), parts) } func (collector moderationTextCollector) collectSystemOneText(value gjson.Result, parts *[]string) { @@ -92,11 +112,17 @@ func (collector moderationTextCollector) collectSystemOneText(value gjson.Result return case value.Type == gjson.String: collector.addModerationText(parts, value.String()) - case value.IsArray(), value.IsObject(): + case value.IsArray(): value.ForEach(func(_, child gjson.Result) bool { collector.collectSystemOneText(child, parts) return true }) + case value.IsObject(): + value.ForEach(func(key, child gjson.Result) bool { + collector.addModerationText(parts, key.String()) + collector.collectSystemOneText(child, parts) + return true + }) } } diff --git a/backend/internal/service/content_moderation_systemone_test.go b/backend/internal/service/content_moderation_systemone_test.go index c9a6ae95056c..8f2eba816fe6 100644 --- a/backend/internal/service/content_moderation_systemone_test.go +++ b/backend/internal/service/content_moderation_systemone_test.go @@ -13,9 +13,10 @@ func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) { want string }{ {"string", `{"state":"plain text"}`, "plain text"}, - {"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "hello world"}, - {"array", `{"state":["first",{"text":"second"},3]}`, "first second"}, - {"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"}}}}`, "state text pick one label a desc a label b low high judge yes"}, + {"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "title hello nested body world"}, + {"array", `{"state":["first",{"text":"second"},3]}`, "first text second"}, + {"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"},"ext":"extra"}},"top":{"k":"v"}}`, "c pick one label a description desc a label b s low text high n judge true yes ext extra top k v state text"}, + {"key only payload", `{"state":{"hidden state key":1},"questions":{"hidden question id":{"type":"noul"}}}`, "hidden question id hidden state key"}, } { t.Run(tc.name, func(t *testing.T) { input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body)) diff --git a/backend/internal/service/gateway_systemone.go b/backend/internal/service/gateway_systemone.go index 261cb74b9efe..0add1999df03 100644 --- a/backend/internal/service/gateway_systemone.go +++ b/backend/internal/service/gateway_systemone.go @@ -67,6 +67,22 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a decoded, err := typesafe.DecodeSystemOneResponse(resp.Body) if err != nil { + // The upstream accepted (and may have charged) this request but the + // gateway cannot relay it; keep an ops trail for reconciliation. + setOpsUpstreamError(c, resp.StatusCode, err.Error(), "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Passthrough: true, + ProxyID: opsUpstreamProxyID(account), + ProxyName: opsUpstreamProxyName(account), + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + UpstreamURL: upstreamURL, + Kind: "response_error", + Message: err.Error(), + }) return nil, err } return &SystemOneForwardResult{ @@ -84,8 +100,19 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a }, nil } +// IsSystemOneRequestErrorStatus reports upstream statuses that describe the +// caller's own payload (malformed, unprocessable, or too large). +func IsSystemOneRequestErrorStatus(status int) bool { + switch status { + case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity: + return true + default: + return false + } +} + // handleSystemOneErrorResponse applies the shared account error policy to a -// non-2xx System One response. 400/422 describe the caller's own payload, so +// non-2xx System One response. 400/413/422 describe the caller's own payload, so // they never touch account state (a tenant must not be able to disable an // account with bad input) and are not retried elsewhere. Every other status // goes through the account error policy (custom error codes, temporary @@ -109,7 +136,7 @@ func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gi Message: upstreamMsg, } - if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity { + if IsSystemOneRequestErrorStatus(resp.StatusCode) { appendOpsUpstreamError(c, event) return &SystemOneUpstreamError{StatusCode: resp.StatusCode} } diff --git a/backend/internal/service/gateway_systemone_test.go b/backend/internal/service/gateway_systemone_test.go index 62b3051ee49c..d3cb1314147d 100644 --- a/backend/internal/service/gateway_systemone_test.go +++ b/backend/internal/service/gateway_systemone_test.go @@ -248,7 +248,7 @@ func systemOneOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent { } func TestForwardSystemOneRequestErrorsNeverTouchAccountState(t *testing.T) { - for _, status := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity} { + for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity} { t.Run(http.StatusText(status), func(t *testing.T) { // Even a custom error-code rule must not let client input disable the account. account := &Account{ID: 21, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{ @@ -357,14 +357,46 @@ func TestForwardSystemOneRejectsOversizedResponse(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(oversized))}, nil }} account := &Account{ID: 26, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} - _, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + c := newSystemOneTestContext() + _, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), c, account, []byte(`{}`)) require.ErrorIs(t, err, typesafe.ErrSystemOneResponseTooLarge) + events := systemOneOpsEvents(t, c) + require.Len(t, events, 1) + require.Equal(t, "response_error", events[0].Kind) + require.Equal(t, http.StatusOK, events[0].UpstreamStatusCode) +} + +func TestForwardSystemOneBillsLenientUsageShapes(t *testing.T) { + upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"answers":{},"usage":{"input_tokens":"21","output_tokens":1.0}}`))}, nil + }} + account := &Account{ID: 27, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}} + result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`)) + require.NoError(t, err) + require.Equal(t, 21, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) } func TestTypeSafeAccountBaseURLNeverFallsBackToAnthropic(t *testing.T) { account := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}} require.Equal(t, typesafe.DefaultBaseURL, account.GetBaseURL()) require.Equal(t, typesafe.DefaultBaseURL, account.GetTypeSafeBaseURL()) + for raw, want := range map[string]string{ + "https://api.typesafe.ai/v1": "https://api.typesafe.ai", + "https://api.typesafe.ai/V1/": "https://api.typesafe.ai", + "https://proxy.example/typesafe/": "https://proxy.example/typesafe", + "https://proxy.example/apiv1": "https://proxy.example/apiv1", + " https://proxy.example/x/v1/ ": "https://proxy.example/x", + "/v1": typesafe.DefaultBaseURL, + } { + withBase := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": raw}} + require.Equal(t, want, withBase.GetTypeSafeBaseURL(), raw) + } anthropic := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}} require.Equal(t, "https://api.anthropic.com", anthropic.GetBaseURL()) } + +func TestTypeSafeModelsListCandidates(t *testing.T) { + require.Equal(t, []string{typesafe.JevLatestModel}, defaultModelsListCandidateIDs(PlatformTypeSafe)) + require.NotContains(t, compositeDefaultModelsListCandidateIDs(), typesafe.JevLatestModel) +} diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 7c2ee3596566..64cea0690098 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4881,6 +4881,8 @@ watch( modelMappings.value = [] if (newPlatform === 'typesafe') { accountCategory.value = 'apikey' + // Grok 等平台会把模式切到映射;TypeSafe 只用白名单写入 jev-latest。 + modelRestrictionMode.value = 'whitelist' allowedModels.value = ['jev-latest'] } // Antigravity: 默认使用映射模式并填充默认映射 From fa49647c6d2d9dd101a19dce674d39c82b88f810 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 14:00:12 +0800 Subject: [PATCH 58/70] =?UTF-8?q?test(model):=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E5=B9=B3=E5=8F=B0=E5=88=97=E8=A1=A8=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E7=BA=B3=E5=85=A5=20typesafe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/error_passthrough_rule_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go index 34ceb414e8fd..17e6fdcbf281 100644 --- a/backend/internal/model/error_passthrough_rule_test.go +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -18,5 +18,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { "deepseek", "minimax", "opencode_go", + "typesafe", }, AllPlatforms()) } From 458b92abd4b0b09d123d4c6727dd8d59060bd883 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:45:18 +0000 Subject: [PATCH 59/70] chore: sync VERSION to 0.2.12 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index d3b5ba4bfc3a..f2722b133960 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.11 +0.2.12 From 66a9e2e6bbf9cf98ed2f63f4d152d8aebd1ba78d Mon Sep 17 00:00:00 2001 From: Kingsley <10992364+kingsleydon@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:45:53 +0000 Subject: [PATCH 60/70] fix(billing): allow upstream billing probe for TypeSafe API-key accounts The create form shows the upstream billing auto-probe toggle for every API-key platform and enables it by default. TypeSafe was added as an API-key platform but not to IsUpstreamBillingProbeIdentity, so creating a TypeSafe account with the default form state fails with 400 UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID ("account is not an API key account"). TypeSafe accounts store credentials.api_key/base_url like every other API-key platform, which is all the probe reads. Add TypeSafe to the probe identity set, and add typesafe.ai to the official API domains so accounts on the default https://api.typesafe.ai base URL record "unsupported" without sending the key to a path that cannot exist, matching the other official providers. Co-Authored-By: Claude Opus 5.5 --- .../service/upstream_billing_probe.go | 4 +++- ...stream_billing_probe_multiplatform_test.go | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 97603793e5ff..adf3d25277fb 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -1013,7 +1013,8 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { } switch platform { case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe: return true default: return false @@ -1052,6 +1053,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "bigmodel.cn", "deepseek.com", "opencode.ai", + "typesafe.ai", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 450cec373c14..57c879954b57 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -17,6 +17,7 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, + PlatformTypeSafe, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) @@ -174,6 +175,9 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te {PlatformDeepseek, "https://api.deepseek.com/anthropic"}, {PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"}, {PlatformOpenCodeGo, "https://opencode.ai/zen/go"}, + // TypeSafe 官方 System One API 同样不提供 /v1/sub2api/billing。 + {PlatformTypeSafe, "https://api.typesafe.ai"}, + {PlatformTypeSafe, "https://api.typesafe.ai/v1"}, } for i, tc := range cases { account := &Account{ @@ -217,6 +221,7 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.typesafe.ai")) // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) @@ -228,6 +233,7 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notmoonshot.cn/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://moonshot.cn.evil.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://kimi.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://nottypesafe.ai")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notbigmodel.cn")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://deepseek.example.com")) } @@ -276,3 +282,18 @@ func TestUpstreamBillingProbeSetAccountEnabledAcceptsGrokAPIKey(t *testing.T) { err := svc.SetAccountEnabled(context.Background(), grokOAuth.ID, true) require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid) } + +// 回归:创建表单对全部 API-key 平台默认开启自动探测;TypeSafe 账号带默认开关创建 +// 不能被 UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID 拒绝。 +func TestBuildAccountForCreateAcceptsTypeSafeAPIKeyWithProbeEnabled(t *testing.T) { + enabled := true + account, err := buildAccountForCreate(&CreateAccountInput{ + Name: "typesafe", + Platform: PlatformTypeSafe, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "ts-key", "base_url": "https://api.typesafe.ai"}, + ProbeEnabled: &enabled, + }, map[string]any{}) + require.NoError(t, err) + require.Equal(t, true, account.Extra[UpstreamBillingProbeEnabledExtraKey]) +} From 8b9788eeb4366995ef33f019220f5051b1c83287 Mon Sep 17 00:00:00 2001 From: Kingsley <10992364+kingsleydon@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:48:37 +0000 Subject: [PATCH 61/70] test(billing): drop added comments Co-Authored-By: Claude Opus 5.5 --- .../service/upstream_billing_probe_multiplatform_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 57c879954b57..2af41cff4ac2 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -175,7 +175,6 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te {PlatformDeepseek, "https://api.deepseek.com/anthropic"}, {PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"}, {PlatformOpenCodeGo, "https://opencode.ai/zen/go"}, - // TypeSafe 官方 System One API 同样不提供 /v1/sub2api/billing。 {PlatformTypeSafe, "https://api.typesafe.ai"}, {PlatformTypeSafe, "https://api.typesafe.ai/v1"}, } @@ -283,8 +282,6 @@ func TestUpstreamBillingProbeSetAccountEnabledAcceptsGrokAPIKey(t *testing.T) { require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid) } -// 回归:创建表单对全部 API-key 平台默认开启自动探测;TypeSafe 账号带默认开关创建 -// 不能被 UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID 拒绝。 func TestBuildAccountForCreateAcceptsTypeSafeAPIKeyWithProbeEnabled(t *testing.T) { enabled := true account, err := buildAccountForCreate(&CreateAccountInput{ From d9d4333795c50a8515f29ab81a188a3266900e78 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 18:59:29 +0800 Subject: [PATCH 62/70] docs: add security policy with private vulnerability reporting Closes #7812 --- .github/SECURITY.md | 103 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000000..40c504c821b1 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,103 @@ +# Security Policy + +[English](#english) | [中文](#中文) + +## English + +### Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.** + +Report them privately through GitHub's private vulnerability reporting: + +**[Report a vulnerability](https://github.com/Wei-Shaw/sub2api/security/advisories/new)** + +(Repository → **Security** tab → **Report a vulnerability**.) Only the maintainers can see the report, and the advisory becomes the place to discuss, fix, and eventually publish the issue. + +A useful report includes: + +- The affected component and version (release tag or commit) +- Impact: what an attacker can do and which privileges they need +- Minimal reproduction steps or a proof of concept +- Any non-default configuration required to trigger the issue +- A suggested fix, if you have one + +### Supported Versions + +Sub2API releases frequently, and fixes ship in a new release rather than being backported. + +| Version | Supported | +| ------- | --------- | +| Latest release | ✅ | +| Older releases | ❌ | + +Please confirm that the issue reproduces on the latest release or on `main` before reporting. + +### Scope + +In scope: the Sub2API backend, frontend, official Docker images, and deployment files in this repository. + +Usually out of scope: + +- Issues that require already-compromised admin credentials or server access +- Risks arising from explicitly weakening documented security settings (for example, disabling `security.url_allowlist`) +- Vulnerabilities in upstream AI providers or third-party dependencies with no Sub2API-specific impact (please report those upstream) +- Missing best-practice headers or scanner output without a demonstrated impact + +### Disclosure Process + +1. We acknowledge the report and assess its validity and severity. +2. We develop a fix privately in the advisory and may ask you to verify it. +3. We publish a release containing the fix, then publish the GitHub Security Advisory (with a CVE when applicable), crediting you unless you prefer to stay anonymous. + +Please keep details private until the advisory is published. This is a volunteer-maintained project, so response times are best effort. + +## 中文 + +### 报告漏洞 + +**请不要通过公开的 Issue、Pull Request 或 Discussion 报告安全漏洞。** + +请使用 GitHub 私密漏洞报告功能提交: + +**[提交漏洞报告](https://github.com/Wei-Shaw/sub2api/security/advisories/new)** + +(仓库 → **Security** 标签页 → **Report a vulnerability**。)报告仅维护者可见,后续的讨论、修复与公告发布都在该安全公告中进行。 + +报告中建议包含: + +- 受影响的组件与版本(Release tag 或 commit) +- 影响:攻击者能做什么、需要什么权限 +- 最小复现步骤或 PoC +- 触发问题所需的非默认配置 +- 修复建议(如有) + +### 支持的版本 + +Sub2API 发版频繁,安全修复随新版本发布,不向旧版本回移。 + +| 版本 | 是否支持 | +| ---- | -------- | +| 最新 Release | ✅ | +| 更早的版本 | ❌ | + +报告前请先确认问题在最新 Release 或 `main` 上仍可复现。 + +### 范围 + +范围内:本仓库中的 Sub2API 后端、前端、官方 Docker 镜像与部署文件。 + +通常不在范围内: + +- 需要事先已掌握管理员凭据或服务器权限才能利用的问题 +- 因主动关闭文档中的安全配置(如关闭 `security.url_allowlist`)而产生的风险 +- 上游 AI 服务商或第三方依赖自身的漏洞,且对 Sub2API 无特定影响(请向上游报告) +- 仅缺少最佳实践响应头或扫描器输出,但未证明实际影响 + +### 披露流程 + +1. 我们确认收到报告,并评估其有效性与严重程度。 +2. 我们在安全公告中私下开发修复,可能请你协助验证。 +3. 我们先发布包含修复的版本,再公开 GitHub 安全公告(适用时申请 CVE),并致谢报告者(如你希望匿名则不署名)。 + +在公告发布前请勿公开漏洞细节。本项目由志愿者维护,响应时间尽力而为。 From 99efaa477f2b487770cde2754163c5e0d60814d7 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 19:04:36 +0800 Subject: [PATCH 63/70] docs(security): add dev@sub2api.org as fallback reporting email --- .github/SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 40c504c821b1..7ef6e7583e0b 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -14,6 +14,8 @@ Report them privately through GitHub's private vulnerability reporting: (Repository → **Security** tab → **Report a vulnerability**.) Only the maintainers can see the report, and the advisory becomes the place to discuss, fix, and eventually publish the issue. +If you cannot use GitHub private vulnerability reporting, email **dev@sub2api.org** instead, with `[Security]` in the subject line. + A useful report includes: - The affected component and version (release tag or commit) @@ -64,6 +66,8 @@ Please keep details private until the advisory is published. This is a volunteer (仓库 → **Security** 标签页 → **Report a vulnerability**。)报告仅维护者可见,后续的讨论、修复与公告发布都在该安全公告中进行。 +如果无法使用 GitHub 私密漏洞报告,也可以发送邮件至 **dev@sub2api.org**,邮件标题请注明 `[Security]`。 + 报告中建议包含: - 受影响的组件与版本(Release tag 或 commit) From c2d5bbd93edfb3a14f7da28d2e62c9216f0bbc86 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 19:51:06 +0800 Subject: [PATCH 64/70] fix(billing): settle usage when the API key is deleted before billing completes When an API key with quota or rate limits was deleted while a request was still in flight, the key counter update matched no rows and returned ErrAPIKeyNotFound, rolling back the whole billing transaction including the balance/subscription charge. Skip the key-scoped counters in that case and keep settling the user and account side as usual. --- .../internal/repository/usage_billing_repo.go | 6 +- .../usage_billing_repo_integration_test.go | 55 ++++++++++++++++ .../usage_billing_repo_unit_test.go | 62 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index ce0adc2772d5..9732d5235528 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -187,16 +187,18 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t result.BalanceOverdrafted = !sufficient } + // API Key 在请求进行中被删除时,其自身的额度/限速计数已无意义,跳过即可; + // 但用户余额、订阅与账号额度必须照常结算,不能因此回滚整个计费事务。 if cmd.APIKeyQuotaCost > 0 { exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost) - if err != nil { + if err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { return err } result.APIKeyQuotaExhausted = exhausted } if cmd.APIKeyRateLimitCost > 0 { - if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil { + if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { return err } } diff --git a/backend/internal/repository/usage_billing_repo_integration_test.go b/backend/internal/repository/usage_billing_repo_integration_test.go index e8d4d32707fb..9f2f0169dcec 100644 --- a/backend/internal/repository/usage_billing_repo_integration_test.go +++ b/backend/internal/repository/usage_billing_repo_integration_test.go @@ -162,6 +162,61 @@ func TestUsageBillingRepositoryApply_RequestFingerprintConflict(t *testing.T) { require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict) } +func TestUsageBillingRepositoryApply_DeletedAPIKeyStillBillsBalance(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewUsageBillingRepository(client, integrationDB) + + user := mustCreateUser(t, client, &service.User{ + Email: fmt.Sprintf("usage-billing-deleted-key-user-%d@example.com", time.Now().UnixNano()), + PasswordHash: "hash", + Balance: 100, + }) + apiKey := mustCreateApiKey(t, client, &service.APIKey{ + UserID: user.ID, + Key: "sk-usage-billing-deleted-key-" + uuid.NewString(), + Name: "billing-deleted-key", + Quota: 50, + RateLimit5h: 50, + }) + account := mustCreateAccount(t, client, &service.Account{ + Name: "usage-billing-deleted-key-account-" + uuid.NewString(), + Type: service.AccountTypeAPIKey, + }) + + _, err := integrationDB.ExecContext(ctx, "UPDATE api_keys SET deleted_at = NOW() WHERE id = $1", apiKey.ID) + require.NoError(t, err) + + requestID := uuid.NewString() + result, err := repo.Apply(ctx, &service.UsageBillingCommand{ + RequestID: requestID, + APIKeyID: apiKey.ID, + UserID: user.ID, + AccountID: account.ID, + AccountType: service.AccountTypeAPIKey, + BalanceCost: 1.25, + APIKeyQuotaCost: 1.25, + APIKeyRateLimitCost: 1.25, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Applied) + require.False(t, result.APIKeyQuotaExhausted) + + var balance float64 + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance)) + require.InDelta(t, 98.75, balance, 0.000001) + + var quotaUsed, usage5h float64 + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT quota_used, usage_5h FROM api_keys WHERE id = $1", apiKey.ID).Scan("aUsed, &usage5h)) + require.InDelta(t, 0, quotaUsed, 0.000001) + require.InDelta(t, 0, usage5h, 0.000001) + + var dedupCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_billing_dedup WHERE request_id = $1 AND api_key_id = $2", requestID, apiKey.ID).Scan(&dedupCount)) + require.Equal(t, 1, dedupCount) +} + func TestUsageBillingRepositoryApply_UpdatesAccountQuota(t *testing.T) { ctx := context.Background() client := testEntClient(t) diff --git a/backend/internal/repository/usage_billing_repo_unit_test.go b/backend/internal/repository/usage_billing_repo_unit_test.go index 6fe4d4fc2632..40394d819c8d 100644 --- a/backend/internal/repository/usage_billing_repo_unit_test.go +++ b/backend/internal/repository/usage_billing_repo_unit_test.go @@ -20,6 +20,8 @@ const ( captureBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance\s+\+ CASE WHEN \$1 > \$2 THEN \$1 - \$2 ELSE 0 END\s+- CASE WHEN \$2 > \$1 THEN \$2 - \$1 ELSE 0 END,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$3 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance` releaseBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance \+ \$1,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance` userExistsForBillingSQL = `(?s)SELECT 1\s+FROM users\s+WHERE id = \$1 AND deleted_at IS NULL` + apiKeyQuotaIncrementSQL = `(?s)UPDATE api_keys\s+SET quota_used = quota_used \+ \$1,.*WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING` + apiKeyRateLimitIncrementSQL = `(?s)UPDATE api_keys SET\s+usage_5h = .*WHERE id = \$2 AND deleted_at IS NULL` ) func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) { @@ -99,6 +101,66 @@ func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +func TestApplyUsageBillingEffects_DeletedAPIKeyStillBillsBalance(t *testing.T) { + ctx := context.Background() + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectBegin() + tx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + mock.ExpectQuery(conditionalBalanceDeductSQL). + WithArgs(10.0, int64(42)). + WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(90.0)) + mock.ExpectQuery(apiKeyQuotaIncrementSQL). + WithArgs(10.0, int64(7), service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted). + WillReturnError(sql.ErrNoRows) + mock.ExpectExec(apiKeyRateLimitIncrementSQL). + WithArgs(10.0, int64(7)). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + result := &service.UsageBillingApplyResult{Applied: true} + err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{ + UserID: 42, + APIKeyID: 7, + BalanceCost: 10, + APIKeyQuotaCost: 10, + APIKeyRateLimitCost: 10, + }, result) + require.NoError(t, err) + require.NotNil(t, result.NewBalance) + require.InDelta(t, 90.0, *result.NewBalance, 0.000001) + require.False(t, result.APIKeyQuotaExhausted) + require.NoError(t, tx.Commit()) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApplyUsageBillingEffects_APIKeyCounterErrorStillFails(t *testing.T) { + ctx := context.Background() + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + mock.ExpectBegin() + tx, err := db.BeginTx(ctx, nil) + require.NoError(t, err) + mock.ExpectExec(apiKeyRateLimitIncrementSQL). + WithArgs(10.0, int64(7)). + WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() + + err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{ + UserID: 42, + APIKeyID: 7, + APIKeyRateLimitCost: 10, + }, &service.UsageBillingApplyResult{Applied: true}) + require.ErrorIs(t, err, sql.ErrConnDone) + require.NoError(t, tx.Rollback()) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) { ctx := context.Background() db, mock, err := sqlmock.New() From 432a6a461f771b22818a88f2c53bf53d1a48adbc Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Oct 2026 19:52:00 +0800 Subject: [PATCH 65/70] chore(billing): simplify comment --- backend/internal/repository/usage_billing_repo.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index 9732d5235528..bfb1bb0de5f4 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -187,8 +187,7 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t result.BalanceOverdrafted = !sufficient } - // API Key 在请求进行中被删除时,其自身的额度/限速计数已无意义,跳过即可; - // 但用户余额、订阅与账号额度必须照常结算,不能因此回滚整个计费事务。 + // Key 已不存在时跳过其自身的额度/限速计数,其余结算项不受影响。 if cmd.APIKeyQuotaCost > 0 { exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost) if err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { From b8dece9000c68815a5b867ca5a1e6f236e173905 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:16:57 +0000 Subject: [PATCH 66/70] chore: sync VERSION to 0.2.13 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index f2722b133960..27792457cb50 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.12 +0.2.13 From bbba01dae905f0036def71cb9fe4785fcab794c9 Mon Sep 17 00:00:00 2001 From: Zero Clover Date: Sun, 4 Oct 2026 05:12:40 +0800 Subject: [PATCH 67/70] fix: enable API-key discovery for remote Codex catalogs --- frontend/src/components/keys/UseKeyModal.vue | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 1de1b7758f99..4b2c0585f810 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -1045,7 +1045,7 @@ ${codexModelCatalogMode.value === 'remote' ? `model_catalog_url = "${escapeTomlB ${generateCodexProviderAuthConfig(apiKey)} [features] -goals = true` +${codexModelCatalogMode.value === 'remote' ? 'api_key_model_discovery = true\n' : ''}goals = true` return buildOpenAICodexFileConfigs(configDir, configContent, apiKey) } @@ -1288,8 +1288,7 @@ requires_openai_auth = false # Grok/Sub2API path is HTTP/SSE; disable WS (Codex may otherwise try WebSocket first) supports_websockets = false -# Optional: -# [features] +${codexModelCatalogMode.value === 'remote' ? '[features]\napi_key_model_discovery = true\n\n# Optional:' : '# Optional:\n# [features]'} # goals = true` return [ @@ -1355,7 +1354,7 @@ base_url = "${baseUrl}" ${codexModelCatalogMode.value === 'remote' ? `model_catalog_url = "${escapeTomlBasicString(buildCodexModelCatalogUrl(baseUrl))}"\n` : ''}env_key = "SUB2API_API_KEY" wire_api = "responses" requires_openai_auth = false -supports_websockets = false` +supports_websockets = false${codexModelCatalogMode.value === 'remote' ? '\n\n[features]\napi_key_model_discovery = true' : ''}` return [ { path: isWindows ? 'PowerShell' : 'Terminal', content: envContent }, @@ -1393,7 +1392,7 @@ supports_websockets = true ${generateCodexProviderAuthConfig(apiKey)} [features] -responses_websockets_v2 = true +${codexModelCatalogMode.value === 'remote' ? 'api_key_model_discovery = true\n' : ''}responses_websockets_v2 = true goals = true` return buildOpenAICodexFileConfigs(configDir, configContent, apiKey) From d1aac6b98989b79d66c0f52bd18dc8c3142e7601 Mon Sep 17 00:00:00 2001 From: "honest.tai" <43802073+honestTai@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:14:56 +0800 Subject: [PATCH 68/70] fix(payment): close EasyPay forged-callback sign reuse (fixes #7881) An order-creation signature (visible to the payer in popup submit.php URLs) could be replayed as a payment-success notification, with no merchant key required. Two compounding defects: - CanonicalizeReturnURL kept client-supplied query parameters, so a return_url ending in '&trade_status=TRADE_SUCCESS' entered the signed order string verbatim; - the sign base string concatenates values unescaped, so a callback that decodes return_url only up to the smuggled pair re-sorts to a byte-identical sign base and passes verification. Fixes: - CanonicalizeReturnURL now drops client query parameters entirely; the server builds its own signed set and the client query has no legitimate use; - VerifyNotification rejects any parameter outside the genuine async notify set (pid/trade_no/out_trade_no/type/name/money/trade_status/ param/sign/sign_type), failing closed against the whole smuggling class regardless of the injection point. Regression tests cover the PoC payload, full order-URL replay, genuine callback acceptance, unknown-parameter rejection (including empty values), and the return_url smuggling strip. Genuinely paid orders that an exotic upstream variant might reject are still recovered through the existing upstream QueryOrder reconcile path. --- backend/internal/payment/provider/easypay.go | 26 +++ .../provider/easypay_notify_security_test.go | 172 ++++++++++++++++++ .../service/payment_resume_service.go | 7 + .../service/payment_resume_service_test.go | 29 ++- 4 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 backend/internal/payment/provider/easypay_notify_security_test.go diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 53e565132798..bbb007bb3ef8 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -376,6 +376,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st // url.ParseQuery already decodes values — no additional decode needed. params := make(map[string]string) for k := range values { + if !easyPayNotifyAllowedParams[k] { + return nil, fmt.Errorf("unexpected notify param: %s", k) + } params[k] = values.Get(k) } sign := params["sign"] @@ -601,3 +604,26 @@ func easyPaySign(params map[string]string, pkey string) string { func easyPayVerifySign(params map[string]string, pkey string, sign string) bool { return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign)) } + +// easyPayNotifyAllowedParams is the exact parameter set a genuine EasyPay +// (彩虹易支付-compatible) async notification carries. Order-creation-only +// fields (notify_url, return_url, cid, device, clientip, ...) must never +// appear in a callback: because the sign base string concatenates values +// unescaped, a signed order URL whose return_url embeds e.g. +// "trade_status=TRADE_SUCCESS" could otherwise be replayed as a forged +// payment-success notification (issue #7881). Rejecting unknown keys closes +// the whole smuggling class; genuinely paid orders rejected by an exotic +// upstream variant are still recovered by the upstream QueryOrder reconcile +// path. +var easyPayNotifyAllowedParams = map[string]bool{ + "pid": true, + "trade_no": true, + "out_trade_no": true, + "type": true, + "name": true, + "money": true, + "trade_status": true, + "param": true, + "sign": true, + "sign_type": true, +} diff --git a/backend/internal/payment/provider/easypay_notify_security_test.go b/backend/internal/payment/provider/easypay_notify_security_test.go new file mode 100644 index 000000000000..66a373e71b17 --- /dev/null +++ b/backend/internal/payment/provider/easypay_notify_security_test.go @@ -0,0 +1,172 @@ +package provider + +// Regression tests for the EasyPay forged-callback vulnerability +// (Wei-Shaw/sub2api issue #7875). +// +// Attack recap: the order-creation signature (visible to the payer in the +// submit.php popup URL) signs return_url among other fields, and the sign +// base string concatenates values unescaped. A client-supplied return_url +// ending in "&trade_status=TRADE_SUCCESS" therefore produced a signature +// that was byte-identical to a payment-success notification's signature +// once the callback decoded return_url partially and promoted the smuggled +// pair to a top-level parameter. Merchant-key rotation could not help +// because the attacker never needed the key. +// +// Fixes covered here: +// 1. service.CanonicalizeReturnURL drops client-supplied query parameters +// (see payment_resume_service_test.go). +// 2. VerifyNotification rejects any parameter outside the genuine async +// notify set, so smuggled order-creation fields (return_url et al.) +// fail closed even if a signed value smuggles a fake pair. + +import ( + "context" + "net/url" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +// easyPayPoCProvider returns a provider with a fixed test credential set. +func easyPayPoCProvider() *EasyPay { + return &EasyPay{config: map[string]string{ + "pid": "1000", + "pkey": "MERCHANT_SECRET_KEY", + "apiBase": "https://pay.example.com", + "notifyUrl": "https://site.example.com/api/v1/payment/webhook/easypay", + "returnUrl": "https://site.example.com/payment/result", + }} +} + +// TestEasyPayNotifyRejectsForgedSignReuseCallback is the exact PoC payload +// that was accepted before the fix: the order's own submit.php signature +// replayed with trade_status smuggled out of the return_url value. +func TestEasyPayNotifyRejectsForgedSignReuseCallback(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + // Order creation (popup mode): return_url carries the smuggled pair as + // its last sorted inner query key, exactly as buildPaymentReturnURL + // would have produced before CanonicalizeReturnURL stripped user query. + returnURL := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS" + createParams := map[string]string{ + "pid": "1000", + "type": "alipay", + "out_trade_no": "ORDER123", + "notify_url": e.config["notifyUrl"], + "return_url": returnURL, + "name": "balance recharge", + "money": "650.00", + } + sign := easyPaySign(createParams, e.config["pkey"]) + + // Forged callback: return_url encoded only up to status=success, then a + // raw &trade_status=TRADE_SUCCESS promotes it to a top-level parameter. + prefix := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success" + cb := url.Values{} + cb.Set("pid", "1000") + cb.Set("type", "alipay") + cb.Set("out_trade_no", "ORDER123") + cb.Set("notify_url", e.config["notifyUrl"]) + cb.Set("name", "balance recharge") + cb.Set("money", "650.00") + cb.Set("return_url", prefix) + rawCallback := cb.Encode() + "&trade_status=TRADE_SUCCESS" + "&sign=" + sign + "&sign_type=MD5" + + if _, err := e.VerifyNotification(context.Background(), rawCallback, nil); err == nil { + t.Fatal("forged sign-reuse callback must be rejected") + } +} + +// TestEasyPayNotifyRejectsOrderURLReplay covers the milder variant where the +// attacker replays the complete signed pay URL unchanged: return_url itself +// is not a legitimate notify parameter and must also be rejected. +func TestEasyPayNotifyRejectsOrderURLReplay(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + createParams := map[string]string{ + "pid": "1000", + "type": "alipay", + "out_trade_no": "ORDER123", + "notify_url": e.config["notifyUrl"], + "return_url": "https://site.example.com/payment/result", + "name": "balance recharge", + "money": "650.00", + } + createParams["sign"] = easyPaySign(createParams, e.config["pkey"]) + createParams["sign_type"] = signTypeMD5 + q := url.Values{} + for k, v := range createParams { + q.Set(k, v) + } + + if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil { + t.Fatal("replayed order URL must be rejected") + } +} + +// TestEasyPayNotifyAcceptsGenuineCallback locks in the legitimate notify +// contract: the canonical parameter set with a valid signature succeeds. +func TestEasyPayNotifyAcceptsGenuineCallback(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + params := map[string]string{ + "pid": "1000", + "trade_no": "2026100622001400000001", + "out_trade_no": "ORDER123", + "type": "alipay", + "name": "balance recharge", + "money": "650.00", + "trade_status": tradeStatusSuccess, + } + sign := easyPaySign(params, e.config["pkey"]) + params["sign"] = sign + params["sign_type"] = signTypeMD5 + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + + n, err := e.VerifyNotification(context.Background(), q.Encode(), nil) + if err != nil { + t.Fatalf("genuine callback rejected: %v", err) + } + if n.Status != payment.ProviderStatusSuccess { + t.Fatalf("status = %v, want success", n.Status) + } + if n.OrderID != "ORDER123" || n.TradeNo != "2026100622001400000001" || n.Amount != 650.00 { + t.Fatalf("unexpected notification: %+v", n) + } +} + +// TestEasyPayNotifyRejectsUnknownParam ensures any parameter outside the +// canonical notify set fails closed, including empty-valued ones that the +// signer itself would skip. +func TestEasyPayNotifyRejectsUnknownParam(t *testing.T) { + t.Parallel() + e := easyPayPoCProvider() + + params := map[string]string{ + "pid": "1000", + "trade_no": "T1", + "out_trade_no": "ORDER123", + "type": "alipay", + "name": "balance recharge", + "money": "650.00", + "trade_status": tradeStatusSuccess, + } + sign := easyPaySign(params, e.config["pkey"]) + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + q.Set("sign", sign) + q.Set("sign_type", signTypeMD5) + q.Set("device", "") // empty value: invisible to the signer, still rejected + + if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil { + t.Fatal("callback with unknown param must be rejected") + } +} diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go index 9ae62fde33b8..7bc76c800962 100644 --- a/backend/internal/service/payment_resume_service.go +++ b/backend/internal/service/payment_resume_service.go @@ -245,6 +245,13 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https") } parsed.Fragment = "" + // Client-supplied query parameters are dropped: buildPaymentReturnURL adds + // its own signed set, and user-controlled pairs such as + // "trade_status=TRADE_SUCCESS" inside the signed return_url value can be + // smuggled into EasyPay-style sign base strings (values are concatenated + // unescaped), letting an order-creation signature be replayed as a forged + // payment-success callback. See issue #7881. + parsed.RawQuery = "" if parsed.Path == "" { parsed.Path = "/" } diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 17b637fa23fe..54b85b4a374d 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "net/url" "strconv" + "strings" "testing" "time" @@ -81,12 +82,30 @@ func TestNormalizePaymentSource(t *testing.T) { func TestCanonicalizeReturnURL(t *testing.T) { t.Parallel() + // Client-supplied query and fragment are both dropped: user-controlled + // pairs inside the signed return_url value could be smuggled into + // EasyPay-style sign base strings (issue #7881). got, err := CanonicalizeReturnURL("https://example.com/payment/result?b=2#a", "example.com", "") if err != nil { t.Fatalf("CanonicalizeReturnURL returned error: %v", err) } - if got != "https://example.com/payment/result?b=2" { - t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://example.com/payment/result?b=2") + if got != "https://example.com/payment/result" { + t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://example.com/payment/result") + } +} + +func TestCanonicalizeReturnURLStripsSmuggledTradeStatus(t *testing.T) { + t.Parallel() + + got, err := CanonicalizeReturnURL( + "https://example.com/payment/result?trade_status=TRADE_SUCCESS", + "example.com", "", + ) + if err != nil { + t.Fatalf("CanonicalizeReturnURL returned error: %v", err) + } + if strings.Contains(got, "trade_status") { + t.Fatalf("CanonicalizeReturnURL kept smuggled trade_status: %q", got) } } @@ -117,8 +136,10 @@ func TestCanonicalizeReturnURLAllowsConfiguredFrontendHost(t *testing.T) { if err != nil { t.Fatalf("CanonicalizeReturnURL returned error: %v", err) } - if got != "https://app.example.com/payment/result?from=checkout" { - t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://app.example.com/payment/result?from=checkout") + // The host allowlist still applies, but client query parameters are + // always dropped (issue #7881: signed-value parameter smuggling). + if got != "https://app.example.com/payment/result" { + t.Fatalf("CanonicalizeReturnURL = %q, want %q", got, "https://app.example.com/payment/result") } } From 6e315616edeeedf55cf477ab8f27cb8d7c68ebfb Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 7 Oct 2026 09:27:36 +0800 Subject: [PATCH 69/70] fix(setup): stop shipping guessable admin credentials on fresh installs (fixes #7850) Fresh Docker installs created the admin as admin@sub2api.local and accepted any ADMIN_PASSWORD (e.g. "123456"), making them easy brute-force targets. - Generate a random admin-<12 hex>@sub2api.local login email when ADMIN_EMAIL is empty; drop the fixed default from .env.example and the compose files, and have docker-deploy.sh write a random ADMIN_EMAIL. - Validate user-supplied ADMIN_PASSWORD (8-72 bytes) and ADMIN_EMAIL on the auto-setup path, only when an admin is actually about to be created, so existing deployments are never blocked at startup. - Align validateEmail with the login endpoint's email binding so setup can no longer create an admin that can never log in (e.g. "a@b", "Name "). - Cap validatePassword at bcrypt's 72-byte limit; the CLI reuses it and now defaults to a random admin email instead of admin@example.com. - Update READMEs/deploy docs to stop suggesting guessable credentials. --- README.md | 10 +- README_CN.md | 10 +- README_JA.md | 10 +- .../integration/e2e_user_flow_test.go | 5 +- backend/internal/setup/cli.go | 25 +- backend/internal/setup/handler.go | 21 +- backend/internal/setup/setup.go | 61 ++++- backend/internal/setup/setup_test.go | 236 ++++++++++++++++++ deploy/.env.example | 8 +- deploy/APPLE_CONTAINER.md | 2 +- deploy/README.md | 16 +- deploy/docker-compose.dev.yml | 2 +- deploy/docker-compose.local.yml | 2 +- deploy/docker-compose.standalone.yml | 2 +- deploy/docker-compose.yml | 2 +- deploy/docker-deploy.sh | 13 + 16 files changed, 368 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 8ec0b7f59c50..1e8ea3f0beed 100644 --- a/README.md +++ b/README.md @@ -354,8 +354,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # Optional: Admin account -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# Leave empty to auto-generate a random email (login username) and password, shown in logs on first startup. +# Avoid guessable values such as admin@example.com: they are brute-force targets. +ADMIN_EMAIL= +ADMIN_PASSWORD= # Optional: Custom port SERVER_PORT=8080 @@ -404,9 +406,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api Open `http://YOUR_SERVER_IP:8080` in your browser. -If admin password was auto-generated, find it in logs: +If the admin email (login username) or password was auto-generated, find them in logs: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### Upgrade diff --git a/README_CN.md b/README_CN.md index 70eef1990ddc..57d92ca92a2f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -380,8 +380,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # 可选:管理员账号 -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# 留空则首次启动时自动生成随机邮箱(登录用户名)和密码,并输出到日志。 +# 请勿使用 admin@example.com 这类可被猜中的值,它们是暴力破解的目标。 +ADMIN_EMAIL= +ADMIN_PASSWORD= # 可选:自定义端口 SERVER_PORT=8080 @@ -442,9 +444,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api 在浏览器中打开 `http://你的服务器IP:8080` -如果管理员密码是自动生成的,在日志中查找: +如果管理员邮箱(登录用户名)或密码是自动生成的,在日志中查找: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### 升级 diff --git a/README_JA.md b/README_JA.md index ca94ba55d39b..24ddd4dd6309 100644 --- a/README_JA.md +++ b/README_JA.md @@ -355,8 +355,10 @@ JWT_SECRET=your_jwt_secret_here TOTP_ENCRYPTION_KEY=your_totp_key_here # オプション: 管理者アカウント -ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=your_admin_password +# 空欄の場合、初回起動時にランダムなメールアドレス(ログインユーザー名)とパスワードが自動生成され、ログに出力されます。 +# admin@example.com のような推測されやすい値は総当たり攻撃の標的になるため避けてください。 +ADMIN_EMAIL= +ADMIN_PASSWORD= # オプション: カスタムポート SERVER_PORT=8080 @@ -405,9 +407,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。 -管理者パスワードが自動生成された場合は、ログで確認できます: +管理者メールアドレス(ログインユーザー名)またはパスワードが自動生成された場合は、ログで確認できます: ```bash -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" ``` #### アップグレード diff --git a/backend/internal/integration/e2e_user_flow_test.go b/backend/internal/integration/e2e_user_flow_test.go index 5489d0a3262a..9a28c6623089 100644 --- a/backend/internal/integration/e2e_user_flow_test.go +++ b/backend/internal/integration/e2e_user_flow_test.go @@ -264,10 +264,11 @@ func loginTestUser(t *testing.T) string { t.Helper() // 先尝试用管理员账户登录 - adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local") + // 管理员邮箱不再有固定默认值(未设置时安装阶段随机生成),需同时提供邮箱与密码。 + adminEmail := getEnv("ADMIN_EMAIL", "") adminPassword := getEnv("ADMIN_PASSWORD", "") - if adminPassword == "" { + if adminEmail == "" || adminPassword == "" { // 尝试用测试用户 adminEmail = testUserEmail adminPassword = testUserPassword diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 2b323acf4abe..8d4e9e419e9d 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -4,7 +4,6 @@ package setup import ( "bufio" "fmt" - "net/mail" "os" "regexp" "strconv" @@ -29,11 +28,6 @@ func cliValidateUsername(name string) bool { return validName.MatchString(name) && len(name) <= 63 } -func cliValidateEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil && len(email) <= 254 -} - func cliValidatePort(port int) bool { return port > 0 && port <= 65535 } @@ -163,9 +157,14 @@ func RunCLI() error { fmt.Println() fmt.Println("── Admin Account ──") + // 默认值使用随机邮箱,避免直接回车得到可被猜中的固定管理员用户名(issue #7850)。 + defaultAdminEmail, err := generateAdminEmail() + if err != nil { + return err + } for { - cfg.Admin.Email = promptString(reader, "Admin Email", "admin@example.com") - if cliValidateEmail(cfg.Admin.Email) { + cfg.Admin.Email = promptString(reader, "Admin Email (login username)", defaultAdminEmail) + if validateEmail(cfg.Admin.Email) { break } fmt.Println(" Invalid email format.") @@ -173,13 +172,9 @@ func RunCLI() error { for { cfg.Admin.Password = promptPassword("Admin Password") - // SECURITY: Match Web API requirement of 8 characters minimum - if len(cfg.Admin.Password) < 8 { - fmt.Println(" Password must be at least 8 characters") - continue - } - if len(cfg.Admin.Password) > 128 { - fmt.Println(" Password must be at most 128 characters") + // SECURITY: Match Web API password requirements + if err := validatePassword(cfg.Admin.Password); err != nil { + fmt.Printf(" Invalid password: %v\n", err) continue } confirm := promptPassword("Confirm Password") diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index 9207b4335c96..e54945061a37 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) // installMutex prevents concurrent installation attempts (TOCTOU protection) @@ -83,19 +84,29 @@ func validateUsername(name string) bool { return validName.MatchString(name) && len(name) <= 63 } -// validateEmail checks if email format is valid +// validateEmail checks if email format is valid. The address must also pass the +// login endpoint's `binding:"email"` rule, otherwise the admin could be created +// with an email (e.g. "a@b" or "Name ") that can never log in. func validateEmail(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil && len(email) <= 254 + if _, err := mail.ParseAddress(email); err != nil || len(email) > 254 { + return false + } + loginReq := struct { + Email string `binding:"required,email"` + }{Email: email} + return binding.Validator.ValidateStruct(&loginReq) == nil } +// maxPasswordBytes is bcrypt's input limit; longer passwords fail to hash. +const maxPasswordBytes = 72 + // validatePassword checks password strength func validatePassword(password string) error { if len(password) < 8 { return fmt.Errorf("password must be at least 8 characters") } - if len(password) > 128 { - return fmt.Errorf("password must be at most 128 characters") + if len(password) > maxPasswordBytes { + return fmt.Errorf("password must be at most %d bytes", maxPasswordBytes) } return nil } diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 963926fa4e02..922ab62469e0 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -417,6 +417,10 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + return bootstrapAdminUser(ctx, db, cfg) +} + +func bootstrapAdminUser(ctx context.Context, db *sql.DB, cfg *SetupConfig) (bool, string, error) { var totalUsers int64 if err := db.QueryRowContext(ctx, "SELECT COUNT(1) FROM users").Scan(&totalUsers); err != nil { return false, "", err @@ -430,12 +434,14 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { return false, decision.reason, nil } - if strings.TrimSpace(cfg.Admin.Password) == "" { - password, genErr := generateSecret(16) - if genErr != nil { - return false, "", fmt.Errorf("failed to generate admin password: %w", genErr) - } - cfg.Admin.Password = password + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&cfg.Admin) + if err != nil { + return false, "", err + } + if emailGenerated { + fmt.Printf("Generated admin email (login username): %s\n", cfg.Admin.Email) + } + if passwordGenerated { fmt.Printf("Generated admin password (one-time): %s\n", cfg.Admin.Password) fmt.Println("IMPORTANT: Save this password! It will not be shown again.") } @@ -473,6 +479,47 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) { return true, decision.reason, nil } +// prepareAdminCredentials fills in missing admin credentials with random values +// and rejects invalid user-supplied emails and weak passwords. It only runs +// when an admin is actually about to be created, so existing deployments are +// never affected. A random email keeps fresh installs off the well-known +// default username that brute-force scanners target (issue #7850). +func prepareAdminCredentials(admin *AdminConfig) (emailGenerated, passwordGenerated bool, err error) { + admin.Email = strings.TrimSpace(admin.Email) + if admin.Email == "" { + email, genErr := generateAdminEmail() + if genErr != nil { + return false, false, genErr + } + admin.Email = email + emailGenerated = true + } else if !validateEmail(admin.Email) { + return false, false, fmt.Errorf("invalid admin email: %q is not a valid login email", admin.Email) + } + + if strings.TrimSpace(admin.Password) == "" { + password, genErr := generateSecret(16) + if genErr != nil { + return false, false, fmt.Errorf("failed to generate admin password: %w", genErr) + } + admin.Password = password + passwordGenerated = true + } else if validateErr := validatePassword(admin.Password); validateErr != nil { + return false, false, fmt.Errorf("invalid admin password: %w", validateErr) + } + + return emailGenerated, passwordGenerated, nil +} + +// generateAdminEmail returns a random, non-guessable admin login email. +func generateAdminEmail() (string, error) { + suffix, err := generateSecret(6) + if err != nil { + return "", fmt.Errorf("failed to generate admin email: %w", err) + } + return fmt.Sprintf("admin-%s@sub2api.local", suffix), nil +} + func writeConfigFile(cfg *SetupConfig) error { // Ensure timezone has a default value tz := cfg.Timezone @@ -596,7 +643,7 @@ func AutoSetupFromEnv() error { EnableTLS: getEnvOrDefault("REDIS_ENABLE_TLS", "false") == "true", }, Admin: AdminConfig{ - Email: getEnvOrDefault("ADMIN_EMAIL", "admin@sub2api.local"), + Email: getEnvOrDefault("ADMIN_EMAIL", ""), Password: getEnvOrDefault("ADMIN_PASSWORD", ""), }, Server: ServerConfig{ diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index 7d4320ada0ae..33ffae25fd7e 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -1,15 +1,19 @@ package setup import ( + "context" "database/sql" "fmt" "os" "path/filepath" + "regexp" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin/binding" "github.com/lib/pq" "gopkg.in/yaml.v3" ) @@ -398,3 +402,235 @@ func TestDatabaseConnectionDoesNotFallbackForTargetAuthenticationError(t *testin t.Fatalf("opened databases = %v, want only configured target %q", opened, cfg.DBName) } } + +func TestPrepareAdminCredentialsGeneratesMissingValues(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: " ", Password: ""} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if !emailGenerated || !passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (true, true)", emailGenerated, passwordGenerated) + } + if !regexp.MustCompile(`^admin-[0-9a-f]{12}@sub2api\.local$`).MatchString(admin.Email) { + t.Fatalf("generated email = %q, want admin-<12 hex>@sub2api.local", admin.Email) + } + // 生成的邮箱必须能通过登录接口的 binding:"required,email" 校验。 + loginReq := struct { + Email string `binding:"required,email"` + }{Email: admin.Email} + if err := binding.Validator.ValidateStruct(&loginReq); err != nil { + t.Fatalf("generated email %q rejected by login validator: %v", admin.Email, err) + } + if len(admin.Password) != 32 { + t.Fatalf("generated password length = %d, want 32", len(admin.Password)) + } + + other := AdminConfig{} + if _, _, err := prepareAdminCredentials(&other); err != nil { + t.Fatalf("prepareAdminCredentials() second call error = %v", err) + } + if other.Email == admin.Email { + t.Fatalf("generated emails should be random, got %q twice", admin.Email) + } +} + +func TestPrepareAdminCredentialsKeepsProvidedValues(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: "a-strong-password"} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if emailGenerated || passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (false, false)", emailGenerated, passwordGenerated) + } + if admin.Email != "owner@example.com" || admin.Password != "a-strong-password" { + t.Fatalf("provided credentials were modified: %+v", admin) + } +} + +func TestPrepareAdminCredentialsRejectsWeakPassword(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + password string + }{ + {name: "too short", password: "123456"}, + {name: "seven chars", password: "1234567"}, + {name: "exceeds bcrypt limit", password: strings.Repeat("a", 73)}, + {name: "former 128 limit", password: strings.Repeat("a", 128)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: tt.password} + _, _, err := prepareAdminCredentials(&admin) + if err == nil || !strings.Contains(err.Error(), "invalid admin password") { + t.Fatalf("prepareAdminCredentials(%q) error = %v, want invalid admin password error", tt.password, err) + } + }) + } +} + +func TestPrepareAdminCredentialsRejectsUnloginableEmail(t *testing.T) { + t.Parallel() + + for _, email := range []string{"admin", "a@b", "Owner ", ""} { + t.Run(email, func(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: email, Password: "a-strong-password"} + _, _, err := prepareAdminCredentials(&admin) + if err == nil || !strings.Contains(err.Error(), "invalid admin email") { + t.Fatalf("prepareAdminCredentials(%q) error = %v, want invalid admin email error", email, err) + } + }) + } +} + +func TestPrepareAdminCredentialsTrimsProvidedEmail(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: " owner@example.com\n", Password: "a-strong-password"} + if _, _, err := prepareAdminCredentials(&admin); err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if admin.Email != "owner@example.com" { + t.Fatalf("email = %q, want trimmed owner@example.com", admin.Email) + } +} + +func TestPrepareAdminCredentialsAcceptsBcryptMaxLengthPassword(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Email: "owner@example.com", Password: strings.Repeat("a", 72)} + if _, _, err := prepareAdminCredentials(&admin); err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + // 校验上限必须与 bcrypt 实际可哈希的上限一致,否则通过校验后仍会在建号时失败。 + user := service.User{} + if err := user.SetPassword(admin.Password); err != nil { + t.Fatalf("SetPassword() with max-length password error = %v", err) + } +} + +func expectAdminBootstrapCounts(mock sqlmock.Sqlmock, totalUsers, adminUsers int64) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(1) FROM users")). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(totalUsers)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(1) FROM users WHERE role = $1")). + WithArgs(service.RoleAdmin). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(adminUsers)) +} + +func TestBootstrapAdminUserSkipsValidationWhenNotCreating(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + totalUsers int64 + adminUsers int64 + reason string + }{ + {name: "admin exists", totalUsers: 3, adminUsers: 1, reason: adminBootstrapReasonAdminExists}, + {name: "users exist without admin", totalUsers: 3, adminUsers: 0, reason: adminBootstrapReasonUsersExistWithoutAdmin}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, tt.totalUsers, tt.adminUsers) + + // 已有部署里遗留的弱密码/非法邮箱不能阻断启动。 + cfg := &SetupConfig{Admin: AdminConfig{Email: "admin", Password: "123456"}} + created, reason, err := bootstrapAdminUser(context.Background(), db, cfg) + if err != nil || created || reason != tt.reason { + t.Fatalf("bootstrapAdminUser() = (%v, %q, %v), want (false, %q, nil)", created, reason, err, tt.reason) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database interaction: %v", err) + } + }) + } +} + +func TestBootstrapAdminUserRejectsWeakPasswordWithoutInsert(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, 0, 0) + + cfg := &SetupConfig{Admin: AdminConfig{Password: "123456"}} + created, _, err := bootstrapAdminUser(context.Background(), db, cfg) + if err == nil || created || !strings.Contains(err.Error(), "invalid admin password") { + t.Fatalf("bootstrapAdminUser() = (%v, %v), want invalid admin password error", created, err) + } + // 未设置 INSERT 期望:若发生插入,sqlmock 会返回非预期调用错误,上面的错误断言即失败。 + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database interaction: %v", err) + } +} + +func TestBootstrapAdminUserCreatesAdminWithGeneratedCredentials(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer func() { _ = db.Close() }() + expectAdminBootstrapCounts(mock, 0, 0) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO users")). + WithArgs( + sqlmock.AnyArg(), sqlmock.AnyArg(), service.RoleAdmin, sqlmock.AnyArg(), + sqlmock.AnyArg(), service.StatusActive, sqlmock.AnyArg(), sqlmock.AnyArg(), + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + + cfg := &SetupConfig{} + created, reason, err := bootstrapAdminUser(context.Background(), db, cfg) + if err != nil || !created || reason != adminBootstrapReasonEmptyDatabase { + t.Fatalf("bootstrapAdminUser() = (%v, %q, %v), want (true, %q, nil)", created, reason, err, adminBootstrapReasonEmptyDatabase) + } + if !regexp.MustCompile(`^admin-[0-9a-f]{12}@sub2api\.local$`).MatchString(cfg.Admin.Email) { + t.Fatalf("admin email = %q, want generated admin-<12 hex>@sub2api.local", cfg.Admin.Email) + } + if len(cfg.Admin.Password) != 32 { + t.Fatalf("admin password length = %d, want generated 32", len(cfg.Admin.Password)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("database expectations not met: %v", err) + } +} + +func TestPrepareAdminCredentialsAcceptsMinimumLengthPassword(t *testing.T) { + t.Parallel() + + admin := AdminConfig{Password: "12345678"} + emailGenerated, passwordGenerated, err := prepareAdminCredentials(&admin) + if err != nil { + t.Fatalf("prepareAdminCredentials() error = %v", err) + } + if !emailGenerated || passwordGenerated { + t.Fatalf("generated flags = (%v, %v), want (true, false)", emailGenerated, passwordGenerated) + } + if admin.Password != "12345678" { + t.Fatalf("password = %q, want unchanged", admin.Password) + } +} diff --git a/deploy/.env.example b/deploy/.env.example index 1299e45c56a6..fa10e83e7910 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -206,10 +206,12 @@ REDIS_ENABLE_TLS=false # ----------------------------------------------------------------------------- # Admin Account # ----------------------------------------------------------------------------- -# Email for the admin account -ADMIN_EMAIL=admin@sub2api.local +# Email for the admin account (used as the login username) +# Leave empty to auto-generate a random one (will be shown in logs on first run). +# Avoid well-known values such as admin@sub2api.local: they are brute-force targets. +ADMIN_EMAIL= -# Password for admin account +# Password for admin account (8-72 bytes) # Leave empty to auto-generate (will be shown in logs on first run) ADMIN_PASSWORD= diff --git a/deploy/APPLE_CONTAINER.md b/deploy/APPLE_CONTAINER.md index 9e44f244086b..f0b86b33d93a 100644 --- a/deploy/APPLE_CONTAINER.md +++ b/deploy/APPLE_CONTAINER.md @@ -41,7 +41,7 @@ nano .env ./apple-container.sh status ``` -Open `http://localhost:8080`. If `ADMIN_PASSWORD` is empty, retrieve the generated password with: +Open `http://localhost:8080`. If `ADMIN_EMAIL` / `ADMIN_PASSWORD` are empty, retrieve the generated admin email (login username) and password with: ```bash ./apple-container.sh logs app diff --git a/deploy/README.md b/deploy/README.md index f38257e397f8..b71334d0a995 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,8 +79,8 @@ docker compose -f docker-compose.local.yml up -d # View logs docker compose -f docker-compose.local.yml logs -f sub2api -# If admin password was auto-generated, find it in logs: -docker compose -f docker-compose.local.yml logs sub2api | grep "admin password" +# If admin email/password were auto-generated, find them in logs: +docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin" # Access Web UI # http://localhost:8080 @@ -112,7 +112,7 @@ mkdir -p data postgres_data redis_data # Start all services using local directory version docker compose -f docker-compose.local.yml up -d -# View logs (check for auto-generated admin password) +# View logs (check for auto-generated admin email and password) docker compose -f docker-compose.local.yml logs -f sub2api # Access Web UI @@ -136,14 +136,14 @@ When using Docker Compose with `AUTO_SETUP=true`: - Connects to PostgreSQL and Redis - Applies database migrations (SQL files in `backend/migrations/*.sql`) and records them in `schema_migrations` - Generates JWT secret (if not provided) - - Creates admin account (password auto-generated if not provided) + - Creates admin account (email and password auto-generated if not provided; a provided password must be 8-72 bytes) - Writes config.yaml 2. No manual Setup Wizard needed - just configure `.env` and start -3. If `ADMIN_PASSWORD` is not set, check logs for the generated password: +3. If `ADMIN_EMAIL` / `ADMIN_PASSWORD` are not set, check logs for the generated admin email (login username) and password: ```bash - docker compose logs sub2api | grep "admin password" + docker compose logs sub2api | grep "Generated admin" ``` ### Startup and Database Recovery @@ -257,8 +257,8 @@ docker compose down -v | `JWT_SECRET` | **Recommended** | *(auto-generated)* | JWT secret (fixed for persistent sessions) | | `TOTP_ENCRYPTION_KEY` | **Recommended** | *(auto-generated)* | TOTP encryption key (fixed for persistent 2FA) | | `SERVER_PORT` | No | `8080` | Server port | -| `ADMIN_EMAIL` | No | `admin@sub2api.local` | Admin email | -| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password | +| `ADMIN_EMAIL` | No | *(auto-generated)* | Admin email (login username) | +| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password (8-72 bytes) | | `TZ` | No | `Asia/Shanghai` | Timezone | | `UPDATE_GITHUB_TOKEN` | No | *(empty)* | Token for `api.github.com` release checks only; asset downloads remain anonymous. | | `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 80d9db178594..45789f48abf4 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -43,7 +43,7 @@ services: - REDIS_USERNAME=${REDIS_USERNAME:-} - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_DB=${REDIS_DB:-0} - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - JWT_SECRET=${JWT_SECRET:-} - SETUP_MIGRATION_TIMEOUT_SECONDS=${SETUP_MIGRATION_TIMEOUT_SECONDS:-0} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 672463c4d7d3..32bc439f4d86 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -88,7 +88,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index abbd8c23f7a6..fe9619df15e0 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -74,7 +74,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3308df0ffe29..84c98643eac2 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -85,7 +85,7 @@ services: # ======================================================================= # Admin Account (auto-created on first run) # ======================================================================= - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # ======================================================================= diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh index a07f4f417a45..97e8e8e04c2e 100644 --- a/deploy/docker-deploy.sh +++ b/deploy/docker-deploy.sh @@ -5,6 +5,7 @@ # This script prepares deployment files for Sub2API: # - Downloads docker-compose.local.yml and .env.example # - Generates secure secrets (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD) +# - Generates a random admin login email (ADMIN_EMAIL) # - Creates necessary data directories # # After running this script, you can start services with: @@ -45,6 +46,14 @@ generate_secret() { openssl rand -hex 32 } +# Generate a random admin login email so new installs never use a guessable default +generate_admin_email() { + local suffix + suffix=$(openssl rand -hex 6) || return 1 + [ -n "$suffix" ] || return 1 + echo "admin-${suffix}@sub2api.local" +} + # Check if command exists command_exists() { command -v "$1" >/dev/null 2>&1 @@ -104,6 +113,7 @@ main() { JWT_SECRET=$(generate_secret) TOTP_ENCRYPTION_KEY=$(generate_secret) POSTGRES_PASSWORD=$(generate_secret) + ADMIN_EMAIL=$(generate_admin_email) # Create .env from .env.example cp .env.example .env @@ -114,11 +124,13 @@ main() { sed -i "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env sed -i "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + sed -i "s/^ADMIN_EMAIL=.*/ADMIN_EMAIL=${ADMIN_EMAIL}/" .env else # BSD sed (macOS) sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env sed -i '' "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + sed -i '' "s/^ADMIN_EMAIL=.*/ADMIN_EMAIL=${ADMIN_EMAIL}/" .env fi # Create data directories @@ -139,6 +151,7 @@ main() { echo " POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}" echo " JWT_SECRET: ${JWT_SECRET}" echo " TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY}" + echo " ADMIN_EMAIL: ${ADMIN_EMAIL} (admin login username)" echo "" print_warning "These credentials have been saved to .env file." print_warning "Please keep them secure and do not share publicly!" From 0f9d460dc65d2509107aff542459e18677e06291 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 7 Oct 2026 09:44:01 +0800 Subject: [PATCH 70/70] fix(deps): resolve frontend audit failures (vue, source-map-js, xlsx exceptions) - Bump vue to ^3.5.43 so @vue/server-renderer is >= 3.5.42 (GHSA-g2v6-rqmx-r4w6). - Override source-map-js to >= 1.2.2 (GHSA-68fv-2mgg-jv7q). - Renew the expired xlsx exceptions until 2027-01-06: xlsx has no patched release on npm and is only used for XLSX.write export in the admin usage view; both advisories require parsing a crafted file and no read path exists. Lockfile regenerated with pnpm 9 to match CI. --- .github/audit-exceptions.yml | 12 +- frontend/package.json | 5 +- frontend/pnpm-lock.yaml | 250 +++++++++++++++++++++++------------ 3 files changed, 172 insertions(+), 95 deletions(-) diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml index 6243f0fa7faa..5881136f578d 100644 --- a/.github/audit-exceptions.yml +++ b/.github/audit-exceptions.yml @@ -3,16 +3,16 @@ exceptions: - package: xlsx advisory: "GHSA-4r6h-8v6p-xvw6" severity: high - reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)" - mitigation: "Load only on export; restrict export permissions and data scope" - expires_on: "2026-10-06" + reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2023-30533)" + mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry" + expires_on: "2027-01-06" owner: "security@your-domain" - package: xlsx advisory: "GHSA-5pgg-2g8v-p4x9" severity: high - reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)" - mitigation: "Load only on export; restrict export permissions and data scope" - expires_on: "2026-10-06" + reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2024-22363)" + mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry" + expires_on: "2027-01-06" owner: "security@your-domain" - package: lodash advisory: "GHSA-r5fr-rjxr-66jc" diff --git a/frontend/package.json b/frontend/package.json index 19d2303c72ef..e714a702f834 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "marked": "^17.0.1", "pinia": "^2.1.7", "qrcode": "^1.5.4", - "vue": "^3.4.0", + "vue": "^3.5.43", "vue-chartjs": "^5.3.0", "vue-draggable-plus": "^0.6.1", "vue-i18n": "^9.14.5", @@ -65,7 +65,8 @@ "js-cookie": "3.0.7", "form-data@<4.0.6": ">=4.0.6", "postcss@<8.5.18": ">=8.5.18", - "dompurify@<3.4.14": ">=3.4.14" + "dompurify@<3.4.14": ">=3.4.14", + "source-map-js@<1.2.2": ">=1.2.2" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8df2e3e15360..1e36493ef0e6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: form-data@<4.0.6: '>=4.0.6' postcss@<8.5.18: '>=8.5.18' dompurify@<3.4.14: '>=3.4.14' + source-map-js@<1.2.2: '>=1.2.2' importers: @@ -25,10 +26,10 @@ importers: version: 9.0.1 '@tanstack/vue-virtual': specifier: ^3.13.23 - version: 3.13.23(vue@3.5.26(typescript@5.6.3)) + version: 3.13.23(vue@3.5.43(typescript@5.6.3)) '@vueuse/core': specifier: ^10.7.0 - version: 10.11.1(vue@3.5.26(typescript@5.6.3)) + version: 10.11.1(vue@3.5.43(typescript@5.6.3)) axios: specifier: ^1.20.0 version: 1.20.0 @@ -49,25 +50,25 @@ importers: version: 17.0.1 pinia: specifier: ^2.1.7 - version: 2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)) + version: 2.3.1(typescript@5.6.3)(vue@3.5.43(typescript@5.6.3)) qrcode: specifier: ^1.5.4 version: 1.5.4 vue: - specifier: ^3.4.0 - version: 3.5.26(typescript@5.6.3) + specifier: ^3.5.43 + version: 3.5.43(typescript@5.6.3) vue-chartjs: specifier: ^5.3.0 - version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3)) + version: 5.3.3(chart.js@4.5.1)(vue@3.5.43(typescript@5.6.3)) vue-draggable-plus: specifier: ^0.6.1 version: 0.6.1(@types/sortablejs@1.15.9) vue-i18n: specifier: ^9.14.5 - version: 9.14.5(vue@3.5.26(typescript@5.6.3)) + version: 9.14.5(vue@3.5.43(typescript@5.6.3)) vue-router: specifier: ^4.2.5 - version: 4.6.4(vue@3.5.26(typescript@5.6.3)) + version: 4.6.4(vue@3.5.43(typescript@5.6.3)) xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -98,7 +99,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.6.3) '@vitejs/plugin-vue': specifier: ^5.2.3 - version: 5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.26(typescript@5.6.3)) + version: 5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.43(typescript@5.6.3)) '@vitest/coverage-v8': specifier: ^2.1.9 version: 2.1.9(vitest@2.1.9(@types/node@20.19.27)(jsdom@24.1.3)) @@ -216,15 +217,28 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -245,6 +259,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@base-ui/react@1.3.0': resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==} engines: {node: '>=14.0.0'} @@ -1692,14 +1710,20 @@ packages: '@vue/compiler-core@3.5.26': resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + '@vue/compiler-dom@3.5.26': resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} - '@vue/compiler-sfc@3.5.26': - resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==} + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} - '@vue/compiler-ssr@3.5.26': - resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==} + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} @@ -1715,23 +1739,24 @@ packages: typescript: optional: true - '@vue/reactivity@3.5.26': - resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} - '@vue/runtime-core@3.5.26': - resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==} + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} - '@vue/runtime-dom@3.5.26': - resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} - '@vue/server-renderer@3.5.26': - resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==} - peerDependencies: - vue: 3.5.26 + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} '@vue/shared@3.5.26': resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + '@vue/test-utils@2.4.6': resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} @@ -2342,6 +2367,10 @@ packages: resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3304,6 +3333,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3532,6 +3566,10 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3928,8 +3966,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map@0.5.7: @@ -4390,8 +4428,8 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@3.5.26: - resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -4620,12 +4658,20 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/runtime@7.28.4': {} '@babel/runtime@7.29.2': {} @@ -4653,6 +4699,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@base-ui/react@1.3.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.2 @@ -4988,7 +5039,7 @@ snapshots: '@intlify/message-compiler@9.14.5': dependencies: '@intlify/shared': 9.14.5 - source-map-js: 1.2.1 + source-map-js: 1.2.2 '@intlify/shared@9.14.5': {} @@ -5044,7 +5095,7 @@ snapshots: '@lobehub/icons@4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@lobehub/ui': 4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.26(typescript@5.6.3)) + '@lobehub/ui': 4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.43(typescript@5.6.3)) antd: 6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) antd-style: 4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) lucide-react: 0.469.0(react@19.2.3) @@ -5055,7 +5106,7 @@ snapshots: - '@types/react' - supports-color - '@lobehub/ui@4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.26(typescript@5.6.3))': + '@lobehub/ui@4.9.2(@lobehub/fluent-emoji@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@lobehub/icons@4.0.2)(@types/mdast@4.0.4)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(micromark-util-types@2.0.2)(micromark@4.0.2)(motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.43(typescript@5.6.3))': dependencies: '@ant-design/cssinjs': 2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@base-ui/react': 1.3.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -5120,7 +5171,7 @@ snapshots: remark-github: 12.0.0 remark-math: 6.0.0 shiki: 3.23.0 - shiki-stream: 0.1.4(react@19.2.3)(vue@3.5.26(typescript@5.6.3)) + shiki-stream: 0.1.4(react@19.2.3)(vue@3.5.43(typescript@5.6.3)) swr: 2.4.1(react@19.2.3) ts-md5: 2.0.1 unified: 11.0.5 @@ -5872,10 +5923,10 @@ snapshots: '@tanstack/virtual-core@3.13.23': {} - '@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))': + '@tanstack/vue-virtual@3.13.23(vue@3.5.43(typescript@5.6.3))': dependencies: '@tanstack/virtual-core': 3.13.23 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) '@types/d3-array@3.2.2': {} @@ -6147,10 +6198,10 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.3 - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.26(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.27))(vue@3.5.43(typescript@5.6.3))': dependencies: vite: 5.4.21(@types/node@20.19.27) - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.19.27)(jsdom@24.1.3))': dependencies: @@ -6228,29 +6279,42 @@ snapshots: '@vue/shared': 3.5.26 entities: 7.0.0 estree-walker: 2.0.2 - source-map-js: 1.2.1 + source-map-js: 1.2.2 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.2 '@vue/compiler-dom@3.5.26': dependencies: '@vue/compiler-core': 3.5.26 '@vue/shared': 3.5.26 - '@vue/compiler-sfc@3.5.26': + '@vue/compiler-dom@3.5.43': dependencies: - '@babel/parser': 7.28.5 - '@vue/compiler-core': 3.5.26 - '@vue/compiler-dom': 3.5.26 - '@vue/compiler-ssr': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.23 - source-map-js: 1.2.1 + postcss: 8.5.29 + source-map-js: 1.2.2 - '@vue/compiler-ssr@3.5.26': + '@vue/compiler-ssr@3.5.43': dependencies: - '@vue/compiler-dom': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 '@vue/compiler-vue2@2.7.16': dependencies: @@ -6272,50 +6336,52 @@ snapshots: optionalDependencies: typescript: 5.6.3 - '@vue/reactivity@3.5.26': + '@vue/reactivity@3.5.43': dependencies: - '@vue/shared': 3.5.26 + '@vue/shared': 3.5.43 - '@vue/runtime-core@3.5.26': + '@vue/runtime-core@3.5.43': dependencies: - '@vue/reactivity': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 - '@vue/runtime-dom@3.5.26': + '@vue/runtime-dom@3.5.43': dependencies: - '@vue/reactivity': 3.5.26 - '@vue/runtime-core': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 csstype: 3.2.3 - '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.6.3))': + '@vue/server-renderer@3.5.43': dependencies: - '@vue/compiler-ssr': 3.5.26 - '@vue/shared': 3.5.26 - vue: 3.5.26(typescript@5.6.3) + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 '@vue/shared@3.5.26': {} + '@vue/shared@3.5.43': {} + '@vue/test-utils@2.4.6': dependencies: js-beautify: 1.15.4 vue-component-type-helpers: 2.2.12 - '@vueuse/core@10.11.1(vue@3.5.26(typescript@5.6.3))': + '@vueuse/core@10.11.1(vue@3.5.43(typescript@5.6.3))': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 10.11.1 - '@vueuse/shared': 10.11.1(vue@3.5.26(typescript@5.6.3)) - vue-demi: 0.14.10(vue@3.5.26(typescript@5.6.3)) + '@vueuse/shared': 10.11.1(vue@3.5.43(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' - vue '@vueuse/metadata@10.11.1': {} - '@vueuse/shared@10.11.1(vue@3.5.26(typescript@5.6.3))': + '@vueuse/shared@10.11.1(vue@3.5.43(typescript@5.6.3))': dependencies: - vue-demi: 0.14.10(vue@3.5.26(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -6969,6 +7035,8 @@ snapshots: entities@7.0.0: {} + entities@7.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7812,7 +7880,7 @@ snapshots: dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -8400,6 +8468,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.20: {} + natural-compare@1.4.0: {} node-releases@2.0.27: {} @@ -8532,11 +8602,11 @@ snapshots: pify@2.3.0: {} - pinia@2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)): + pinia@2.3.1(typescript@5.6.3)(vue@3.5.43(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.6.3) - vue-demi: 0.14.10(vue@3.5.26(typescript@5.6.3)) + vue: 3.5.43(typescript@5.6.3) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.6.3)) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -8598,7 +8668,13 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 prelude-ls@1.2.1: {} @@ -9080,12 +9156,12 @@ snapshots: shebang-regex@3.0.0: {} - shiki-stream@0.1.4(react@19.2.3)(vue@3.5.26(typescript@5.6.3)): + shiki-stream@0.1.4(react@19.2.3)(vue@3.5.43(typescript@5.6.3)): dependencies: '@shikijs/core': 3.23.0 optionalDependencies: react: 19.2.3 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) shiki@3.23.0: dependencies: @@ -9104,7 +9180,7 @@ snapshots: slash@3.0.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map@0.5.7: {} @@ -9504,16 +9580,16 @@ snapshots: vscode-uri@3.1.0: {} - vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3)): + vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.43(typescript@5.6.3)): dependencies: chart.js: 4.5.1 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) vue-component-type-helpers@2.2.12: {} - vue-demi@0.14.10(vue@3.5.26(typescript@5.6.3)): + vue-demi@0.14.10(vue@3.5.43(typescript@5.6.3)): dependencies: - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9): dependencies: @@ -9532,17 +9608,17 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@9.14.5(vue@3.5.26(typescript@5.6.3)): + vue-i18n@9.14.5(vue@3.5.43(typescript@5.6.3)): dependencies: '@intlify/core-base': 9.14.5 '@intlify/shared': 9.14.5 '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) - vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)): + vue-router@4.6.4(vue@3.5.43(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.6.3) + vue: 3.5.43(typescript@5.6.3) vue-tsc@2.2.12(typescript@5.6.3): dependencies: @@ -9550,13 +9626,13 @@ snapshots: '@vue/language-core': 2.2.12(typescript@5.6.3) typescript: 5.6.3 - vue@3.5.26(typescript@5.6.3): + vue@3.5.43(typescript@5.6.3): dependencies: - '@vue/compiler-dom': 3.5.26 - '@vue/compiler-sfc': 3.5.26 - '@vue/runtime-dom': 3.5.26 - '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.6.3)) - '@vue/shared': 3.5.26 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 optionalDependencies: typescript: 5.6.3