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
+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)
}