fix(typesafe): 堵住校验解析差异与审计盲区,收紧模型列表口径

- System One 校验拒绝重复键与大小写/Unicode 折叠变体键(请求顶层、
  questions、问题对象):encoding/json 大小写不敏感且取最后一个重复键,
  而原始 body 原样转发,此前 {"model":"x","MODEL":"jev-latest"} 可绕过
  jev-latest 限制、模型白名单与流式限制。
- Prompt 审计与旧审核同时收集对象键名、问题 ID 与未知扩展字段(不含规范
  字段名与已校验的 type),此前把内容放进键名即可完全绕过审计。
- 上游成功响应的 usage/model 宽松解码(浮点、数字字符串),避免上游已
  计费而网关整体 502 不记账;解码失败记录 response_error ops 事件。
- 上游 413 与 400/422 同视为请求错误:透传状态码、不切换、不改账号状态。
- TypeSafe base_url 末尾的 /v1 自动剥离,避免拼出 /v1/v1/systemone;
  /v1/systemone 改挂文本请求体上限。
- Composite 静态兜底模型列表、管理端候选列表恢复为不含 jev-latest;
  Codex 清单永不列出 TypeSafe 模型;TypeSafe 分组候选默认 jev-latest。
- 创建账号从 Grok 切到 TypeSafe 时重置为白名单模式;错误透传平台列表补 typesafe。
This commit is contained in:
shaw
2026-10-02 13:51:58 +08:00
parent 2d8a386811
commit 9eb394e3e9
19 changed files with 378 additions and 64 deletions
+1 -1
View File
@@ -848,7 +848,7 @@ curl https://your-sub2api.example.com/v1/systemone \
--data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}'
```
The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400` and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`.
The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400`, `413`, and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`.
---
+1 -1
View File
@@ -764,7 +764,7 @@ curl https://your-sub2api.example.com/v1/systemone \
--data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}'
```
`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。
`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`413`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。
---
+13 -4
View File
@@ -1166,7 +1166,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, true)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
@@ -1265,7 +1265,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
platform = group.Platform
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(ctx, groupID)
availableModels := h.compositeAvailableModels(ctx, groupID, false)
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
if group.ModelAllowlistEnabled() {
source := availableModels
@@ -1291,7 +1291,10 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
return fallbackModels
}
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
// compositeAvailableModels lists the models the composite group can serve.
// includeSystemOne adds TypeSafe models, which only work through /v1/systemone;
// LLM client catalogs (Codex) must exclude them.
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, includeSystemOne bool) []string {
if h == nil || h.gatewayService == nil {
return nil
}
@@ -1299,6 +1302,9 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} {
if platform == service.PlatformTypeSafe && !includeSystemOne {
continue
}
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
@@ -1487,7 +1493,10 @@ func defaultModelIDsForPlatform(platform string) []string {
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformTypeSafe} {
// TypeSafe is deliberately absent: jev-latest only works through
// /v1/systemone, so the static fallback never advertises it to LLM
// clients. compositeAvailableModels lists it when the group can serve it.
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -1526,3 +1526,47 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t
})
}
}
// Scenario: jev-latest only works through /v1/systemone, so Composite groups list
// it in /v1/models only when they can serve it, and never in the Codex manifest.
func TestGatewayModels_CompositeTypeSafeListingScope(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(66)
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAnthropic}, {ID: 2, Platform: service.PlatformTypeSafe, Type: service.AccountTypeAPIKey}},
},
})
newContext := func(path string) (*gin.Context, *httptest.ResponseRecorder) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
return c, rec
}
c, rec := newContext("/v1/models")
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var models gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models))
require.Contains(t, modelIDsForTest(models.Data), "jev-latest")
require.Contains(t, modelIDsForTest(models.Data), "claude-opus-4-6")
c, rec = newContext("/models?client_version=0.147.0")
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var manifest codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest))
slugs := codexModelSlugsForTest(manifest.Models)
require.Contains(t, slugs, "claude-opus-4-6")
require.NotContains(t, slugs, "jev-latest")
}
func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing.T) {
require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest")
require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest")
}
@@ -228,7 +228,7 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) {
var upstreamErr *service.SystemOneUpstreamError
if errors.As(forwardErr, &upstreamErr) {
status := upstreamErr.StatusCode
if status != http.StatusBadRequest && status != http.StatusUnprocessableEntity {
if !service.IsSystemOneRequestErrorStatus(status) {
status = http.StatusBadGateway
}
h.errorResponse(c, status, "upstream_error", "TypeSafe rejected the request")
@@ -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,
}
}
+46 -6
View File
@@ -78,14 +78,54 @@ func DecodeSystemOneResponse(r io.Reader) (*SystemOneResponse, error) {
if !json.Valid(body) {
return nil, errors.New("typesafe invalid response")
}
var envelope struct {
Model string `json:"model"`
Usage Usage `json:"usage"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil {
return nil, errors.New("typesafe invalid response")
}
return &SystemOneResponse{Body: body, Model: envelope.Model, Usage: envelope.Usage}, nil
// The upstream already answered (and charged); an unexpected model or usage
// shape must not discard the answer, so both are decoded leniently.
var model string
_ = json.Unmarshal(envelope["model"], &model)
var usage map[string]json.RawMessage
_ = json.Unmarshal(envelope["usage"], &usage)
return &SystemOneResponse{
Body: body,
Model: model,
Usage: Usage{
InputTokens: systemOneTokenCount(usage["input_tokens"]),
OutputTokens: systemOneTokenCount(usage["output_tokens"]),
},
}, nil
}
// maxSystemOneTokenCount bounds a reported token count before int conversion.
const maxSystemOneTokenCount = 1 << 40
// systemOneTokenCount accepts integer, float, or numeric-string token counts.
func systemOneTokenCount(raw json.RawMessage) int {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return 0
}
if raw[0] == '"' {
var text string
if json.Unmarshal(raw, &text) != nil {
return 0
}
raw = json.RawMessage(strings.TrimSpace(text))
}
var number json.Number
if json.Unmarshal(raw, &number) != nil {
return 0
}
value, err := number.Float64()
if err != nil || math.IsNaN(value) || value <= 0 {
return 0
}
if value > maxSystemOneTokenCount {
value = maxSystemOneTokenCount
}
return int(math.Round(value))
}
// Evaluate performs one attempt. The caller owns timeouts, retries and key rotation.
@@ -17,11 +17,22 @@ type systemOneEnvelope struct {
Stream json.RawMessage `json:"stream"`
}
var (
systemOneRequestFields = []string{"model", "state", "questions", "stream"}
systemOneQuestionFields = []string{"type", "instructions", "criteria"}
)
func ValidateSystemOneRequest(body []byte) (string, error) {
var envelope systemOneEnvelope
if err := json.Unmarshal(body, &envelope); err != nil {
return "", errors.New("invalid JSON request")
}
// encoding/json matches struct fields case-insensitively and keeps the last
// duplicate, while the raw body is forwarded upstream unchanged. Reject any
// spelling the upstream could read differently from this validator.
if err := checkSystemOneObjectKeys(body, "request", systemOneRequestFields); err != nil {
return "", err
}
model, err := requiredString(envelope.Model, "model")
if err != nil {
@@ -38,6 +49,9 @@ func ValidateSystemOneRequest(body []byte) (string, error) {
if len(questionsRaw) == 0 || questionsRaw[0] != '{' || json.Unmarshal(questionsRaw, &questions) != nil || len(questions) == 0 {
return "", errors.New("questions must be a non-empty object")
}
if err := checkSystemOneObjectKeys(questionsRaw, "questions", nil); err != nil {
return "", err
}
if len(envelope.Stream) > 0 && string(envelope.Stream) != "null" {
var stream bool
if err := json.Unmarshal(envelope.Stream, &stream); err != nil {
@@ -65,6 +79,9 @@ func validateQuestion(id string, raw json.RawMessage) error {
if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &question) != nil {
return fmt.Errorf("question %q must be an object", id)
}
if err := checkSystemOneObjectKeys(raw, fmt.Sprintf("question %q", id), systemOneQuestionFields); err != nil {
return err
}
typ, err := requiredString(question.Type, "question type")
if err != nil {
return fmt.Errorf("question %q: %w", id, err)
@@ -152,3 +169,34 @@ func rawString(raw json.RawMessage) bool {
var value string
return json.Unmarshal(raw, &value) == nil
}
// checkSystemOneObjectKeys rejects duplicate keys and non-canonical spellings
// (any case variant) of the known fields in one JSON object level.
func checkSystemOneObjectKeys(raw []byte, scope string, canonical []string) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
return fmt.Errorf("%s must be an object", scope)
}
seen := make(map[string]struct{})
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return errors.New("invalid JSON request")
}
key, _ := token.(string)
if _, duplicate := seen[key]; duplicate {
return fmt.Errorf("%s contains duplicate field %q", scope, key)
}
seen[key] = struct{}{}
for _, field := range canonical {
if key != field && strings.EqualFold(key, field) {
return fmt.Errorf("%s field %q must be written as %q", scope, key, field)
}
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return errors.New("invalid JSON request")
}
}
return nil
}
@@ -2,6 +2,7 @@ package typesafe
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -66,6 +67,15 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) {
{"numeric instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":1}}}`, "instructions"},
{"boolean instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":true,"criteria":{}}}}`, "instructions"},
{"stream true", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x"}},"stream":true}`, "streaming"},
{"case variant model smuggles upstream model", `{"model":"jev-pro","MODEL":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `must be written as "model"`},
{"case variant stream hides streaming", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}},"stream":true,"Stream":false}`, `must be written as "stream"`},
{"unicode fold variant state", `{"model":"jev-latest","state":"x","ſtate":"y","questions":{"q":{"type":"noul"}}}`, `must be written as "state"`},
{"duplicate model", `{"model":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`},
{"escaped duplicate model", `{"\u006dodel":"jev-pro","model":"jev-latest","state":"x","questions":{"q":{"type":"noul"}}}`, `duplicate field "model"`},
{"duplicate state", `{"model":"jev-latest","state":"benign","state":"payload","questions":{"q":{"type":"noul"}}}`, `duplicate field "state"`},
{"duplicate question id", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul"},"q":{"type":"noul"}}}`, `duplicate field "q"`},
{"duplicate question instructions", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"a","instructions":"b"}}}`, `duplicate field "instructions"`},
{"case variant question type", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","Type":"choice"}}}`, `must be written as "type"`},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ValidateSystemOneRequest([]byte(tc.body))
@@ -77,3 +87,38 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) {
})
}
}
func TestDecodeSystemOneResponseToleratesUsageShapes(t *testing.T) {
for _, tc := range []struct {
name string
body string
model string
input, output int
}{
{"integers", `{"model":"jev-1","usage":{"input_tokens":12,"output_tokens":3}}`, "jev-1", 12, 3},
{"floats", `{"model":"jev-1","usage":{"input_tokens":12.0,"output_tokens":2.6}}`, "jev-1", 12, 3},
{"numeric strings", `{"usage":{"input_tokens":"15","output_tokens":" 4 "}}`, "", 15, 4},
{"non numeric usage", `{"model":7,"usage":{"input_tokens":"many","output_tokens":null}}`, "", 0, 0},
{"negative usage", `{"usage":{"input_tokens":-5}}`, "", 0, 0},
{"usage not object", `{"model":"jev-1","usage":"none","answers":{}}`, "jev-1", 0, 0},
{"missing usage", `{"answers":{}}`, "", 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
decoded, err := DecodeSystemOneResponse(strings.NewReader(tc.body))
require.NoError(t, err)
require.Equal(t, []byte(tc.body), decoded.Body)
require.Equal(t, tc.model, decoded.Model)
require.Equal(t, tc.input, decoded.Usage.InputTokens)
require.Equal(t, tc.output, decoded.Usage.OutputTokens)
})
}
}
func TestDecodeSystemOneResponseRejectsNonObjects(t *testing.T) {
for _, body := range []string{`[]`, `"text"`, `null`, `{`, `data: {}`} {
_, err := DecodeSystemOneResponse(strings.NewReader(body))
require.Error(t, err, body)
}
_, err := DecodeSystemOneResponse(strings.NewReader(`{"pad":"` + strings.Repeat("a", MaxSystemOneResponseBytes) + `"}`))
require.ErrorIs(t, err, ErrSystemOneResponseTooLarge)
}
@@ -123,31 +123,45 @@ func extractProtocolSegments(protocol string, document any) []promptSegment {
}
// extractSystemOneSegments collects every client-controlled text of a TypeSafe
// System One request. Question instructions and criteria come first and the
// evaluated state last, so the state is the prioritized segment. Object keys are
// visited in sorted order to keep the prompt hash stable across requests.
// System One request: question IDs, every question field except the validated
// type, unknown top-level extension fields, and the evaluated state. Object keys
// are text too (Jev reads the whole JSON), so they are collected with values.
// The state comes last so it is the prioritized segment, and keys are visited
// in sorted order to keep the prompt hash stable across requests.
func extractSystemOneSegments(root map[string]any) []promptSegment {
if root == nil {
return nil
}
texts := make([]string, 0, 4)
questions, _ := root["questions"].(map[string]any)
questions, isObject := root["questions"].(map[string]any)
if !isObject {
texts = appendJSONStringLeaves(texts, root["questions"])
}
for _, id := range sortedJSONKeys(questions) {
texts = appendJSONStringLeaves(texts, id)
question, ok := questions[id].(map[string]any)
if !ok {
texts = appendJSONStringLeaves(texts, questions[id])
continue
}
texts = appendJSONStringLeaves(texts, question["instructions"])
criteria, isObject := question["criteria"].(map[string]any)
if !isObject || stringValue(question["type"]) != "choice" {
texts = appendJSONStringLeaves(texts, question["criteria"])
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
}
// Choice option labels are client-chosen text sent to the model as well.
for _, label := range sortedJSONKeys(criteria) {
texts = appendJSONStringLeaves(texts, label)
texts = appendJSONStringLeaves(texts, criteria[label])
}
texts = appendJSONStringLeaves(texts, field)
texts = appendJSONStringLeaves(texts, root[field])
}
texts = appendJSONStringLeaves(texts, root["state"])
return userPromptSegments(texts)
@@ -165,6 +179,7 @@ func appendJSONStringLeaves(texts []string, value any) []string {
}
case map[string]any:
for _, key := range sortedJSONKeys(typed) {
texts = appendJSONStringLeaves(texts, key)
texts = appendJSONStringLeaves(texts, typed[key])
}
}
@@ -414,19 +414,22 @@ func metadataTextForTest(scanText string) string {
}
func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T) {
body := `{"model":"jev-latest","state":{"title":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` +
body := `{"model":"jev-latest","state":{"STATE_KEY":"STATE_TITLE","items":["STATE_ITEM",{"text":"STATE_NESTED"},3]},` +
`"questions":{"b":{"type":"choice","instructions":"CHOICE_INSTRUCTIONS","criteria":{"OPTION_LABEL":{"description":"OPTION_DESC"},"empty":null}},` +
`"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"}},` +
`"c":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}}}`
`"a":{"type":"noul","instructions":["NOUL_INSTRUCTIONS"],"criteria":{"true":"NOUL_TRUE","false":"NOUL_FALSE"},"EXTENSION_KEY":"EXTENSION_VALUE"},` +
`"QUESTION_ID":{"type":"score","criteria":["SCORE_LOW",{"description":"SCORE_HIGH"}]}},"TOP_EXTENSION":{"x":"TOP_VALUE"}}`
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)})
require.NoError(t, err)
for _, text := range []string{"STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC",
"NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "SCORE_LOW", "SCORE_HIGH"} {
for _, text := range []string{"STATE_KEY", "STATE_TITLE", "STATE_ITEM", "STATE_NESTED", "CHOICE_INSTRUCTIONS", "OPTION_LABEL", "OPTION_DESC",
"NOUL_INSTRUCTIONS", "NOUL_TRUE", "NOUL_FALSE", "EXTENSION_KEY", "EXTENSION_VALUE", "QUESTION_ID", "SCORE_LOW", "SCORE_HIGH",
"TOP_EXTENSION", "TOP_VALUE"} {
require.Contains(t, snapshot.ScanText, text)
}
require.NotContains(t, snapshot.ScanText, "jev-latest")
require.Equal(t, 12, snapshot.MessageCount)
// Canonical field names and validated enum values are not client text.
for _, text := range []string{"jev-latest", "instructions", "criteria", "questions", "choice", "score"} {
require.NotContains(t, snapshot.ScanText, text)
}
// Map iteration order must not change the audited text or its hash.
for range 20 {
@@ -440,14 +443,22 @@ func TestPromptSnapshotTypeSafeSystemOneCollectsStateAndQuestions(t *testing.T)
require.NoError(t, err)
require.Contains(t, blocking.ScanText, "STATE_TITLE")
require.Contains(t, blocking.ScanText, "CHOICE_INSTRUCTIONS")
require.Contains(t, blocking.ScanText, "QUESTION_ID")
}
func TestPromptSnapshotTypeSafeSystemOneStringStateIsAudited(t *testing.T) {
func TestPromptSnapshotTypeSafeSystemOneStringStateIsPrioritized(t *testing.T) {
snapshot, err := ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":"plain state","questions":{"q":{"type":"noul"}}}`)})
require.NoError(t, err)
require.True(t, strings.HasPrefix(snapshot.ScanText, "plain state"))
require.Equal(t, 1, snapshot.MessageCount)
require.Equal(t, 2, snapshot.MessageCount)
}
_, err = ExtractPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(`{"model":"jev-latest","state":{"n":1},"questions":{"q":{"type":"noul"}}}`)})
require.ErrorIs(t, err, ErrNoPromptText)
func TestPromptSnapshotTypeSafeSystemOneAuditsKeyOnlyPayloads(t *testing.T) {
body := `{"model":"jev-latest","state":{"HIDDEN_STATE_KEY":1},"questions":{"HIDDEN_QUESTION_ID":{"type":"noul"}}}`
for _, latestTurnOnly := range []bool{false, true} {
snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: "typesafe_systemone", Body: []byte(body)}, latestTurnOnly)
require.NoError(t, err)
require.Contains(t, snapshot.ScanText, "HIDDEN_STATE_KEY")
require.Contains(t, snapshot.ScanText, "HIDDEN_QUESTION_ID")
}
}
+2 -1
View File
@@ -202,7 +202,8 @@ func RegisterGatewayRoutes(
}
h.Gateway.Messages(c)
})
gateway.POST("/systemone", h.Gateway.SystemOne)
// System One carries only JSON text, so it uses the text body limit.
gateway.POST("/systemone", textBodyLimit, h.Gateway.SystemOne)
// /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates
// locally, and Anthropic-compatible platforms retain their existing path.
gateway.POST("/messages/count_tokens", countTokensHandler)
+8 -3
View File
@@ -1014,10 +1014,15 @@ func (a *Account) GetTypeSafeBaseURL() string {
if a == nil || !a.IsTypeSafe() || a.Type != AccountTypeAPIKey {
return ""
}
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return strings.TrimRight(baseURL, "/")
baseURL := strings.TrimRight(strings.TrimSpace(a.GetCredential("base_url")), "/")
// The System One path already carries /v1; accept a base URL pasted with it.
if len(baseURL) >= 3 && strings.EqualFold(baseURL[len(baseURL)-3:], "/v1") {
baseURL = strings.TrimRight(baseURL[:len(baseURL)-3], "/")
}
return typesafe.DefaultBaseURL
if baseURL == "" {
return typesafe.DefaultBaseURL
}
return baseURL
}
func (a *Account) GetTypeSafeAPIKey() string {
+7 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
@@ -296,6 +297,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
return xai.DefaultModelIDs()
case PlatformOpenCodeGo:
return DefaultOpenCodeGoModelIDs()
case PlatformTypeSafe:
return []string{typesafe.JevLatestModel}
case PlatformComposite:
return compositeDefaultModelsListCandidateIDs()
default:
@@ -316,7 +319,10 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
func compositeDefaultModelsListCandidateIDs() []string {
seen := make(map[string]struct{})
ids := make([]string, 0)
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe} {
// TypeSafe stays out of the static composite candidates (jev-latest only works
// through /v1/systemone); groups with TypeSafe accounts still get it from the
// account model mappings collected by GetGroupModelsListCandidates.
for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
for _, id := range defaultModelsListCandidateIDs(platform) {
if _, ok := seen[id]; ok {
continue
@@ -65,25 +65,45 @@ func extractContentModerationInput(protocol string, body []byte, filterReminders
return out
}
// collectSystemOneInput moderates the evaluated state plus every question's
// instructions and criteria: all of them are client text sent to Jev.
// collectSystemOneInput moderates every client-controlled text of a System One
// request: question IDs, every question field except the validated type,
// unknown top-level extension fields, and the evaluated state. Object keys are
// sent to Jev as part of the JSON, so they are moderated like values.
func (collector moderationTextCollector) collectSystemOneInput(body []byte, parts *[]string) {
collector.collectSystemOneText(gjson.GetBytes(body, "state"), parts)
gjson.GetBytes(body, "questions").ForEach(func(_, question gjson.Result) bool {
collector.collectSystemOneText(question.Get("instructions"), parts)
criteria := question.Get("criteria")
if question.Get("type").String() == "choice" && criteria.IsObject() {
// Choice option labels are client-chosen text as well.
criteria.ForEach(func(label, description gjson.Result) bool {
collector.addModerationText(parts, label.String())
collector.collectSystemOneText(description, parts)
return true
})
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
}
collector.collectSystemOneText(criteria, parts)
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) {
@@ -92,11 +112,17 @@ func (collector moderationTextCollector) collectSystemOneText(value gjson.Result
return
case value.Type == gjson.String:
collector.addModerationText(parts, value.String())
case value.IsArray(), value.IsObject():
case value.IsArray():
value.ForEach(func(_, child gjson.Result) bool {
collector.collectSystemOneText(child, parts)
return true
})
case value.IsObject():
value.ForEach(func(key, child gjson.Result) bool {
collector.addModerationText(parts, key.String())
collector.collectSystemOneText(child, parts)
return true
})
}
}
@@ -13,9 +13,10 @@ func TestExtractContentModerationInputTypeSafeSystemOne(t *testing.T) {
want string
}{
{"string", `{"state":"plain text"}`, "plain text"},
{"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "hello world"},
{"array", `{"state":["first",{"text":"second"},3]}`, "first second"},
{"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"}}}}`, "state text pick one label a desc a label b low high judge yes"},
{"object", `{"state":{"title":"hello","nested":{"body":"world"}}}`, "title hello nested body world"},
{"array", `{"state":["first",{"text":"second"},3]}`, "first text second"},
{"questions", `{"state":"state text","questions":{"c":{"type":"choice","instructions":"pick one","criteria":{"label a":{"description":"desc a"},"label b":null}},"s":{"type":"score","criteria":["low",{"text":"high"}]},"n":{"type":"noul","instructions":["judge"],"criteria":{"true":"yes"},"ext":"extra"}},"top":{"k":"v"}}`, "c pick one label a description desc a label b s low text high n judge true yes ext extra top k v state text"},
{"key only payload", `{"state":{"hidden state key":1},"questions":{"hidden question id":{"type":"noul"}}}`, "hidden question id hidden state key"},
} {
t.Run(tc.name, func(t *testing.T) {
input := ExtractContentModerationInput(ContentModerationProtocolTypeSafeSystemOne, []byte(tc.body))
+29 -2
View File
@@ -67,6 +67,22 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a
decoded, err := typesafe.DecodeSystemOneResponse(resp.Body)
if err != nil {
// The upstream accepted (and may have charged) this request but the
// gateway cannot relay it; keep an ops trail for reconciliation.
setOpsUpstreamError(c, resp.StatusCode, err.Error(), "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Passthrough: true,
ProxyID: opsUpstreamProxyID(account),
ProxyName: opsUpstreamProxyName(account),
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: upstreamURL,
Kind: "response_error",
Message: err.Error(),
})
return nil, err
}
return &SystemOneForwardResult{
@@ -84,8 +100,19 @@ func (s *GatewayService) ForwardSystemOne(ctx context.Context, c *gin.Context, a
}, nil
}
// IsSystemOneRequestErrorStatus reports upstream statuses that describe the
// caller's own payload (malformed, unprocessable, or too large).
func IsSystemOneRequestErrorStatus(status int) bool {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity:
return true
default:
return false
}
}
// handleSystemOneErrorResponse applies the shared account error policy to a
// non-2xx System One response. 400/422 describe the caller's own payload, so
// non-2xx System One response. 400/413/422 describe the caller's own payload, so
// they never touch account state (a tenant must not be able to disable an
// account with bad input) and are not retried elsewhere. Every other status
// goes through the account error policy (custom error codes, temporary
@@ -109,7 +136,7 @@ func (s *GatewayService) handleSystemOneErrorResponse(ctx context.Context, c *gi
Message: upstreamMsg,
}
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnprocessableEntity {
if IsSystemOneRequestErrorStatus(resp.StatusCode) {
appendOpsUpstreamError(c, event)
return &SystemOneUpstreamError{StatusCode: resp.StatusCode}
}
@@ -248,7 +248,7 @@ func systemOneOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent {
}
func TestForwardSystemOneRequestErrorsNeverTouchAccountState(t *testing.T) {
for _, status := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity} {
for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusUnprocessableEntity} {
t.Run(http.StatusText(status), func(t *testing.T) {
// Even a custom error-code rule must not let client input disable the account.
account := &Account{ID: 21, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{
@@ -357,14 +357,46 @@ func TestForwardSystemOneRejectsOversizedResponse(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(oversized))}, nil
}}
account := &Account{ID: 26, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
_, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
c := newSystemOneTestContext()
_, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), c, account, []byte(`{}`))
require.ErrorIs(t, err, typesafe.ErrSystemOneResponseTooLarge)
events := systemOneOpsEvents(t, c)
require.Len(t, events, 1)
require.Equal(t, "response_error", events[0].Kind)
require.Equal(t, http.StatusOK, events[0].UpstreamStatusCode)
}
func TestForwardSystemOneBillsLenientUsageShapes(t *testing.T) {
upstream := &systemOneHTTPUpstream{do: func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"answers":{},"usage":{"input_tokens":"21","output_tokens":1.0}}`))}, nil
}}
account := &Account{ID: 27, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "http://typesafe.test", "api_key": "ts-secret"}}
result, err := newSystemOneTestService(upstream).ForwardSystemOne(context.Background(), newSystemOneTestContext(), account, []byte(`{}`))
require.NoError(t, err)
require.Equal(t, 21, result.Usage.InputTokens)
require.Equal(t, 1, result.Usage.OutputTokens)
}
func TestTypeSafeAccountBaseURLNeverFallsBackToAnthropic(t *testing.T) {
account := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "ts-secret"}}
require.Equal(t, typesafe.DefaultBaseURL, account.GetBaseURL())
require.Equal(t, typesafe.DefaultBaseURL, account.GetTypeSafeBaseURL())
for raw, want := range map[string]string{
"https://api.typesafe.ai/v1": "https://api.typesafe.ai",
"https://api.typesafe.ai/V1/": "https://api.typesafe.ai",
"https://proxy.example/typesafe/": "https://proxy.example/typesafe",
"https://proxy.example/apiv1": "https://proxy.example/apiv1",
" https://proxy.example/x/v1/ ": "https://proxy.example/x",
"/v1": typesafe.DefaultBaseURL,
} {
withBase := &Account{Platform: PlatformTypeSafe, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": raw}}
require.Equal(t, want, withBase.GetTypeSafeBaseURL(), raw)
}
anthropic := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk"}}
require.Equal(t, "https://api.anthropic.com", anthropic.GetBaseURL())
}
func TestTypeSafeModelsListCandidates(t *testing.T) {
require.Equal(t, []string{typesafe.JevLatestModel}, defaultModelsListCandidateIDs(PlatformTypeSafe))
require.NotContains(t, compositeDefaultModelsListCandidateIDs(), typesafe.JevLatestModel)
}
@@ -4881,6 +4881,8 @@ watch(
modelMappings.value = []
if (newPlatform === 'typesafe') {
accountCategory.value = 'apikey'
// Grok 等平台会把模式切到映射;TypeSafe 只用白名单写入 jev-latest。
modelRestrictionMode.value = 'whitelist'
allowedModels.value = ['jev-latest']
}
// Antigravity: 默认使用映射模式并填充默认映射