From bb97ab364af010cb1720ec175af02d8303d8f74d Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:44:50 +0800 Subject: [PATCH] fix(typesafe): align System One validation with upstream schema --- README.md | 2 + README_CN.md | 2 + backend/internal/pkg/typesafe/systemone.go | 39 ++++++++++++----- .../internal/pkg/typesafe/systemone_test.go | 26 +++++++++-- .../service/gateway_systemone_test.go | 43 +++++++++++++++++++ 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9b915ca5e3..48a6f0cdb3 100644 --- a/README.md +++ b/README.md @@ -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' \ diff --git a/README_CN.md b/README_CN.md index 6c7d834dbb..4680298f82 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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' \ diff --git a/backend/internal/pkg/typesafe/systemone.go b/backend/internal/pkg/typesafe/systemone.go index 0222289c13..898a0e5c24 100644 --- a/backend/internal/pkg/typesafe/systemone.go +++ b/backend/internal/pkg/typesafe/systemone.go @@ -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) == "" { diff --git a/backend/internal/pkg/typesafe/systemone_test.go b/backend/internal/pkg/typesafe/systemone_test.go index a5056bd358..c4233a97f0 100644 --- a/backend/internal/pkg/typesafe/systemone_test.go +++ b/backend/internal/pkg/typesafe/systemone_test.go @@ -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) { diff --git a/backend/internal/service/gateway_systemone_test.go b/backend/internal/service/gateway_systemone_test.go index a69964aa8e..c7a6606b54 100644 --- a/backend/internal/service/gateway_systemone_test.go +++ b/backend/internal/service/gateway_systemone_test.go @@ -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) {