mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 04:46:08 +00:00
Merge pull request #7425 from aofee/codex/jev-system-one
feat: add native TypeSafe Jev System One support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 模型。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'))")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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'
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user