Merge pull request #7425 from aofee/codex/jev-system-one

feat: add native TypeSafe Jev System One support
This commit is contained in:
Wesley Liddick
2026-10-02 14:16:08 +08:00
committed by GitHub
78 changed files with 2322 additions and 64 deletions
+25
View File
@@ -827,6 +827,31 @@ 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.
Question validation follows the TypeSafe OpenAPI wire schema (also used by SDK v0.5.7). `instructions` may be omitted or `null` for all question types. Noul `criteria` may be omitted or `null`; its `true`/`false` descriptions and Choice descriptions accept strings, objects, arrays, or `null`. Score `criteria` must be a non-empty array of string, object, or array descriptions; a single level is valid. SDK integer-keyed Score maps are normalized to arrays by the SDK before sending.
```bash
curl https://your-sub2api.example.com/v1/systemone \
-H 'Authorization: Bearer sk-your-sub2api-key' \
-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, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400`, `413`, and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`.
---
## Antigravity Support
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
+25
View File
@@ -743,6 +743,31 @@ 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 或流式客户端。
问题校验遵循 TypeSafe OpenAPI 的线上协议 schema(SDK v0.5.7 也使用该 schema)。所有问题的 `instructions` 都可以省略或为 `null`。Noul 的 `criteria` 可以省略或为 `null`,其中 `true`/`false` 的描述和 Choice 描述支持字符串、对象、数组或 `null`。Score 的 `criteria` 必须是至少包含一档描述的数组,每档支持字符串、对象或数组;单档也合法。SDK 的整数键 Score 映射会由 SDK 在发送前转换为数组。
```bash
curl https://your-sub2api.example.com/v1/systemone \
-H 'Authorization: Bearer sk-your-sub2api-key' \
-H 'Content-Type: application/json' \
--data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}'
```
`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`413`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。
---
## Antigravity 使用说明
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
+1 -1
View File
@@ -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)
+1
View File
@@ -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"
@@ -27,6 +27,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -2950,6 +2951,12 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
return
}
// TypeSafe accounts serve only the native System One model.
if account.IsTypeSafe() {
response.Success(c, []claude.Model{{ID: typesafe.JevLatestModel, Type: "model", DisplayName: typesafe.JevLatestModel}})
return
}
// Handle Claude/Anthropic accounts
// For OAuth and Setup-Token accounts: return default models
if account.IsOAuth() {
@@ -526,3 +526,36 @@ func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarnin
require.Len(t, resp.Data.Warnings, 1)
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
}
func TestAccountHandlerGetAvailableModels_TypeSafeOnlyReturnsJev(t *testing.T) {
for _, credentials := range []map[string]any{
{"api_key": "ts-secret"},
{"api_key": "ts-secret", "model_mapping": map[string]any{"jev-latest": "jev-latest"}},
} {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 46,
Name: "typesafe",
Platform: service.PlatformTypeSafe,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Credentials: credentials,
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil))
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 1)
require.Equal(t, "jev-latest", resp.Data[0].ID)
}
}
@@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
service.PlatformTypeSafe: "typesafe",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -565,7 +565,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)
@@ -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"`
@@ -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))
+6
View File
@@ -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
@@ -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},
+26 -4
View File
@@ -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"
@@ -222,6 +223,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) {
return
}
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage {
h.anthropicSecurityAuditError(c, decision)
@@ -1162,7 +1166,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, true)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
@@ -1206,6 +1210,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)
}
@@ -1257,7 +1265,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
platform = group.Platform
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(ctx, groupID)
availableModels := h.compositeAvailableModels(ctx, groupID, false)
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
if group.ModelAllowlistEnabled() {
source := availableModels
@@ -1283,14 +1291,20 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
return fallbackModels
}
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
// compositeAvailableModels lists the models the composite group can serve.
// includeSystemOne adds TypeSafe models, which only work through /v1/systemone;
// LLM client catalogs (Codex) must exclude them.
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, includeSystemOne bool) []string {
if h == nil || h.gatewayService == nil {
return nil
}
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} {
if platform == service.PlatformTypeSafe && !includeSystemOne {
continue
}
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
@@ -1474,9 +1488,14 @@ 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{})
// TypeSafe is deliberately absent: jev-latest only works through
// /v1/systemone, so the static fallback never advertises it to LLM
// clients. compositeAvailableModels lists it when the group can serve it.
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
@@ -2162,6 +2181,9 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) {
return
}
setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false)))
@@ -82,6 +82,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.chatCompletionsErrorResponse) {
return
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -82,6 +82,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if rejectSystemOneOnlyPlatform(c, apiKey, h.responsesErrorResponse) {
return
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -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 {
@@ -932,6 +932,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
@@ -1522,3 +1526,47 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t
})
}
}
// Scenario: jev-latest only works through /v1/systemone, so Composite groups list
// it in /v1/models only when they can serve it, and never in the Codex manifest.
func TestGatewayModels_CompositeTypeSafeListingScope(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(66)
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAnthropic}, {ID: 2, Platform: service.PlatformTypeSafe, Type: service.AccountTypeAPIKey}},
},
})
newContext := func(path string) (*gin.Context, *httptest.ResponseRecorder) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
return c, rec
}
c, rec := newContext("/v1/models")
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var models gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models))
require.Contains(t, modelIDsForTest(models.Data), "jev-latest")
require.Contains(t, modelIDsForTest(models.Data), "claude-opus-4-6")
c, rec = newContext("/models?client_version=0.147.0")
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var manifest codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest))
slugs := codexModelSlugsForTest(manifest.Models)
require.Contains(t, slugs, "claude-opus-4-6")
require.NotContains(t, slugs, "jev-latest")
}
func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing.T) {
require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest")
require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest")
}
@@ -0,0 +1,287 @@
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"
)
const systemOneOnlyPlatformMessage = "TypeSafe models are only available through POST /v1/systemone"
// rejectSystemOneOnlyPlatform stops TypeSafe traffic from entering a non System
// One protocol chain. TypeSafe accounts only speak the native System One
// protocol; letting them reach the Anthropic/OpenAI converters would send
// foreign payloads (and the account key) to the wrong upstream path and feed
// the resulting auth failures back into account state.
func rejectSystemOneOnlyPlatform(c *gin.Context, apiKey *service.APIKey, writeError func(*gin.Context, int, string, string)) bool {
if _, forced := middleware2.GetForcePlatformFromContext(c); forced {
return false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformTypeSafe {
return false
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
writeError(c, http.StatusNotFound, "not_found_error", systemOneOnlyPlatformMessage)
return true
}
// SystemOne proxies TypeSafe's native, non-streaming System One protocol.
func (h *GatewayHandler) SystemOne(c *gin.Context) {
requestStart := time.Now()
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 {
reqLog.Warn("systemone.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", false)
return
}
// 在请求结束或 Context 取消时确保释放槽位,避免客户端断开造成泄漏。
userRelease = wrapReleaseOnDone(c.Request.Context(), userRelease)
if userRelease != nil {
defer userRelease()
}
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("systemone.billing_eligibility_check_failed", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(model, body))
if err != nil {
reqLog.Info("systemone.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
defer inflightRelease()
fs := NewFailoverState(h.maxAccountSwitches, false)
for {
if failoverClientGone(c) {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", model, fs.FailedAccountIDs, "", subject.UserID)
if err == nil && (selection == nil || selection.Account == nil) {
err = service.ErrNoAvailableAccounts
}
if err != nil {
if failoverClientGone(c) {
reqLog.Info("systemone.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, model, model, service.PlatformTypeSafe)
cls = classifySelectionFailureError(err, cls)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
reqLog.Warn("systemone.select_account_no_available", zap.Bool("model_not_found", cls.ModelNotFound), zap.Error(err))
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
switch fs.HandleSelectionExhausted(c.Request.Context()) {
case FailoverContinue:
continue
case FailoverCanceled:
failoverClientGone(c)
return
default:
if fs.LastFailoverErr != nil {
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false)
} else {
h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, false)
}
return
}
}
account := selection.Account
accountRelease := selection.ReleaseFunc
if !selection.Acquired {
if selection.WaitPlan == nil {
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
accountRelease, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted)
if err != nil {
reqLog.Warn("systemone.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
h.handleConcurrencyError(c, err, "account", false)
return
}
}
// 准入终检:与其他网关入口一致,利润控制否决的账号不得承接本次请求。
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
if accountRelease != nil {
accountRelease()
}
reqLog.Debug("systemone.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("systemone.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
return
}
continue
}
account = latest
accountRelease = wrapReleaseOnDone(c.Request.Context(), accountRelease)
setOpsSelectedAccount(c, account.ID, account.Platform)
service.SetOpsUpstreamModel(c, model)
forwardStart := time.Now()
result, forwardErr := h.gatewayService.ForwardSystemOne(c.Request.Context(), c, account, body)
if accountRelease != nil {
accountRelease()
}
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
if forwardErr != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(forwardErr, &failoverErr) {
switch fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) {
case FailoverContinue:
reqLog.Warn("systemone.upstream_failover_switching",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("switch_count", fs.SwitchCount),
)
continue
case FailoverExhausted:
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false)
return
case FailoverCanceled:
failoverClientGone(c)
return
}
}
if failoverClientGone(c) {
return
}
var upstreamErr *service.SystemOneUpstreamError
if errors.As(forwardErr, &upstreamErr) {
status := upstreamErr.StatusCode
if !service.IsSystemOneRequestErrorStatus(status) {
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))
if errors.Is(forwardErr, typesafe.ErrSystemOneResponseTooLarge) {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe response exceeds the gateway size limit")
return
}
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe upstream request failed")
return
}
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) 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))
}
})
}
@@ -0,0 +1,109 @@
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")
}
func newTypeSafeGroupContext(t *testing.T, path, body, groupPlatform string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
c, recorder := newSystemOneHandlerContext(body)
c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
groupID := int64(9)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: groupPlatform}})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1})
return c, recorder
}
func TestRejectSystemOneOnlyPlatform(t *testing.T) {
write := func(c *gin.Context, status int, errType, message string) {
c.JSON(status, gin.H{"type": errType, "message": message})
}
c, recorder := newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformTypeSafe)
require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
require.Equal(t, http.StatusNotFound, recorder.Code)
require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage)
c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformComposite)
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformTypeSafe))
require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformAnthropic)
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
c, _ = newTypeSafeGroupContext(t, "/antigravity/v1/messages", `{}`, service.PlatformTypeSafe)
c.Set(string(middleware2.ContextKeyForcePlatform), service.PlatformAntigravity)
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
}
func mustAPIKey(t *testing.T, c *gin.Context) *service.APIKey {
t.Helper()
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
require.True(t, ok)
return apiKey
}
func TestTypeSafeGroupsRejectNonSystemOneProtocolsBeforeScheduling(t *testing.T) {
h := &GatewayHandler{
cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
gatewayService: &service.GatewayService{},
}
for _, tc := range []struct {
name string
path string
body string
handler func(*gin.Context)
}{
{"messages", "/v1/messages", `{"model":"jev-latest","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`, h.Messages},
{"count_tokens", "/v1/messages/count_tokens", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.CountTokens},
{"chat_completions", "/v1/chat/completions", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.ChatCompletions},
{"responses", "/v1/responses", `{"model":"jev-latest","input":"hi"}`, h.Responses},
} {
t.Run(tc.name, func(t *testing.T) {
c, recorder := newTypeSafeGroupContext(t, tc.path, tc.body, service.PlatformTypeSafe)
tc.handler(c)
require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage)
})
}
}
@@ -46,6 +46,7 @@ const (
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
PlatformTypeSafe = domain.PlatformTypeSafe
)
// AllPlatforms 返回所有支持的平台列表
@@ -61,6 +62,7 @@ func AllPlatforms() []string {
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
PlatformTypeSafe,
}
}
@@ -18,5 +18,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"deepseek",
"minimax",
"opencode_go",
"typesafe",
}, AllPlatforms())
}
+95 -8
View File
@@ -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,103 @@ 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
}
// MaxSystemOneResponseBytes bounds a buffered System One response body.
const MaxSystemOneResponseBytes = 4 << 20
var ErrSystemOneResponseTooLarge = errors.New("typesafe response exceeds size limit")
func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) {
body, err := io.ReadAll(io.LimitReader(r, MaxSystemOneResponseBytes+1))
if err != nil {
return nil, errors.New("typesafe invalid response")
}
// A truncated body would otherwise surface as a misleading "invalid JSON".
if len(body) > MaxSystemOneResponseBytes {
return nil, ErrSystemOneResponseTooLarge
}
if !json.Valid(body) {
return nil, errors.New("typesafe invalid response")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil {
return nil, errors.New("typesafe invalid response")
}
// The upstream already answered (and charged); an unexpected model or usage
// shape must not discard the answer, so both are decoded leniently.
var model string
_ = json.Unmarshal(envelope["model"], &model)
var usage map[string]json.RawMessage
_ = json.Unmarshal(envelope["usage"], &usage)
return &SystemOneResponse{
Body: body,
Model: model,
Usage: Usage{
InputTokens: systemOneTokenCount(usage["input_tokens"]),
OutputTokens: systemOneTokenCount(usage["output_tokens"]),
},
}, nil
}
// maxSystemOneTokenCount bounds a reported token count before int conversion.
const maxSystemOneTokenCount = 1 << 40
// systemOneTokenCount accepts integer, float, or numeric-string token counts.
func systemOneTokenCount(raw json.RawMessage) int {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return 0
}
if raw[0] == '"' {
var text string
if json.Unmarshal(raw, &text) != nil {
return 0
}
raw = json.RawMessage(strings.TrimSpace(text))
}
var number json.Number
if json.Unmarshal(raw, &number) != nil {
return 0
}
value, err := number.Float64()
if err != nil || math.IsNaN(value) || value <= 0 {
return 0
}
if value > maxSystemOneTokenCount {
value = maxSystemOneTokenCount
}
return int(math.Round(value))
}
// Evaluate performs one attempt. The caller owns timeouts, retries and key rotation.
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 {
+202
View File
@@ -0,0 +1,202 @@
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 json.RawMessage `json:"questions"`
Stream json.RawMessage `json:"stream"`
}
var (
systemOneRequestFields = []string{"model", "state", "questions", "stream"}
systemOneQuestionFields = []string{"type", "instructions", "criteria"}
)
func ValidateSystemOneRequest(body []byte) (string, error) {
var envelope systemOneEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
return "", errors.New("invalid JSON request")
}
// encoding/json matches struct fields case-insensitively and keeps the last
// duplicate, while the raw body is forwarded upstream unchanged. Reject any
// spelling the upstream could read differently from this validator.
if err := checkSystemOneObjectKeys(body, "request", systemOneRequestFields); err != nil {
return "", err
}
model, err := requiredString(envelope.Model, "model")
if err != nil {
return "", err
}
if model != JevLatestModel {
return "", fmt.Errorf("model must be %s", JevLatestModel)
}
if err := validateStringObjectOrArray(envelope.State, "state"); err != nil {
return "", err
}
questionsRaw := bytes.TrimSpace(envelope.Questions)
var questions map[string]json.RawMessage
if len(questionsRaw) == 0 || questionsRaw[0] != '{' || json.Unmarshal(questionsRaw, &questions) != nil || len(questions) == 0 {
return "", errors.New("questions must be a non-empty object")
}
if err := checkSystemOneObjectKeys(questionsRaw, "questions", nil); err != nil {
return "", err
}
if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" {
var stream bool
if err := json.Unmarshal(envelope.Stream, &stream); err != nil {
return "", errors.New("stream must be a boolean")
}
if stream {
return "", ErrStreamingUnsupported
}
}
for id, raw := range 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)
}
if err := checkSystemOneObjectKeys(raw, fmt.Sprintf("question %q", id), systemOneQuestionFields); err != nil {
return err
}
typ, err := requiredString(question.Type, "question type")
if err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
// Mirror the wire schema generated from https://api.typesafe.ai/openapi.json
// in TypeSafe SDK v0.5.7: instructions are optional and nullable for all types.
if err := validateOptionalDescription(question.Instructions, "instructions"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
switch typ {
case "noul":
criteria := bytes.TrimSpace(question.Criteria)
if len(criteria) == 0 || bytes.Equal(criteria, []byte("null")) {
return nil
}
var descriptions map[string]json.RawMessage
if criteria[0] != '{' || json.Unmarshal(criteria, &descriptions) != nil {
return fmt.Errorf("question %q: noul criteria must be an object", id)
}
for _, outcome := range []string{"true", "false"} {
if err := validateOptionalDescription(descriptions[outcome], "noul criteria "+outcome); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
case "choice":
var criteria map[string]json.RawMessage
if json.Unmarshal(question.Criteria, &criteria) != nil || criteria == nil {
return fmt.Errorf("question %q: choice criteria must be an object", id)
}
for _, value := range criteria {
if err := validateOptionalDescription(value, "choice criteria value"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
case "score":
var criteria []json.RawMessage
if json.Unmarshal(question.Criteria, &criteria) != nil || len(criteria) == 0 {
return fmt.Errorf("question %q: score criteria must contain at least one level", id)
}
for _, value := range criteria {
if err := validateStringObjectOrArray(value, "score criteria value"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
default:
return fmt.Errorf("question %q: unsupported type %q", id, typ)
}
return nil
}
func validateOptionalDescription(raw json.RawMessage, name string) error {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
return validateStringObjectOrArray(raw, name)
}
func requiredString(raw json.RawMessage, name string) (string, error) {
var value string
if len(raw) == 0 || json.Unmarshal(raw, &value) != nil || strings.TrimSpace(value) == "" {
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
}
// checkSystemOneObjectKeys rejects duplicate keys and non-canonical spellings
// (any case variant) of the known fields in one JSON object level.
func checkSystemOneObjectKeys(raw []byte, scope string, canonical []string) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
return fmt.Errorf("%s must be an object", scope)
}
seen := make(map[string]struct{})
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return errors.New("invalid JSON request")
}
key, _ := token.(string)
if _, duplicate := seen[key]; duplicate {
return fmt.Errorf("%s contains duplicate field %q", scope, key)
}
seen[key] = struct{}{}
for _, field := range canonical {
if key != field && strings.EqualFold(key, field) {
return fmt.Errorf("%s field %q must be written as %q", scope, key, field)
}
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return errors.New("invalid JSON request")
}
}
return nil
}
@@ -0,0 +1,124 @@
package typesafe
import (
"errors"
"strings"
"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"]}}}`},
{"noul omitted instructions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul"}}}`},
{"noul nullable instructions and criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":null,"criteria":null}}}`},
{"noul structured descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","criteria":{"true":{"examples":["yes",null]},"false":["no",null],"extension":42}}}}`},
{"choice structured descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","instructions":null,"criteria":{"a":{"description":"A","extra":null},"b":["B",null],"c":null}}}}`},
{"choice empty criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{}}}}`},
{"score one level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":["only"]}}}`},
{"score object level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","instructions":null,"criteria":[{"description":"only","extra":null}]}}}`},
{"score array level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":[["only",null]]}}}`},
{"native extensions preserved", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","extension":{"kept":true}}},"extension":[1,null]}`},
} {
t.Run(tc.name, func(t *testing.T) {
model, err := ValidateSystemOneRequest([]byte(tc.body))
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"},
{"array questions", `{"model":"jev-latest","state":"x","questions":[{"type":"noul"}]}`, "questions must be a non-empty object"},
{"null questions", `{"model":"jev-latest","state":"x","questions":null}`, "questions must be a non-empty object"},
{"unknown question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"boolean","instructions":"x"}}}`, "unsupported type"},
{"question type with whitespace", `{"model":"jev-latest","state":"x","questions":{"q":{"type":" noul ","instructions":"x"}}}`, "unsupported type"},
{"noul criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":[]}}}`, "noul criteria"},
{"noul numeric description", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","criteria":{"true":1}}}}`, "noul criteria"},
{"noul boolean description", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","criteria":{"false":false}}}}`, "noul criteria"},
{"missing choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice"}}}`, "choice criteria"},
{"null choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","criteria":null}}}`, "choice criteria"},
{"choice criteria array", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","criteria":[]}}}`, "choice criteria"},
{"choice numeric value", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{"one":1}}}}`, "choice criteria"},
{"missing score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score"}}}`, "score criteria"},
{"empty score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":[]}}}`, "score criteria"},
{"null score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":null}}}`, "score criteria"},
{"score map is not wire protocol", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":{"0":"low"}}}}`, "score criteria"},
{"numeric score level", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":["low",2]}}}`, "score criteria"},
{"null score level", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","criteria":[null]}}}`, "score criteria"},
{"numeric instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":1}}}`, "instructions"},
{"boolean instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":true,"criteria":{}}}}`, "instructions"},
{"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"},
{"case variant model smuggles upstream model", `{"model":"jev-pro","MODEL":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `must be written as "model"`},
{"case variant stream hides streaming", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}},"stream":true,"Stream":false}`, `must be written as "stream"`},
{"unicode fold variant state", `{"model":"jev-latest","state":"x","ſtate":"y","questions":{"q":{"type":"noul"}}}`, `must be written as "state"`},
{"duplicate model", `{"model":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`},
{"escaped duplicate model", `{"\u006dodel":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`},
{"duplicate state", `{"model":"jev-latest","state":"benign","state":"payload","questions":{"q":{"type":"noul"}}}`, `duplicate field "state"`},
{"duplicate question id", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"},"q":{"type":"noul"}}}`, `duplicate field "q"`},
{"duplicate question instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"a","instructions":"b"}}}`, `duplicate field "instructions"`},
{"case variant question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","Type":"choice"}}}`, `must be written as "type"`},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ValidateSystemOneRequest([]byte(tc.body))
require.Error(t, err)
require.Contains(t, err.Error(), tc.want)
if tc.name == "stream true" {
require.True(t, errors.Is(err, ErrStreamingUnsupported))
}
})
}
}
func TestDecodeSystemOneResponseToleratesUsageShapes(t *testing.T) {
for _, tc := range []struct {
name string
body string
model string
input, output int
}{
{"integers", `{"model":"jev-1","usage":{"input_tokens":12,"output_tokens":3}}`, "jev-1", 12, 3},
{"floats", `{"model":"jev-1","usage":{"input_tokens":12.0,"output_tokens":2.6}}`, "jev-1", 12, 3},
{"numeric strings", `{"usage":{"input_tokens":"15","output_tokens":" 4 "}}`, "", 15, 4},
{"non numeric usage", `{"model":7,"usage":{"input_tokens":"many","output_tokens":null}}`, "", 0, 0},
{"negative usage", `{"usage":{"input_tokens":-5}}`, "", 0, 0},
{"usage not object", `{"model":"jev-1","usage":"none","answers":{}}`, "jev-1", 0, 0},
{"missing usage", `{"answers":{}}`, "", 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
decoded, err := DecodeSystemOneResponse(strings.NewReader(tc.body))
require.NoError(t, err)
require.Equal(t, []byte(tc.body), decoded.Body)
require.Equal(t, tc.model, decoded.Model)
require.Equal(t, tc.input, decoded.Usage.InputTokens)
require.Equal(t, tc.output, decoded.Usage.OutputTokens)
})
}
}
func TestDecodeSystemOneResponseRejectsNonObjects(t *testing.T) {
for _, body := range []string{`[]`, `"text"`, `null`, `{`, `data: {}`} {
_, err := DecodeSystemOneResponse(strings.NewReader(body))
require.Error(t, err, body)
}
_, err := DecodeSystemOneResponse(strings.NewReader(`{"pad":"` + strings.Repeat("a", MaxSystemOneResponseBytes) + `"}`))
require.ErrorIs(t, err, ErrSystemOneResponseTooLarge)
}
@@ -106,6 +106,8 @@ func extractProtocolSegments(protocol string, document any) []promptSegment {
return append(extractInstructions(root["instructions"]), extractResponses(root["input"])...)
case "openai_images", "grok_media", "media", "images":
return userPromptSegments(extractMediaPrompts(root))
case "typesafe_systemone":
return extractSystemOneSegments(root)
default:
if segments := extractChatLikeSegments(root); len(segments) > 0 {
return segments
@@ -120,6 +122,79 @@ func extractProtocolSegments(protocol string, document any) []promptSegment {
}
}
// extractSystemOneSegments collects every client-controlled text of a TypeSafe
// System One request: question IDs, every question field except the validated
// type, unknown top-level extension fields, and the evaluated state. Object keys
// are text too (Jev reads the whole JSON), so they are collected with values.
// The state comes last so it is the prioritized segment, and keys are visited
// in sorted order to keep the prompt hash stable across requests.
func extractSystemOneSegments(root map[string]any) []promptSegment {
if root == nil {
return nil
}
texts := make([]string, 0, 4)
questions, isObject := root["questions"].(map[string]any)
if !isObject {
texts = appendJSONStringLeaves(texts, root["questions"])
}
for _, id := range sortedJSONKeys(questions) {
texts = appendJSONStringLeaves(texts, id)
question, ok := questions[id].(map[string]any)
if !ok {
texts = appendJSONStringLeaves(texts, questions[id])
continue
}
for _, field := range sortedJSONKeys(question) {
switch field {
case "type":
continue
case "instructions", "criteria":
default:
texts = appendJSONStringLeaves(texts, field)
}
texts = appendJSONStringLeaves(texts, question[field])
}
}
for _, field := range sortedJSONKeys(root) {
switch field {
case "model", "stream", "state", "questions":
continue
}
texts = appendJSONStringLeaves(texts, field)
texts = appendJSONStringLeaves(texts, root[field])
}
texts = appendJSONStringLeaves(texts, root["state"])
return userPromptSegments(texts)
}
func appendJSONStringLeaves(texts []string, value any) []string {
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
texts = append(texts, text)
}
case []any:
for _, item := range typed {
texts = appendJSONStringLeaves(texts, item)
}
case map[string]any:
for _, key := range sortedJSONKeys(typed) {
texts = appendJSONStringLeaves(texts, key)
texts = appendJSONStringLeaves(texts, typed[key])
}
}
return texts
}
func sortedJSONKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// clientInstructionRoles are roles a client may freely populate. Attackers can
// place jailbreak/PII text in assistant/tool turns, so blocking audit must scan
// them too—not only user/system/developer instructions.
@@ -412,3 +412,53 @@ func mustJSON(t *testing.T, value string) []byte {
func metadataTextForTest(scanText string) string {
return strings.Replace(scanText, promptAuditPrioritySeparator, "\n\n", 1)
}
func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) {
body := `{"model":"jev-latest","state":{"STATE_KEY":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` +
`"questions":{"b":{"type":"choice","instructions":"CHOICE_INSTRUCTIONS","criteria":{"OPTION_LABEL":{"description":"OPTION_DESC"},"empty":null}},` +
`"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"},"EXTENSION_KEY":"EXTENSION_VALUE"},` +
`"QUESTION_ID":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}},"TOP_EXTENSION":{"x":"TOP_VALUE"}}`
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)})
require.NoError(t, err)
for _, text := range []string{"STATE_KEY", "STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC",
"NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "EXTENSION_KEY", "EXTENSION_VALUE", "QUESTION_ID", "SCORE_LOW", "SCORE_HIGH",
"TOP_EXTENSION", "TOP_VALUE"} {
require.Contains(t, snapshot.ScanText, text)
}
// Canonical field names and validated enum values are not client text.
for _, text := range []string{"jev-latest", "instructions", "criteria", "questions", "choice", "score"} {
require.NotContains(t, snapshot.ScanText, text)
}
// Map iteration order must not change the audited text or its hash.
for range 20 {
again, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)})
require.NoError(t, err)
require.Equal(t, snapshot.PromptHash, again.PromptHash)
require.Equal(t, snapshot.ScanText, again.ScanText)
}
blocking, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, true)
require.NoError(t, err)
require.Contains(t, blocking.ScanText, "STATE_TITLE")
require.Contains(t, blocking.ScanText, "CHOICE_INSTRUCTIONS")
require.Contains(t, blocking.ScanText, "QUESTION_ID")
}
func TestPromptSnapshotTypeSafeSystemOneStringStateIsPrioritized(t *testing.T) {
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":"plain state","questions":{"q":{"type":"noul"}}}`)})
require.NoError(t, err)
require.True(t, strings.HasPrefix(snapshot.ScanText, "plain state"))
require.Equal(t, 2, snapshot.MessageCount)
}
func TestPromptSnapshotTypeSafeSystemOneAuditsKeyOnlyPayloads(t *testing.T) {
body := `{"model":"jev-latest","state":{"HIDDEN_STATE_KEY":1},"questions":{"HIDDEN_QUESTION_ID":{"type":"noul"}}}`
for _, latestTurnOnly := range []bool{false, true} {
snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, latestTurnOnly)
require.NoError(t, err)
require.Contains(t, snapshot.ScanText, "HIDDEN_STATE_KEY")
require.Contains(t, snapshot.ScanText, "HIDDEN_QUESTION_ID")
}
}
+2 -2
View File
@@ -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,
@@ -1186,7 +1186,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,
@@ -202,6 +202,8 @@ func RegisterGatewayRoutes(
}
h.Gateway.Messages(c)
})
// System One carries only JSON text, so it uses the text body limit.
gateway.POST("/systemone", textBodyLimit, h.Gateway.SystemOne)
// /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates
// locally, and Anthropic-compatible platforms retain their existing path.
gateway.POST("/messages/count_tokens", countTokensHandler)
@@ -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"}`},
@@ -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"},
+31
View File
@@ -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
}
@@ -980,6 +985,10 @@ func (a *Account) GetBaseURL() string {
}
baseURL := a.GetCredential("base_url")
if baseURL == "" {
// TypeSafe keys must never fall back to the Anthropic host.
if a.Platform == PlatformTypeSafe {
return typesafe.DefaultBaseURL
}
return "https://api.anthropic.com"
}
if a.Platform == PlatformAntigravity {
@@ -1001,6 +1010,28 @@ func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
return baseURL
}
func (a *Account) GetTypeSafeBaseURL() string {
if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey {
return ""
}
baseURL := strings.TrimRight(strings.TrimSpace(a.GetCredential("base_url")), "/")
// The System One path already carries /v1; accept a base URL pasted with it.
if len(baseURL) >= 3 && strings.EqualFold(baseURL[len(baseURL)-3:], "/v1") {
baseURL = strings.TrimRight(baseURL[:len(baseURL)-3], "/")
}
if baseURL == "" {
return typesafe.DefaultBaseURL
}
return baseURL
}
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 ""
@@ -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
@@ -416,6 +416,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return s.testOpenCodeGoAccountConnection(c, account, modelID, prompt)
}
if account.IsTypeSafe() {
return s.testTypeSafeAccountConnection(c, account, prompt)
}
return s.testClaudeAccountConnection(c, account, modelID)
}
@@ -0,0 +1,95 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
)
const (
typeSafeTestDefaultState = "Sub2API connection test"
typeSafeTestQuestionID = "connection_test"
typeSafeTestMaxPreviewBytes = 2000
)
// testTypeSafeAccountConnection probes a TypeSafe account with a minimal native
// System One request. TypeSafe accounts never speak the Claude protocol, so
// they must not fall through to testClaudeAccountConnection (which would send
// the key to /v1/messages and could misclassify the account).
func (s *AccountTestService) testTypeSafeAccountConnection(c *gin.Context, account *Account, prompt string) error {
ctx := c.Request.Context()
if account.Type != AccountTypeAPIKey {
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
}
apiKey := account.GetTypeSafeAPIKey()
if apiKey == "" {
return s.sendErrorAndEnd(c, "No API key available")
}
baseURL, err := s.validateUpstreamBaseURL(account.GetTypeSafeBaseURL())
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
}
state := strings.TrimSpace(prompt)
if state == "" {
state = typeSafeTestDefaultState
}
payload, err := json.Marshal(map[string]any{
"model": typesafe.JevLatestModel,
"state": state,
"questions": map[string]any{
typeSafeTestQuestionID: map[string]any{
"type": "noul",
"instructions": "Is this text a connection test?",
},
},
})
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create test payload")
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.Flush()
s.sendEvent(c, TestEvent{Type: "test_start", Model: typesafe.JevLatestModel})
req, err := typesafe.NewSystemOneRequest(ctx, baseURL, apiKey, payload)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create request")
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", sanitizeUpstreamErrorMessage(err.Error())))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, truncateString(string(body), typeSafeTestMaxPreviewBytes))
// 401/403 表示 API Key 无效或被上游拒绝,标记为 error 状态。
if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil {
_ = s.accountRepo.SetError(ctx, account.ID, errMsg)
}
return s.sendErrorAndEnd(c, errMsg)
}
decoded, err := typesafe.DecodeSystemOneResponse(resp.Body)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid System One response: %s", err.Error()))
}
s.sendEvent(c, TestEvent{Type: "content", Text: truncateString(string(decoded.Body), typeSafeTestMaxPreviewBytes)})
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
return nil
}
@@ -0,0 +1,92 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newTypeSafeAccountTestFixture(t *testing.T, status int, responseBody string) (*AccountTestService, *systemOnePolicyAccountRepo, *[]*http.Request, *gin.Context, *httptest.ResponseRecorder) {
t.Helper()
account := &Account{ID: 31, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
var requests []*http.Request
upstream := &systemOneHTTPUpstream{do: func(req *http.Request) (*http.Response, error) {
requests = append(requests, req)
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responseBody))}, nil
}}
svc := &AccountTestService{
accountRepo: repo,
httpUpstream: upstream,
cfg: &config.Config{},
}
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/31/test", nil)
return svc, repo, &requests, c, rec
}
func TestTypeSafeAccountTestSendsNativeSystemOneRequest(t *testing.T) {
svc, repo, requests, c, rec := newTypeSafeAccountTestFixture(t, http.StatusOK, `{"model":"jev-1.13.0","answers":{"connection_test":{"type":"noul"}},"usage":{"input_tokens":9}}`)
// The Claude default model must be ignored: TypeSafe only serves jev-latest.
require.NoError(t, svc.TestAccountConnection(c, 31, "claude-sonnet-4-5", "", AccountTestModeDefault))
require.Len(t, *requests, 1)
req := (*requests)[0]
require.Equal(t, typesafe.DefaultBaseURL+typesafe.SystemOnePath, req.URL.String())
require.Equal(t, "Bearer ts-secret", req.Header.Get("Authorization"))
require.Empty(t, req.Header.Get("x-api-key"))
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
model, err := typesafe.ValidateSystemOneRequest(body)
require.NoError(t, err)
require.Equal(t, typesafe.JevLatestModel, model)
output := rec.Body.String()
require.Contains(t, output, `"type":"test_start"`)
require.Contains(t, output, `"model":"jev-latest"`)
require.Contains(t, output, `"type":"test_complete"`)
require.Contains(t, output, "jev-1.13.0")
require.Zero(t, repo.errorCalls)
}
func TestTypeSafeAccountTestMarksRejectedKey(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
t.Run(http.StatusText(status), func(t *testing.T) {
svc, repo, _, c, rec := newTypeSafeAccountTestFixture(t, status, `{"detail":"invalid key"}`)
require.Error(t, svc.TestAccountConnection(c, 31, "", "", AccountTestModeDefault))
require.Equal(t, 1, repo.errorCalls)
responseText, errMsg := parseTestSSEOutput(rec.Body.String())
require.Empty(t, responseText)
require.Contains(t, errMsg, "API returned")
})
}
}
func TestTypeSafeAccountTestTransientFailureKeepsAccountState(t *testing.T) {
svc, repo, _, c, _ := newTypeSafeAccountTestFixture(t, http.StatusServiceUnavailable, `{"detail":"busy"}`)
require.Error(t, svc.TestAccountConnection(c, 31, "", "", AccountTestModeDefault))
require.Zero(t, repo.errorCalls)
}
func TestTypeSafeAccountTestUsesPromptAsState(t *testing.T) {
svc, _, requests, c, _ := newTypeSafeAccountTestFixture(t, http.StatusOK, `{"answers":{}}`)
require.NoError(t, svc.TestAccountConnection(c, 31, "", "custom state", AccountTestModeDefault))
body, err := io.ReadAll((*requests)[0].Body)
require.NoError(t, err)
var payload struct {
State string `json:"state"`
}
require.NoError(t, json.Unmarshal(body, &payload))
require.Equal(t, "custom state", payload.State)
}
@@ -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)
@@ -576,6 +579,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)
+6
View File
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
@@ -296,6 +297,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
return xai.DefaultModelIDs()
case PlatformOpenCodeGo:
return DefaultOpenCodeGoModelIDs()
case PlatformTypeSafe:
return []string{typesafe.JevLatestModel}
case PlatformComposite:
return compositeDefaultModelsListCandidateIDs()
default:
@@ -316,6 +319,9 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
// TypeSafe stays out of the static composite candidates (jev-latest only works
// through /v1/systemone); groups with TypeSafe accounts still get it from the
// account model mappings collected by GetGroupModelsListCandidates.
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
@@ -698,6 +698,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 处理)。
@@ -994,6 +1000,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) {
@@ -130,6 +130,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) {
+1 -1
View File
@@ -368,7 +368,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}
}
@@ -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 {
@@ -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
@@ -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))
}
@@ -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"
@@ -46,6 +46,10 @@ 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:
// System One carries no client-harness reminder blocks, so a literal
// <system-reminder> is ordinary user text and must never be skipped.
moderationTextCollector{}.collectSystemOneInput(body, &parts)
default:
collector.collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images)
collector.collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images)
@@ -61,6 +65,67 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders
return out
}
// collectSystemOneInput moderates every client-controlled text of a System One
// request: question IDs, every question field except the validated type,
// unknown top-level extension fields, and the evaluated state. Object keys are
// sent to Jev as part of the JSON, so they are moderated like values.
func (collector moderationTextCollector) collectSystemOneInput(body []byte, parts *[]string) {
root := gjson.ParseBytes(body)
questions := root.Get("questions")
if !questions.IsObject() {
collector.collectSystemOneText(questions, parts)
}
questions.ForEach(func(id, question gjson.Result) bool {
collector.addModerationText(parts, id.String())
if !question.IsObject() {
collector.collectSystemOneText(question, parts)
return true
}
question.ForEach(func(field, value gjson.Result) bool {
switch field.String() {
case "type":
return true
case "instructions", "criteria":
default:
collector.addModerationText(parts, field.String())
}
collector.collectSystemOneText(value, parts)
return true
})
return true
})
root.ForEach(func(field, value gjson.Result) bool {
switch field.String() {
case "model", "stream", "state", "questions":
return true
}
collector.addModerationText(parts, field.String())
collector.collectSystemOneText(value, parts)
return true
})
collector.collectSystemOneText(root.Get("state"), parts)
}
func (collector moderationTextCollector) collectSystemOneText(value gjson.Result, parts *[]string) {
switch {
case !value.Exists():
return
case value.Type == gjson.String:
collector.addModerationText(parts, value.String())
case value.IsArray():
value.ForEach(func(_, child gjson.Result) bool {
collector.collectSystemOneText(child, parts)
return true
})
case value.IsObject():
value.ForEach(func(key, child gjson.Result) bool {
collector.addModerationText(parts, key.String())
collector.collectSystemOneText(child, parts)
return true
})
}
}
func (collector moderationTextCollector) collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) {
if !messages.IsArray() {
return
@@ -0,0 +1,34 @@
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"}}}`, "title hello nested body world"},
{"array", `{"state":["first",{"text":"second"},3]}`, "first text second"},
{"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"},"ext":"extra"}},"top":{"k":"v"}}`, "c pick one label a description desc a label b s low text high n judge true yes ext extra top k v state text"},
{"key only payload", `{"state":{"hidden state key":1},"questions":{"hidden question id":{"type":"noul"}}}`, "hidden question id hidden state key"},
} {
t.Run(tc.name, func(t *testing.T) {
input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body))
require.Equal(t, tc.want, input.Text)
require.Empty(t, input.Images)
})
}
}
func TestExtractContentModerationInputTypeSafeSystemOneKeepsReminderText(t *testing.T) {
body := `{"state":"<system-reminder>hidden payload</system-reminder>","questions":{"q":{"type":"noul","instructions":"<system-reminder>hidden instructions</system-reminder>"}}}`
input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(body))
require.Contains(t, input.Text, "hidden payload")
require.Contains(t, input.Text, "hidden instructions")
}
@@ -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 是允许设置账号自动停调阈值的平台列表。
@@ -0,0 +1,182 @@
package service
import (
"context"
"errors"
"fmt"
"mime"
"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
}
upstreamURL := req.URL.Scheme + "://" + req.URL.Host + req.URL.Path
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: upstreamURL,
})
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, s.handleSystemOneErrorResponse(ctx, c, account, resp, upstreamURL)
}
decoded, err := typesafe.DecodeSystemOneResponse(resp.Body)
if err != nil {
// The upstream accepted (and may have charged) this request but the
// gateway cannot relay it; keep an ops trail for reconciliation.
setOpsUpstreamError(c, resp.StatusCode, err.Error(), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "response_error",
Message: err.Error(),
})
return nil, err
}
return &SystemOneForwardResult{
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: systemOneResponseContentType(resp.Header.Get("Content-Type")),
}, nil
}
// IsSystemOneRequestErrorStatus reports upstream statuses that describe the
// caller's own payload (malformed, unprocessable, or too large).
func IsSystemOneRequestErrorStatus(status int) bool {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity:
return true
default:
return false
}
}
// handleSystemOneErrorResponse applies the shared account error policy to a
// non-2xx System One response. 400/413/422 describe the caller's own payload, so
// they never touch account state (a tenant must not be able to disable an
// account with bad input) and are not retried elsewhere. Every other status
// goes through the account error policy (custom error codes, temporary
// unschedulable rules, pool mode) and fails over when the status is retryable
// or the policy took the account out of rotation.
func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gin.Context, account *Account, resp *http.Response, upstreamURL string) error {
respBody, _ := s.readUpstreamErrorBody(resp)
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "")
event := OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "http_error",
Message: upstreamMsg,
}
if IsSystemOneRequestErrorStatus(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
shouldDisable := false
if s.rateLimitService != nil {
shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, typesafe.JevLatestModel)
}
if !shouldDisable && !s.shouldFailoverUpstreamError(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
event.Kind = "failover"
appendOpsUpstreamError(c, event)
failoverErr := &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
ResponseHeaders: resp.Header.Clone(),
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
if resp.StatusCode == http.StatusUnauthorized {
failoverErr.Stage = GatewayFailureStageAccountAuth
failoverErr.Scope = GatewayFailureScopeAccount
failoverErr.Reason = TypeSafeCredentialRejectedReason
failoverErr.NextAccountAction = NextAccountRetry
}
return failoverErr
}
// systemOneResponseContentType keeps the upstream JSON media type (and its
// charset) but never relays a non-JSON type for a body already validated as
// JSON, so the gateway origin cannot be made to serve it as HTML.
func systemOneResponseContentType(raw string) string {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(raw))
if err != nil {
return "application/json"
}
if mediaType == "application/json" || (strings.HasPrefix(mediaType, "application/") && strings.HasSuffix(mediaType, "+json")) {
return strings.TrimSpace(raw)
}
return "application/json"
}
@@ -0,0 +1,402 @@
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/Wei-Shaw/sub2api/internal/pkg/typesafe"
"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 TestForwardSystemOneSchemaConformancePassthrough(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{"omitted noul instructions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","extension":{"kept":true}}}}`},
{"nullable noul fields", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":null,"criteria":null}}}`},
{"structured noul descriptions", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","criteria":{"true":{"reason":"Yes"},"false":["No",null]}}}}`},
{"object choice description", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{"a":{"description":"A","extra":null}}}}}`},
{"array choice description", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","instructions":null,"criteria":{"a":["A",null],"b":null}}}}`},
{"empty choice criteria", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"choice","criteria":{}}}}`},
{"one-level score", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":["only"]}}}`},
{"object score level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","instructions":null,"criteria":[{"description":"only","extra":null}]}}}`},
{"array score level", `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"score","criteria":[["only",null]]}}}`},
} {
t.Run(tc.name, func(t *testing.T) {
requestBody := []byte(tc.body)
_, err := typesafe.ValidateSystemOneRequest(requestBody)
require.NoError(t, err)
responseBody := []byte(`{"answers":{},"usage":{"input_tokens":12}}`)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/systemone", r.URL.Path)
require.Equal(t, "Bearer ts-mock-key", r.Header.Get("Authorization"))
got, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Equal(t, requestBody, got)
w.Header().Set("Content-Type", "application/json")
_, err = w.Write(responseBody)
require.NoError(t, err)
}))
defer server.Close()
svc := newSystemOneTestService(&systemOneHTTPUpstream{do: server.Client().Do})
account := &Account{ID: 12, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": server.URL, "api_key": "ts-mock-key"}}
result, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, requestBody)
require.NoError(t, err)
require.Equal(t, responseBody, result.Body)
require.Equal(t, 12, result.Usage.InputTokens)
})
}
}
func TestForwardSystemOneErrorPolicy(t *testing.T) {
for _, status := range []int{400, 422, 401, 429, 529, 500, 503} {
t.Run(http.StatusText(status), func(t *testing.T) {
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)
}
}
func newSystemOneStatusUpstream(status int, body string) *systemOneHTTPUpstream {
return &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Header: http.Header{"X-Request-Id": []string{"req-err"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
}}
}
func systemOneOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent {
t.Helper()
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := raw.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
return events
}
func TestForwardSystemOneRequestErrorsNeverTouchAccountState(t *testing.T) {
for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity} {
t.Run(http.StatusText(status), func(t *testing.T) {
// Even a custom error-code rule must not let client input disable the account.
account := &Account{ID: 21, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{
"base_url": "http://typesafe.test", "api_key": "ts-secret",
"custom_error_codes_enabled": true, "custom_error_codes": []any{float64(status)},
}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(status, `{"detail":"bad question"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
c := newSystemOneTestContext()
_, err := svc.ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
var upstreamErr *SystemOneUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Equal(t, status, upstreamErr.StatusCode)
require.Zero(t, repo.errorCalls+repo.rateLimitedCalls+repo.overloadedCalls)
events := systemOneOpsEvents(t, c)
require.Len(t, events, 1)
require.Equal(t, "http_error", events[0].Kind)
require.Equal(t, status, events[0].UpstreamStatusCode)
require.Equal(t, "req-err", events[0].UpstreamRequestID)
})
}
}
func TestForwardSystemOneAccountLevelFailuresFailOver(t *testing.T) {
for _, tc := range []struct {
name string
status int
wantError int
}{
{name: "payment required", status: http.StatusPaymentRequired, wantError: 1},
{name: "forbidden", status: http.StatusForbidden, wantError: 1},
} {
t.Run(tc.name, func(t *testing.T) {
account := &Account{ID: 22, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(tc.status, `{"detail":"account problem"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
c := newSystemOneTestContext()
_, err := svc.ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.True(t, failoverErr.ShouldRetryNextAccount())
require.Equal(t, tc.status, failoverErr.StatusCode)
require.Equal(t, tc.wantError, repo.errorCalls)
require.Equal(t, "failover", systemOneOpsEvents(t, c)[0].Kind)
})
}
}
func TestForwardSystemOneHonorsCustomErrorCodes(t *testing.T) {
account := &Account{ID: 23, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{
"base_url": "http://typesafe.test", "api_key": "ts-secret",
"custom_error_codes_enabled": true, "custom_error_codes": []any{float64(http.StatusNotFound)},
}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(http.StatusNotFound, `{"detail":"missing"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
_, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, 1, repo.errorCalls)
}
func TestForwardSystemOneUnhandledStatusDoesNotFailOver(t *testing.T) {
account := &Account{ID: 24, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
repo := &systemOnePolicyAccountRepo{account: account}
svc := newSystemOneTestService(newSystemOneStatusUpstream(http.StatusConflict, `{"detail":"conflict"}`))
svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
_, err := svc.ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
var upstreamErr *SystemOneUpstreamError
require.ErrorAs(t, err, &upstreamErr)
require.Equal(t, http.StatusConflict, upstreamErr.StatusCode)
require.Zero(t, repo.errorCalls)
}
func TestForwardSystemOneNormalizesResponseContentType(t *testing.T) {
for _, tc := range []struct{ upstream, want string }{
{"application/json; charset=utf-8", "application/json; charset=utf-8"},
{"application/problem+json", "application/problem+json"},
{"text/html; charset=utf-8", "application/json"},
{"", "application/json"},
{"not a media type;;", "application/json"},
} {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
header := make(http.Header)
if tc.upstream != "" {
header.Set("Content-Type", tc.upstream)
}
return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`{"answers":{}}`))}, nil
}}
account := &Account{ID: 25, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.NoError(t, err)
require.Equal(t, tc.want, result.ContentType, tc.upstream)
}
}
func TestForwardSystemOneRejectsOversizedResponse(t *testing.T) {
oversized := `{"pad":"` + strings.Repeat("a", typesafe.MaxSystemOneResponseBytes) + `"}`
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(oversized))}, nil
}}
account := &Account{ID: 26, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
c := newSystemOneTestContext()
_, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
require.ErrorIs(t, err, typesafe.ErrSystemOneResponseTooLarge)
events := systemOneOpsEvents(t, c)
require.Len(t, events, 1)
require.Equal(t, "response_error", events[0].Kind)
require.Equal(t, http.StatusOK, events[0].UpstreamStatusCode)
}
func TestForwardSystemOneBillsLenientUsageShapes(t *testing.T) {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"answers":{},"usage":{"input_tokens":"21","output_tokens":1.0}}`))}, nil
}}
account := &Account{ID: 27, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.NoError(t, err)
require.Equal(t, 21, result.Usage.InputTokens)
require.Equal(t, 1, result.Usage.OutputTokens)
}
func TestTypeSafeAccountBaseURLNeverFallsBackToAnthropic(t *testing.T) {
account := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}}
require.Equal(t, typesafe.DefaultBaseURL, account.GetBaseURL())
require.Equal(t, typesafe.DefaultBaseURL, account.GetTypeSafeBaseURL())
for raw, want := range map[string]string{
"https://api.typesafe.ai/v1": "https://api.typesafe.ai",
"https://api.typesafe.ai/V1/": "https://api.typesafe.ai",
"https://proxy.example/typesafe/": "https://proxy.example/typesafe",
"https://proxy.example/apiv1": "https://proxy.example/apiv1",
" https://proxy.example/x/v1/ ": "https://proxy.example/x",
"/v1": typesafe.DefaultBaseURL,
} {
withBase := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": raw}}
require.Equal(t, want, withBase.GetTypeSafeBaseURL(), raw)
}
anthropic := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}}
require.Equal(t, "https://api.anthropic.com", anthropic.GetBaseURL())
}
func TestTypeSafeModelsListCandidates(t *testing.T) {
require.Equal(t, []string{typesafe.JevLatestModel}, defaultModelsListCandidateIDs(PlatformTypeSafe))
require.NotContains(t, compositeDefaultModelsListCandidateIDs(), typesafe.JevLatestModel)
}
@@ -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。
@@ -0,0 +1,26 @@
-- Add TypeSafe (Jev System One) as a first-class platform.
--
-- 1. user_platform_quotas.platform CHECK
-- 2. composite_model_routes.target_platform CHECK
--
-- TypeSafe 不是对话模型,不进入渠道监控 provider,因此 channel_monitors /
-- channel_monitor_request_templates 的约束保持不变。
--
-- Runs after 238_opencode_go_platform.sql. DROP ... IF EXISTS 保证可重入;
-- 新约束是 238 的超集,存量行瞬时校验通过。
ALTER TABLE user_platform_quotas
DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check;
ALTER TABLE user_platform_quotas
ADD CONSTRAINT user_platform_quotas_platform_check
CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'));
ALTER TABLE composite_model_routes
DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check;
ALTER TABLE composite_model_routes
ADD CONSTRAINT composite_model_routes_target_platform_check
CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'));
@@ -0,0 +1,21 @@
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestTypeSafePlatformMigration(t *testing.T) {
content, err := FS.ReadFile("241_add_typesafe_platform.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check")
require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check")
require.Contains(t, sql,
"CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'))")
require.Contains(t, sql,
"CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'))")
}
+2 -2
View File
@@ -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<Record<PlatformType, PlatformQuotaLimits>>
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"]
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok", "typesafe"]
export type SchedulingThresholdPlatformType =
| "openai"
+1 -1
View File
@@ -333,7 +333,7 @@ export async function bindUserAuthIdentity(
// Keep aligned with backend/internal/service/domain_constants.go AllowedQuotaPlatforms.
export const PLATFORM_QUOTA_PLATFORMS = [
'anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe',
] as const
export type PlatformQuotaPlatform = typeof PLATFORM_QUOTA_PLATFORMS[number]
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
@@ -228,6 +228,19 @@
<PlatformIcon platform="opencode_go" size="sm" />
OpenCode
</button>
<button
type="button"
@click="selectTypeSafePlatform()"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-all',
form.platform === 'typesafe'
? 'bg-white text-sky-700 shadow-sm dark:bg-dark-600 dark:text-sky-300'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<PlatformIcon platform="typesafe" size="sm" />
TypeSafe / Jev
</button>
</div>
</div>
@@ -4046,6 +4059,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'
}
@@ -4068,6 +4083,8 @@ const apiKeyValuePlaceholder = computed(() => {
case 'minimax':
case 'opencode_go':
return 'sk-...'
case 'typesafe':
return 'ts-...'
default:
return 'sk-ant-...'
}
@@ -4274,6 +4291,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
@@ -4847,12 +4871,20 @@ 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'
// Grok 等平台会把模式切到映射;TypeSafe 只用白名单写入 jev-latest。
modelRestrictionMode.value = 'whitelist'
allowedModels.value = ['jev-latest']
}
// Antigravity: 默认使用映射模式并填充默认映射
if (newPlatform === 'antigravity') {
antigravityModelRestrictionMode.value = 'mapping'
@@ -5775,6 +5807,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
@@ -3991,6 +3991,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 (
@@ -4440,6 +4441,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' ||
@@ -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,12 +101,12 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
})
it('renders all ten supported platforms with empty limits', async () => {
it('renders all eleven supported platforms with empty limits', async () => {
const w = await mountAndOpen()
const rows = w.findAll('tbody tr')
expect(rows.map(row => row.find('td').text())).toEqual([
'anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe',
])
for (const row of rows) {
const inputs = row.findAll('input[type=number]')
@@ -116,11 +116,11 @@ describe('UserPlatformQuotaModal', () => {
w.unmount()
})
it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] as const)(
it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'] as const)(
'saves edits to %s without erasing existing platform limits', async (platform) => {
const existing: PlatformQuotaUpdateItem[] = [
{ platform: 'openai', daily_limit_usd: 10, weekly_limit_usd: 20, monthly_limit_usd: 100 },
...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'] as const).map(p => ({
...(['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'] as const).map(p => ({
platform: p, daily_limit_usd: 0, weekly_limit_usd: null, monthly_limit_usd: 50,
})),
]
@@ -137,7 +137,7 @@ describe('UserPlatformQuotaModal', () => {
: item)
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledWith(99, expect.arrayContaining(expected))
expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(10)
expect(apiMocks.updatePlatformQuotas.mock.calls[0][1]).toHaveLength(11)
expect(w.emitted('success')).toHaveLength(1)
w.unmount()
},
@@ -152,13 +152,13 @@ describe('UserPlatformQuotaModal', () => {
})
const w = await mountAndOpen()
const inputs = w.findAll('input[type=number]')
// 10 platforms × 3 windows = 30 inputs
expect(inputs.length).toBe(30)
// 11 platforms × 3 windows = 33 inputs
expect(inputs.length).toBe(33)
// 第一个 input 是 anthropic.daily = 10
expect((inputs[0].element as HTMLInputElement).value).toBe('10')
})
it('保存提交完整 10 platform payload', async () => {
it('保存提交完整 11 platform payload', async () => {
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
platform_quotas: [
{ platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null,
@@ -175,7 +175,7 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0]
expect(uid).toBe(99)
expect(payload).toHaveLength(10) // 10 platforms always submitted
expect(payload).toHaveLength(11) // 11 platforms always submitted
const openai = payload.find((p: any) => p.platform === 'openai')
expect(openai.weekly_limit_usd).toBe(20)
})
@@ -240,7 +240,7 @@ describe('UserPlatformQuotaModal', () => {
it('未配置限额的平台重置按钮禁用并提示不可用', async () => {
const w = await mountAndOpen()
const resetBtns = w.findAll('button').filter((b) => b.text() === '↻')
expect(resetBtns.length).toBe(30) // 10 平台 × 3 窗口
expect(resetBtns.length).toBe(33) // 11 平台 × 3 窗口
for (const b of resetBtns) {
expect((b.element as HTMLButtonElement).disabled).toBe(true)
expect(b.attributes('title')).toBe('admin.users.platformQuota.reset.unavailable')
@@ -366,6 +366,8 @@ const defaultClientTab = computed(() => {
return 'gemini'
case 'antigravity':
return 'claude'
case 'typesafe':
return 'systemone'
default:
return 'claude'
}
@@ -496,6 +498,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':
@@ -580,6 +586,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')
}
@@ -637,6 +645,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')
}
@@ -765,6 +775,8 @@ const currentFiles = computed((): FileConfig[] => {
}
switch (props.platform) {
case 'typesafe':
return [generateSystemOneCurl(baseRoot, apiKey)]
case 'openai':
if (activeClientTab.value === 'claude') {
// Anthropic clients append /v1/messages themselves.
@@ -819,6 +831,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
@@ -1283,6 +1335,7 @@ function generateRoutedCodexFiles(
deepseek: 'DeepSeek',
minimax: 'MiniMax',
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
composite: 'Composite'
}
const label = labels[platform]
@@ -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(() => {
@@ -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')
})
})
@@ -259,6 +259,7 @@ const PLATFORM_LABELS: Record<string, string> = {
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<FusedPlatformCard[]>(() => {
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) {
@@ -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)] })
@@ -475,6 +475,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
@@ -11,7 +11,8 @@ const concretePlatforms = [
'zhipu',
'deepseek',
'minimax',
'opencode_go'
'opencode_go',
'typesafe'
]
describe('platform option catalogs', () => {
+2 -1
View File
@@ -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<AccountPlatform>[]
/** Platforms that can own a group. */
@@ -109,6 +109,7 @@ export default {
deepseek: 'DeepSeek',
minimax: 'MiniMax',
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
},
cnProviders: {
accountMode: {
@@ -1045,6 +1045,7 @@ export default {
deepseek: 'DeepSeek',
minimax: 'MiniMax',
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
composite: 'Composite',
},
deleteConfirm:
@@ -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',
@@ -312,6 +312,7 @@ export default {
deepseek: 'DeepSeek',
minimax: 'MiniMax',
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
},
cnProviders: {
accountMode: {
@@ -978,6 +978,7 @@ export default {
deepseek: 'DeepSeek',
minimax: 'MiniMax',
opencode_go: 'OpenCode',
typesafe: 'TypeSafe / Jev',
composite: 'Composite',
},
saving: '保存中...',
@@ -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',
+2 -2
View File
@@ -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<string, Record<string, number>>
@@ -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'
+2 -1
View File
@@ -16,7 +16,8 @@ const PROVIDER_BY_PLATFORM: Record<GroupPlatform, KeyGroupProvider> = {
grok: 'other',
antigravity: 'other',
composite: 'other',
opencode_go: 'other'
opencode_go: 'other',
typesafe: 'other'
}
export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider {
+16
View File
@@ -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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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<Platform, string> = {
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'
}
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -4055,7 +4055,7 @@
</tr>
</thead>
<tbody class="space-y-2">
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] as const)" :key="p" class="align-top">
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'typesafe'] as const)" :key="p" class="align-top">
<td class="pr-4 py-1">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
</td>
@@ -4390,7 +4390,7 @@
</tr>
</thead>
<tbody>
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'typesafe'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
<td class="pr-4 py-1">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
</td>
@@ -1919,7 +1919,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);
@@ -1932,9 +1932,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);
@@ -1950,7 +1951,7 @@ describe("admin SettingsView platform quota matrix", () => {
// 应携带嵌套对象,而非扁平字段
expect(payload).toHaveProperty("default_platform_quotas");
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
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<string, unknown>;
@@ -1964,7 +1965,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: {
@@ -1989,6 +1990,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 () => {
@@ -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)
})
})
})