fix(typesafe): align System One validation with upstream schema

This commit is contained in:
aofee
2026-10-02 09:44:50 +08:00
parent 7225538358
commit bb97ab364a
5 changed files with 98 additions and 14 deletions
+2
View File
@@ -839,6 +839,8 @@ Sub2API supports TypeSafe API-key accounts through Jev's native, non-streaming S
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' \
+2
View File
@@ -755,6 +755,8 @@ Sub2API 支持使用 TypeSafe API Key 账户,通过 Jev 原生、非流式的
请求和成功响应保持 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' \
+29 -10
View File
@@ -67,34 +67,45 @@ func validateQuestion(id string, raw json.RawMessage) error {
if err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
if err := validateStringObjectOrArray(question.Instructions, "instructions"); err != nil {
// 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 && criteria[0] != '{' {
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 || len(criteria) == 0 {
return fmt.Errorf("question %q: choice criteria must be a non-empty object", id)
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 string(value) != "null" && !rawString(value) {
return fmt.Errorf("question %q: choice criteria values must be strings or null", id)
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) < 2 {
return fmt.Errorf("question %q: score criteria must contain at least two strings", id)
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 !rawString(value) {
return fmt.Errorf("question %q: score criteria must contain only strings", id)
if err := validateStringObjectOrArray(value, "score criteria value"); err != nil {
return fmt.Errorf("question %q: %w", id, err)
}
}
default:
@@ -103,6 +114,14 @@ func validateQuestion(id string, raw json.RawMessage) error {
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) == "" {
@@ -15,6 +15,15 @@ func TestValidateSystemOneRequestValidQuestionTypes(t *testing.T) {
{"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))
@@ -40,11 +49,20 @@ func TestValidateSystemOneRequestRejectsInvalidRequests(t *testing.T) {
{"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 criteria null", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"noul","instructions":"x","criteria":null}}}`, "noul criteria"},
{"empty choice criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"choice","instructions":"x","criteria":{}}}}`, "choice 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"},
{"short score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","instructions":"x","criteria":["only"]}}}`, "score criteria"},
{"non-string score criteria", `{"model":"jev-latest","state":"x","questions":{"q":{"type":"score","instructions":"x","criteria":["low",2]}}}`, "score 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"},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -12,6 +12,7 @@ import (
"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"
)
@@ -117,6 +118,48 @@ func TestForwardSystemOneAllowsSuccessfulResponseWithoutModel(t *testing.T) {
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) {