diff --git a/README.md b/README.md index 3d6fd891be..ec11a1d2df 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 9a4907c461..6919c70082 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 20c0b3aa14..663be0a6ec 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 e82c1df1dc..e1e524c99f 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 3b65ac97a8..99765c97df 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 d735c54707..43e475835a 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 4af0492550..774a00f882 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 0c820868b4..d5d48845e1 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 2115c2d7c0..82d8279e90 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 aaa7e9f8fd..6941e56374 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 c141431515..54be03ed1e 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 26a5b6107a..fc590a35df 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 26c0c72ec4..0f9e57bc5e 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 0000000000..7c2713e79d --- /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 0000000000..bf60bf4990 --- /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 35783249e0..3c66d8c314 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"` } +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, 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) { - endpoint, err := url.JoinPath(strings.TrimRight(baseURL, "/"), "/v1/systemone") - if err != nil { - return nil, 0, errors.New("typesafe invalid endpoint") - } 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 0000000000..0222289c13 --- /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 0000000000..a5056bd358 --- /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 986a691a5a..96598de2c5 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 86f605993a..18346eb219 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 85c5099342..1115bd0d8d 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 af4272cb89..2d82f7c1da 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 278d359c65..81ee55a6fe 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 205907abc1..bbf668eaff 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 5d778b75ce..44f5dc01a5 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 c779f5b7f6..2dcdfb84a5 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 55e4f08d9b..544ec6a92b 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 0a68e07ad7..89598fb10b 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 0d34f44014..6d63ade999 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 9b85aa3e00..62ba8e0b9a 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 6ddb9cfa9a..c10e335cbe 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 20505a3985..4b11366a29 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 cb000e2a0a..ee162a0234 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 013c10c13d..70b9232c23 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 0000000000..993a71fa7e --- /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 883ac9f0d5..002b2c18b8 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 0000000000..f9201f91e8 --- /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 0000000000..a69964aa8e --- /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 14d43f0c87..6f999c189c 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 4c8c06a7a3..79d836853c 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 738e0531c1..eb74fd9c50 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 c6616c679f..88a32c2256 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 4e9bbff7a1..fba46ab551 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 57cb4b394d..2e5d47be67 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 50740b7cef..34936f07b5 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 18308b4900..520a3f0055 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 cf3b4d0eda..f59d262904 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 533d1a3225..9a9928a6b3 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 293172bf8f..e7d9f79ae9 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 a2ab9ed5cf..284409d56e 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 91a847bf19..083f7a5c81 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 af4fc9ab49..c4c1b8ec72 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 1b46eb1c63..048ecfc57b 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 b25f0f8c87..1844d8e66a 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 725cdada28..2eb6c30b57 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 76be95b4d2..e792a43f6c 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 fc4bb94426..0321dc59fd 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 b3b3e706d2..2cdd3d0ccc 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 d74ff78d99..cbe1e3896d 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 80cef707bd..5c2ada799d 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 f2c80c6be1..87010a0101 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 ceb8f3a2e2..7c206ee46d 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 ace4b14519..c255267407 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 c25b06eb3b..a468ce5959 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 8491ff9e30..2a78347668 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 d02356f104..4d26fbcd1e 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) }) }) })