Reject requests locally that the TypeSafe service always rejects: null state (422), null Score levels (422), Noul questions with neither instructions nor criteria (400), and Choice/Score questions over the 255-option and 10-level limits (400). Adds MaxChoiceOptions and MaxScoreLevels and bumps Version to 0.2.0.
- commit
- b86478c440b0f87928d457f9e83a6cd4674ecd6e
- parent
- 224e331036fb5c30ffd7c51f3f40c8128edf3c19
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
| M | CHANGELOG.md | 16 | ++++++++- |
| M | README.md | 8 | ++++- |
| M | options.go | 2 | +- |
| M | questions.go | 41 | ++++++++++++++++++---- |
| M | questions_test.go | 56 | +++++++++++++++++++++++++++++- |
| M | types.go | 17 | +++++++-- |
@@ -6,6 +6,19 @@ All notable changes to this project are documented here.
66 77 No changes yet.88 9+## [0.2.0] - 2026-09-2610+11+Local request validation now matches the limits enforced by the hosted API,12+so requests the service would always reject fail before any network call.13+14+- Added `MaxChoiceOptions` (255) and `MaxScoreLevels` (10); `Validate` rejects15+ Choice and Score questions that exceed them (the API returns HTTP 400).16+- `Validate` rejects a null `State`, including a nil pointer (the API returns17+ HTTP 422).18+- `Validate` rejects null Score levels (the API returns HTTP 422).19+- `Validate` rejects Noul questions with neither instructions nor a non-null20+ criterion (the API returns HTTP 400).21+922 ## [0.1.0] - 2026-09-171023 1124 Initial public release of the unofficial, community-maintained Go SDK.@@ -15,5 +28,6 @@ Initial public release of the unofficial, community-maintained Go SDK.
1528 - Added context-aware timeouts, retry policy, request IDs, and typed errors.1629 - Added examples and compile-checked documentation.1730 18-[Unreleased]: https://github.com/FelineStateMachine/typesafe-go/compare/v0.1.0...HEAD31+[Unreleased]: https://github.com/FelineStateMachine/typesafe-go/compare/v0.2.0...HEAD32+[0.2.0]: https://github.com/FelineStateMachine/typesafe-go/compare/v0.1.0...v0.2.01933 [0.1.0]: https://github.com/FelineStateMachine/typesafe-go/releases/tag/v0.1.0@@ -8,7 +8,7 @@ hosted service; it does not run inference locally.
88 The module is pure Go and uses only the standard library.99 1010 ```sh11-go get github.com/FelineStateMachine/typesafe-go@v0.1.011+go get github.com/FelineStateMachine/typesafe-go@v0.2.01212 ```1313 1414 Documentation: [pkg.go.dev/github.com/FelineStateMachine/typesafe-go](https://pkg.go.dev/github.com/FelineStateMachine/typesafe-go)@@ -142,6 +142,12 @@ replaying API keys or state to another endpoint. When `WithHTTPClient` is used,
142142 the client configuration is copied before the SDK installs its redirect policy;143143 the caller's `*http.Client` is never mutated.144144 145+`SystemOne` validates requests locally before sending them, matching the146+hosted service's limits: `State` must not be null, a Noul needs instructions147+or a non-null criterion, a Choice has 2 to `MaxChoiceOptions` (255) labels,148+and a Score has 2 to `MaxScoreLevels` (10) non-null levels. Call149+`SystemOneRequest.Validate` to check a request without sending it.150+145151 HTTP failures return `*APIError`, which exposes status, request ID, headers,146152 method, URL, and response bytes without placing the body in its `Error()`147153 string. Response decoding validates required and known answer fields and@@ -16,7 +16,7 @@ const DefaultBaseURL = "https://api.typesafe.ai"
1616 const DefaultModel = "jev-latest"1717 1818 // Version is the SDK version sent in diagnostic request headers.19-const Version = "0.1.0"19+const Version = "0.2.0"2020 2121 type clientConfig struct {2222 apiKey string@@ -7,6 +7,14 @@ import (
77 "fmt"88 )99 10+// API limits enforced by the hosted service.11+const (12+ // MaxChoiceOptions is the maximum number of labels in a ChoiceQuestion.13+ MaxChoiceOptions = 25514+ // MaxScoreLevels is the maximum number of levels in a ScoreQuestion.15+ MaxScoreLevels = 1016+)17+1018 // Question is a typed System One question.1119 //1220 // The concrete question types are NoulQuestion, ChoiceQuestion, and@@ -22,7 +30,8 @@ type NoulCriteria struct {
2230 False any `json:"false"`2331 }2432 25-// NoulQuestion asks for the probability of a yes answer.33+// NoulQuestion asks for the probability of a yes answer. It requires34+// Instructions, a non-null criterion, or both.2635 type NoulQuestion struct {2736 Instructions any `json:"instructions"`2837 Criteria *NoulCriteria `json:"criteria"`@@ -30,7 +39,8 @@ type NoulQuestion struct {
3039 3140 func (NoulQuestion) questionType() string { return "noul" }3241 33-// ChoiceQuestion selects one label from Criteria.42+// ChoiceQuestion selects one label from Criteria, which must contain between43+// 2 and MaxChoiceOptions labels. A label's description may be nil.3444 type ChoiceQuestion struct {3545 Instructions any `json:"instructions"`3646 Criteria map[string]any `json:"criteria"`@@ -38,7 +48,8 @@ type ChoiceQuestion struct {
3848 3949 func (ChoiceQuestion) questionType() string { return "choice" }4050 41-// ScoreQuestion estimates a score using ordered rubric levels in Criteria.51+// ScoreQuestion estimates a score using ordered rubric levels in Criteria,52+// which must contain between 2 and MaxScoreLevels non-null levels.4253 type ScoreQuestion struct {4354 Instructions any `json:"instructions"`4455 Criteria []any `json:"criteria"`@@ -142,21 +153,32 @@ func validateQuestion(name string, question Question) error {
142153 func validateQuestionValue(name string, question Question) error {143154 switch value := question.(type) {144155 case NoulQuestion:145- if err := validateTopLevelValue(value.Instructions); err != nil {156+ kind, err := topLevelKind(value.Instructions)157+ if err != nil {146158 return fmt.Errorf("question %q instructions: %w", name, err)147159 }160+ described := kind != 'n'148161 if value.Criteria != nil {149- if err := validateTopLevelValue(value.Criteria.True); err != nil {162+ kind, err := topLevelKind(value.Criteria.True)163+ if err != nil {150164 return fmt.Errorf("question %q criteria.true: %w", name, err)151165 }152- if err := validateTopLevelValue(value.Criteria.False); err != nil {166+ described = described || kind != 'n'167+ if kind, err = topLevelKind(value.Criteria.False); err != nil {153168 return fmt.Errorf("question %q criteria.false: %w", name, err)154169 }170+ described = described || kind != 'n'171+ }172+ if !described {173+ return fmt.Errorf("question %q noul requires instructions or criteria", name)155174 }156175 case ChoiceQuestion:157176 if len(value.Criteria) < 2 {158177 return fmt.Errorf("question %q choice criteria must contain at least two labels", name)159178 }179+ if len(value.Criteria) > MaxChoiceOptions {180+ return fmt.Errorf("question %q choice criteria must contain at most %d labels", name, MaxChoiceOptions)181+ }160182 if err := validateTopLevelValue(value.Instructions); err != nil {161183 return fmt.Errorf("question %q instructions: %w", name, err)162184 }@@ -169,12 +191,17 @@ func validateQuestionValue(name string, question Question) error {
169191 if len(value.Criteria) < 2 {170192 return fmt.Errorf("question %q score criteria must contain at least two levels", name)171193 }194+ if len(value.Criteria) > MaxScoreLevels {195+ return fmt.Errorf("question %q score criteria must contain at most %d levels", name, MaxScoreLevels)196+ }172197 if err := validateTopLevelValue(value.Instructions); err != nil {173198 return fmt.Errorf("question %q instructions: %w", name, err)174199 }175200 for index, criterion := range value.Criteria {176- if err := validateTopLevelValue(criterion); err != nil {201+ if kind, err := topLevelKind(criterion); err != nil {177202 return fmt.Errorf("question %q criteria[%d]: %w", name, index, err)203+ } else if kind == 'n' {204+ return fmt.Errorf("question %q criteria[%d]: score level must not be null", name, index)178205 }179206 }180207 default:@@ -2,6 +2,7 @@ package typesafe
22 33 import (44 jsonv2 "encoding/json/v2"5+ "fmt"56 "reflect"67 "strings"78 "testing"@@ -68,6 +69,57 @@ func TestRequestValidate(t *testing.T) {
6869 }6970 }7071 72+func TestRequestValidateServiceLimits(t *testing.T) {73+ labels := func(n int) map[string]any {74+ criteria := make(map[string]any, n)75+ for i := range n {76+ criteria[fmt.Sprintf("o%d", i)] = nil77+ }78+ return criteria79+ }80+ levels := func(n int) []any {81+ criteria := make([]any, n)82+ for i := range criteria {83+ criteria[i] = fmt.Sprintf("l%d", i)84+ }85+ return criteria86+ }87+ var nilState *requestState88+ tests := []struct {89+ name string90+ state any91+ q Question92+ wantErr string93+ }{94+ {"max choice labels", "x", ChoiceQuestion{Instructions: "x", Criteria: labels(MaxChoiceOptions)}, ""},95+ {"too many choice labels", "x", ChoiceQuestion{Instructions: "x", Criteria: labels(MaxChoiceOptions + 1)}, "at most 255 labels"},96+ {"max score levels", "x", ScoreQuestion{Instructions: "x", Criteria: levels(MaxScoreLevels)}, ""},97+ {"too many score levels", "x", ScoreQuestion{Instructions: "x", Criteria: levels(MaxScoreLevels + 1)}, "at most 10 levels"},98+ {"null score level", "x", ScoreQuestion{Instructions: "x", Criteria: []any{"low", nil}}, "must not be null"},99+ {"null choice instructions", "x", ChoiceQuestion{Criteria: labels(2)}, ""},100+ {"noul criteria only", "x", NoulQuestion{Criteria: &NoulCriteria{True: "yes"}}, ""},101+ {"noul without description", "x", NoulQuestion{Criteria: &NoulCriteria{}}, "requires instructions or criteria"},102+ {"noul empty", "x", NoulQuestion{}, "requires instructions or criteria"},103+ {"nil state", nil, NoulQuestion{Instructions: "x"}, "state is required"},104+ {"nil pointer state", nilState, NoulQuestion{Instructions: "x"}, "state is required"},105+ {"empty string state", "", NoulQuestion{Instructions: "x"}, ""},106+ }107+ for _, tt := range tests {108+ t.Run(tt.name, func(t *testing.T) {109+ err := SystemOneRequest{State: tt.state, Questions: map[string]Question{"q": tt.q}}.Validate()110+ if tt.wantErr == "" {111+ if err != nil {112+ t.Fatalf("unexpected error: %v", err)113+ }114+ return115+ }116+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {117+ t.Fatalf("got %v, want error containing %q", err, tt.wantErr)118+ }119+ })120+ }121+}122+71123 type requestState struct {72124 Name string `json:"name"`73125 }@@ -105,13 +157,13 @@ func TestRequestValidateQuestionPointers(t *testing.T) {
105157 choice := &ChoiceQuestion{Instructions: "x", Criteria: map[string]any{"a": nil, "b": nil}}106158 score := &ScoreQuestion{Instructions: "x", Criteria: []any{"a", "b"}}107159 for name, question := range map[string]Question{"noul": noul, "choice": choice, "score": score} {108- request := SystemOneRequest{Questions: map[string]Question{name: question}}160+ request := SystemOneRequest{State: "x", Questions: map[string]Question{name: question}}109161 if err := request.Validate(); err != nil {110162 t.Fatalf("%s: %v", name, err)111163 }112164 }113165 var nilNoul *NoulQuestion114- if err := (SystemOneRequest{Questions: map[string]Question{"q": nilNoul}}).Validate(); err == nil {166+ if err := (SystemOneRequest{State: "x", Questions: map[string]Question{"q": nilNoul}}).Validate(); err == nil {115167 t.Fatal("typed nil question should be rejected")116168 }117169 }@@ -15,8 +15,10 @@ type SystemOneRequest struct {
1515 1616 // Validate checks the locally enforceable System One request constraints.1717 func (r SystemOneRequest) Validate() error {18- if err := validateTopLevelValue(r.State); err != nil {18+ if kind, err := topLevelKind(r.State); err != nil {1919 return fmt.Errorf("state: %w", err)20+ } else if kind == 'n' {21+ return fmt.Errorf("state is required and must not be null")2022 }2123 if len(r.Questions) == 0 {2224 return fmt.Errorf("questions must contain at least one question")@@ -33,13 +35,20 @@ func (r SystemOneRequest) Validate() error {
3335 }3436 3537 func validateTopLevelValue(value any) error {38+ _, err := topLevelKind(value)39+ return err40+}41+42+// topLevelKind validates that value encodes as a JSON string, object, array,43+// or null, and returns its kind so callers can apply stricter rules.44+func topLevelKind(value any) (jsontext.Kind, error) {3645 encoded, err := jsonv2.Marshal(value)3746 if err != nil {38- return fmt.Errorf("must be valid JSON: %w", err)47+ return 0, fmt.Errorf("must be valid JSON: %w", err)3948 }4049 kind := jsontext.Value(encoded).Kind()4150 if kind != 'n' && kind != '"' && kind != '{' && kind != '[' {42- return fmt.Errorf("must be a JSON string, object, array, or null")51+ return 0, fmt.Errorf("must be a JSON string, object, array, or null")4352 }44- return nil53+ return kind, nil4554 }