mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(typesafe): align System One validation with upstream schema
This commit is contained in:
@@ -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' \
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user