mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user