clone

an unofficial pure go sdk for the typesafe api: typed answers, retries and no dependencies

Add pure Go 1.27 TypeSafe SDK with typed answers and resilient HTTP client

FelineStateMachine committed 224e331
commit
224e331036fb5c30ffd7c51f3f40c8128edf3c19
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
25 files changed, +2465 -0
A.github/workflows/ci.yml30+++
A.gitignore14+
ACHANGELOG.md19++
ACONTRIBUTING.md24++
ALICENSE21++
AREADME.md205++++++++++++++++++
Aclient.go100+++++++++
Aclient_test.go199+++++++++++++++++
Adoc.go8+
Aerrors.go39+++
Aexample_test.go62+++++
Aexamples/classify/main.go62+++++
Ago.mod3+
Aintegration_test.go55+++++
Aoptions.go170+++++++++++++++
Aoptions_test.go73++++++
Aquestions.go200+++++++++++++++++
Aquestions_test.go117++++++++++
Aresponses.go351++++++++++++++++++++++++++++++
Aresponses_test.go99++++++++
Aretry.go124+++++++++++
Aretry_test.go170+++++++++++++++
Atransport.go149+++++++++++++
Atransport_test.go126+++++++++++
Atypes.go45++++
A .github/workflows/ci.yml +30 -0
A .gitignore +14 -0
A CHANGELOG.md +19 -0
A CONTRIBUTING.md +24 -0
A LICENSE +21 -0
A README.md +205 -0
@@ -0,0 +1,205 @@
1+# typesafe-go2+3+An unofficial, community-maintained Go SDK for the hosted [TypeSafe](https://typesafe.ai/) API.4+It is an independent project by FelineStateMachine, with no official affiliation5+with or endorsement from TypeSafe. The SDK sends state and questions to the6+hosted service; it does not run inference locally.7+8+The module is pure Go and uses only the standard library.9+10+```sh11+go get github.com/FelineStateMachine/typesafe-go@v0.1.012+```13+14+Documentation: [pkg.go.dev/github.com/FelineStateMachine/typesafe-go](https://pkg.go.dev/github.com/FelineStateMachine/typesafe-go)15+16+## Quickstart17+18+Set `TYPESAFE_API_KEY` and create a client. `NewClient` also accepts19+`WithAPIKey` when applications prefer explicit configuration.20+21+```go22+package main23+24+import (25+ "context"26+ "fmt"27+ "log"28+29+ "github.com/FelineStateMachine/typesafe-go"30+)31+32+func main() {33+ client, err := typesafe.NewClient()34+ if err != nil {35+ log.Fatal(err)36+ }37+38+ result, err := client.SystemOne(context.Background(), typesafe.SystemOneRequest{39+ State: map[string]any{"text": "The package has tests and documentation."},40+ Questions: map[string]typesafe.Question{41+ "is_ready": typesafe.NoulQuestion{42+ Instructions: "Is this ready to publish?",43+ Criteria: &typesafe.NoulCriteria{44+ True: "The package is ready for users.",45+ False: "The package still needs substantial work.",46+ },47+ },48+ "release_channel": typesafe.ChoiceQuestion{49+ Instructions: "Which release channel fits?",50+ Criteria: map[string]any{51+ "stable": "Ready for a stable release.",52+ "beta": "Useful, but still needs real-world feedback.",53+ },54+ },55+ "readiness": typesafe.ScoreQuestion{56+ Instructions: "Score the release readiness.",57+ Criteria: []any{58+ "Not ready",59+ "Needs review",60+ "Ready",61+ },62+ },63+ },64+ })65+ if err != nil {66+ log.Fatal(err)67+ }68+69+ isReady, err := result.Noul("is_ready")70+ if err != nil {71+ log.Fatal(err)72+ }73+ channel, err := result.Choice("release_channel")74+ if err != nil {75+ log.Fatal(err)76+ }77+ readiness, err := result.Score("readiness")78+ if err != nil {79+ log.Fatal(err)80+ }81+ fmt.Printf("ready=%v channel=%s score=%.1f\n", isReady.Noul >= 0.5, channel.Choice, readiness.Score)82+}83+```84+85+The accessors check the answer's wire type and return an error when a question86+has a different type, is missing, or contains malformed known fields. Unknown87+answer types are retained as `UnknownAnswer` so a newer service response is not88+silently discarded.89+90+`NoulAnswer.Noul` is the service's yes probability. `ChoiceAnswer.Confidence`91+and `ScoreAnswer.Confidence` describe confidence in the selected result; they92+are not probabilities for the answer's truth. Treat all model output as93+uncertain application data and choose thresholds appropriate to your use case.94+95+## Models96+97+```go98+models, err := client.ListModels(context.Background())99+if err != nil {100+ // Handle *typesafe.APIError or *typesafe.ResponseError as appropriate.101+ log.Fatal(err)102+}103+for _, model := range models.Models {104+ fmt.Println(model.Name, model.Description)105+}106+```107+108+Requests use `jev-latest` by default. Select another model with109+`typesafe.WithModel("model-name")`, or set `TYPESAFE_DEFAULT_MODEL`. A request's110+`SystemOneRequest.Model` takes precedence for that request.111+112+## Configuration113+114+`NewClient` reads these environment variables when the corresponding option115+is not supplied:116+117+| Setting | Option | Environment | Default |118+| --- | --- | --- | --- |119+| API key | `WithAPIKey` | `TYPESAFE_API_KEY` | required |120+| Base URL | `WithBaseURL` | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` |121+| Model | `WithModel` | `TYPESAFE_DEFAULT_MODEL` | `jev-latest` |122+| HTTP client | `WithHTTPClient` | — | fresh `http.Client` |123+| Attempt timeout | `WithTimeout` | — | 10 seconds |124+| Retries | `WithMaxRetries` or `WithRetryPolicy` | — | 2 retries |125+126+Whitespace-only environment values are ignored. Explicit options take127+precedence over environment values. `WithHeaders` adds headers to requests;128+the SDK sets authentication and content headers itself.129+130+## Errors, context, and retries131+132+Every request accepts a `context.Context`. Cancellation and an expired caller133+deadline stop the request and are returned through the wrapped error chain.134+`WithTimeout` limits each attempt; use the context for an overall deadline.135+136+The default policy retries connection errors, timeouts, HTTP 408, HTTP 429,137+and HTTP 5xx responses. It uses exponential backoff with jitter and honors138+`retry-after-ms` and `Retry-After` up to the policy's maximum. Set a custom139+`RetryPolicy` when the defaults do not fit, or `WithMaxRetries(0)` to disable140+retries. Caller cancellation is never retried. Redirects are rejected to avoid141+replaying API keys or state to another endpoint. When `WithHTTPClient` is used,142+the client configuration is copied before the SDK installs its redirect policy;143+the caller's `*http.Client` is never mutated.144+145+HTTP failures return `*APIError`, which exposes status, request ID, headers,146+method, URL, and response bytes without placing the body in its `Error()`147+string. Response decoding validates required and known answer fields and148+returns `*ResponseError` when it fails; the error retains the request ID when149+one was supplied. The typed accessors then check only whether an answer exists150+and has the requested type. Use `errors.Is` for context errors and151+`errors.AsType` for typed errors:152+153+```go154+apiErr, ok := errors.AsType[*typesafe.APIError](err)155+if ok {156+ fmt.Println(apiErr.StatusCode, apiErr.RequestID)157+}158+```159+160+Responses larger than 16 MiB are rejected before decoding. This bounds memory161+use when an endpoint returns an unexpectedly large payload.162+163+## Compatibility and scope164+165+This release targets Go 1.27 and later, supports Go modules, and has no166+third-party dependencies. The public surface covers the documented167+`SystemOne` and `models` operations, typed Noul, Choice, and Score questions,168+and forward-compatible handling of unknown answer types. The API contract was169+checked against the TypeSafe API documentation; the documentation is a170+reference for wire compatibility and does not imply that this repository is an171+official TypeSafe SDK.172+173+Go 1.27's `encoding/json/v2` is used for strict response handling: duplicate174+object members and invalid UTF-8 are rejected, while unknown answer payloads175+are preserved as `jsontext.Value`. In addition to the named convenience176+methods, a response can be read with a generic accessor:177+178+```go179+answer, err := result.Answer[typesafe.ChoiceAnswer]("release_channel")180+```181+182+## Development183+184+The default test suite is offline. Run the same checks used by CI with:185+186+```sh187+gofmt -w .188+go test ./...189+go test -race ./...190+go vet ./...191+```192+193+The live API check makes billable requests and is explicitly gated. Run it194+only with a configured API key:195+196+```sh197+TYPESAFE_LIVE_TEST=1 go test -run TestLiveAPI -v198+```199+200+See [CONTRIBUTING.md](CONTRIBUTING.md), [CHANGELOG.md](CHANGELOG.md), and the201+[TypeSafe API documentation](https://docs.typesafe.ai/api) for more context.202+203+## License204+205+MIT. See [LICENSE](LICENSE).
A client.go +100 -0
@@ -0,0 +1,100 @@
1+package typesafe2+3+import (4+ "context"5+ "encoding/json/v2"6+ "fmt"7+ "net/http"8+)9+10+// Client calls the TypeSafe hosted API. A client can be shared by concurrent11+// goroutines. Caller-owned request values and custom transports must also be12+// safe for their usage; do not mutate a request while it is being marshaled.13+// Construct clients with NewClient; the zero value is not ready for use.14+type Client struct {15+ config clientConfig16+}17+18+// NewClient constructs a client using environment variables and options.19+// Explicit options take precedence. An API key is required. Configuration is20+// validated locally; construction does not make any network requests.21+func NewClient(options ...Option) (*Client, error) {22+ config := defaultConfig()23+ for _, option := range options {24+ if option == nil {25+ return nil, fmt.Errorf("typesafe: option must not be nil")26+ }27+ if err := option(&config); err != nil {28+ return nil, err29+ }30+ }31+ if err := config.validate(); err != nil {32+ return nil, err33+ }34+ client := *config.httpClient35+ client.CheckRedirect = func(*http.Request, []*http.Request) error {36+ return http.ErrUseLastResponse37+ }38+ config.httpClient = &client39+ return &Client{config: config}, nil40+}41+42+// SystemOne evaluates named questions against a shared state. Questions are43+// validated before sending. A blank model uses the client's configured model.44+// The context bounds the entire operation, including retries and backoff.45+func (c *Client) SystemOne(ctx context.Context, request SystemOneRequest) (*SystemOneResponse, error) {46+ if err := c.ready(ctx); err != nil {47+ return nil, err48+ }49+ if request.Model == "" {50+ request.Model = c.config.model51+ }52+ if err := request.Validate(); err != nil {53+ return nil, fmt.Errorf("typesafe: invalid request: %w", err)54+ }55+ body, err := json.Marshal(request)56+ if err != nil {57+ return nil, fmt.Errorf("typesafe: encode request: %w", err)58+ }59+ wire, err := c.send(ctx, http.MethodPost, "/v1/systemone", body)60+ if err != nil {61+ return nil, err62+ }63+ var result SystemOneResponse64+ if err := json.Unmarshal(wire.body, &result); err != nil {65+ return nil, &ResponseError{RequestID: wire.requestID(), Err: err}66+ }67+ result.RequestID = wire.requestID()68+ return &result, nil69+}70+71+// ListModels lists models available to the authenticated account.72+// An empty Models slice is a valid response.73+func (c *Client) ListModels(ctx context.Context) (*ListModelsResponse, error) {74+ if err := c.ready(ctx); err != nil {75+ return nil, err76+ }77+ wire, err := c.send(ctx, http.MethodGet, "/v1/models", nil)78+ if err != nil {79+ return nil, err80+ }81+ var result ListModelsResponse82+ if err := json.Unmarshal(wire.body, &result); err != nil {83+ return nil, &ResponseError{RequestID: wire.requestID(), Err: err}84+ }85+ if result.Models == nil {86+ return nil, &ResponseError{RequestID: wire.requestID(), Err: fmt.Errorf("missing models array")}87+ }88+ result.RequestID = wire.requestID()89+ return &result, nil90+}91+92+func (c *Client) ready(ctx context.Context) error {93+ if c == nil || c.config.httpClient == nil {94+ return fmt.Errorf("typesafe: construct the client with NewClient")95+ }96+ if ctx == nil {97+ return fmt.Errorf("typesafe: context must not be nil")98+ }99+ return ctx.Err()100+}
A client_test.go +199 -0
@@ -0,0 +1,199 @@
1+package typesafe2+3+import (4+ "context"5+ "encoding/json/jsontext"6+ "encoding/json/v2"7+ "errors"8+ "fmt"9+ "net/http"10+ "net/http/httptest"11+ "strings"12+ "sync"13+ "sync/atomic"14+ "testing"15+ "time"16+)17+18+const testResponse = `{"model":"jev-test","answers":{"urgent":{"type":"noul","noul":0}},"usage":{"input_tokens":3,"output_tokens":1}}`19+20+func testClient(t *testing.T, handler http.HandlerFunc, options ...Option) *Client {21+ t.Helper()22+ server := httptest.NewServer(handler)23+ t.Cleanup(server.Close)24+ defaults := []Option{WithAPIKey("test-key"), WithBaseURL(server.URL), WithMaxRetries(0)}25+ client, err := NewClient(append(defaults, options...)...)26+ if err != nil {27+ t.Fatal(err)28+ }29+ return client30+}31+32+func testRequest() SystemOneRequest {33+ return SystemOneRequest{State: "a ticket", Questions: map[string]Question{34+ "urgent": NoulQuestion{Instructions: "Is this urgent?"},35+ }}36+}37+38+func writeResponse(t *testing.T, w http.ResponseWriter, body string) {39+ t.Helper()40+ if _, err := fmt.Fprint(w, body); err != nil {41+ t.Error(err)42+ }43+}44+45+func TestSystemOneWireContract(t *testing.T) {46+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {47+ if r.Method != http.MethodPost || r.URL.Path != "/v1/systemone" {48+ t.Errorf("unexpected route: %s %s", r.Method, r.URL.Path)49+ }50+ if got := r.Header.Get("Authorization"); got != "Bearer test-key" {51+ t.Errorf("authorization: %q", got)52+ }53+ if got := r.Header.Get("Content-Type"); got != "application/json" {54+ t.Errorf("content type: %q", got)55+ }56+ var body map[string]jsontext.Value57+ if err := json.UnmarshalRead(r.Body, &body); err != nil {58+ t.Error(err)59+ }60+ if string(body["model"]) != `"jev-custom"` || string(body["state"]) != `"a ticket"` {61+ t.Errorf("unexpected body: %s", body)62+ }63+ w.Header().Set("x-request-id", "req_test")64+ writeResponse(t, w, testResponse)65+ }, WithModel("jev-custom"))66+ response, err := client.SystemOne(context.Background(), testRequest())67+ if err != nil {68+ t.Fatal(err)69+ }70+ answer, err := response.Noul("urgent")71+ if err != nil || answer.Noul != 0 || response.RequestID != "req_test" {72+ t.Fatalf("response: %#v; answer: %#v; error: %v", response, answer, err)73+ }74+}75+76+func TestListModels(t *testing.T) {77+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {78+ if r.Method != http.MethodGet || r.URL.Path != "/v1/models" {79+ t.Errorf("unexpected route: %s %s", r.Method, r.URL.Path)80+ }81+ w.Header().Set("request-id", "req_models")82+ writeResponse(t, w, `{"models":[{"name":"jev-test","description":"Test model","release_date":"2026-09-17"}]}`)83+ })84+ response, err := client.ListModels(context.Background())85+ if err != nil {86+ t.Fatal(err)87+ }88+ if len(response.Models) != 1 || response.Models[0].Name != "jev-test" || response.RequestID != "req_models" {89+ t.Fatalf("unexpected response: %#v", response)90+ }91+}92+93+func TestModelOverrideAndBasePath(t *testing.T) {94+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {95+ if r.URL.Path != "/proxy/v1/systemone" {96+ t.Errorf("path: %q", r.URL.Path)97+ }98+ var body struct {99+ Model string `json:"model"`100+ }101+ if err := json.UnmarshalRead(r.Body, &body); err != nil {102+ t.Error(err)103+ }104+ if body.Model != "pinned-model" {105+ t.Errorf("model: %q", body.Model)106+ }107+ writeResponse(t, w, testResponse)108+ }))109+ defer server.Close()110+ client, err := NewClient(WithAPIKey("key"), WithBaseURL(server.URL+"/proxy/"))111+ if err != nil {112+ t.Fatal(err)113+ }114+ request := testRequest()115+ request.Model = "pinned-model"116+ if _, err := client.SystemOne(context.Background(), request); err != nil {117+ t.Fatal(err)118+ }119+}120+121+func TestAPIErrorPreservesMetadata(t *testing.T) {122+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {123+ w.Header().Set("x-request-id", "req_bad")124+ w.WriteHeader(http.StatusUnprocessableEntity)125+ writeResponse(t, w, `{"detail":"private request content"}`)126+ })127+ _, err := client.SystemOne(context.Background(), testRequest())128+ apiErr, ok := errors.AsType[*APIError](err)129+ if !ok {130+ t.Fatalf("expected APIError, got %v", err)131+ }132+ if apiErr.StatusCode != 422 || apiErr.RequestID != "req_bad" || !strings.Contains(string(apiErr.Body), "private") {133+ t.Fatalf("missing error metadata: %#v", apiErr)134+ }135+ if strings.Contains(err.Error(), "private") || strings.Contains(err.Error(), "test-key") {136+ t.Fatalf("error leaked content: %s", err)137+ }138+}139+140+func TestMalformedResponses(t *testing.T) {141+ for _, body := range []string{"", "not JSON", `null`, `{}`, testResponse + `{}`, `{"models":null}`} {142+ t.Run(body, func(t *testing.T) {143+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {144+ w.Header().Set("x-request-id", "req_decode")145+ writeResponse(t, w, body)146+ })147+ _, err := client.SystemOne(context.Background(), testRequest())148+ responseErr, ok := errors.AsType[*ResponseError](err)149+ if !ok || responseErr.RequestID != "req_decode" {150+ t.Fatalf("expected response error and request ID, got %v", err)151+ }152+ })153+ }154+}155+156+func TestCancellationAndTimeout(t *testing.T) {157+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {158+ <-r.Context().Done()159+ }, WithTimeout(20*time.Millisecond))160+ ctx, cancel := context.WithCancel(context.Background())161+ cancel()162+ if _, err := client.ListModels(ctx); !errors.Is(err, context.Canceled) {163+ t.Fatalf("canceled context: %v", err)164+ }165+ if _, err := client.ListModels(context.Background()); !errors.Is(err, context.DeadlineExceeded) {166+ t.Fatalf("timeout must wrap DeadlineExceeded: %v", err)167+ }168+}169+170+func TestRedirectDoesNotReplayCredentials(t *testing.T) {171+ var reached atomic.Bool172+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {173+ reached.Store(true)174+ }))175+ defer target.Close()176+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {177+ http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)178+ })179+ _, err := client.SystemOne(context.Background(), testRequest())180+ apiErr, ok := errors.AsType[*APIError](err)181+ if reached.Load() || !ok || apiErr.StatusCode != 307 {182+ t.Fatalf("redirect was followed or unexpected error: %v", err)183+ }184+}185+186+func TestClientConcurrentUse(t *testing.T) {187+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {188+ writeResponse(t, w, testResponse)189+ })190+ var group sync.WaitGroup191+ for range 12 {192+ group.Go(func() {193+ if _, err := client.SystemOne(context.Background(), testRequest()); err != nil {194+ t.Error(err)195+ }196+ })197+ }198+ group.Wait()199+}
A doc.go +8 -0
A errors.go +39 -0
A example_test.go +62 -0
@@ -0,0 +1,62 @@
1+package typesafe_test2+3+import (4+ "context"5+ json "encoding/json/v2"6+ "fmt"7+ "net/http"8+ "net/http/httptest"9+10+ "github.com/FelineStateMachine/typesafe-go"11+)12+13+func ExampleClient_SystemOne() {14+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {15+ writer.Header().Set("Content-Type", "application/json")16+ if err := json.MarshalWrite(writer, map[string]any{17+ "model": "jev-latest",18+ "answers": map[string]any{19+ "ready": map[string]any{"type": "noul", "noul": 0.9},20+ "kind": map[string]any{"type": "choice", "choice": "stable", "confidence": 0.8, "probabilities": map[string]float64{"stable": 0.8, "beta": 0.2}},21+ "score": map[string]any{"type": "score", "score": 1.6, "confidence": 0.7, "legend": map[string]any{"0": "not ready", "1": "review", "2": "ready"}, "probabilities": map[string]float64{"0": 0.1, "1": 0.2, "2": 0.7}},22+ },23+ "usage": map[string]int{"input_tokens": 12, "output_tokens": 3},24+ }); err != nil {25+ http.Error(writer, err.Error(), http.StatusInternalServerError)26+ }27+ }))28+ defer server.Close()29+ client, err := typesafe.NewClient(30+ typesafe.WithAPIKey("test-key"),31+ typesafe.WithBaseURL(server.URL),32+ )33+ if err != nil {34+ panic(err)35+ }36+ response, err := client.SystemOne(context.Background(), typesafe.SystemOneRequest{37+ State: map[string]any{"text": "ready"},38+ Questions: map[string]typesafe.Question{39+ "ready": typesafe.NoulQuestion{Instructions: "Is it ready?", Criteria: &typesafe.NoulCriteria{True: "yes", False: "no"}},40+ "kind": typesafe.ChoiceQuestion{Instructions: "Which kind?", Criteria: map[string]any{"stable": "stable", "beta": "beta"}},41+ "score": typesafe.ScoreQuestion{Instructions: "How ready?", Criteria: []any{"not ready", "review", "ready"}},42+ },43+ })44+ if err != nil {45+ panic(err)46+ }47+ ready, err := response.Noul("ready")48+ if err != nil {49+ panic(err)50+ }51+ kind, err := response.Choice("kind")52+ if err != nil {53+ panic(err)54+ }55+ score, err := response.Score("score")56+ if err != nil {57+ panic(err)58+ }59+ fmt.Printf("%.1f %s %.1f\n", ready.Noul, kind.Choice, score.Score)60+ // Output:61+ // 0.9 stable 1.662+}
A examples/classify/main.go +62 -0
@@ -0,0 +1,62 @@
1+package main2+3+import (4+ "context"5+ "fmt"6+ "log"7+8+ "github.com/FelineStateMachine/typesafe-go"9+)10+11+func main() {12+ if err := run(); err != nil {13+ log.Fatal(err)14+ }15+}16+17+func run() error {18+ client, err := typesafe.NewClient()19+ if err != nil {20+ return err21+ }22+ response, err := client.SystemOne(context.Background(), typesafe.SystemOneRequest{23+ State: map[string]any{"text": "A customer asks for a refund."},24+ Questions: map[string]typesafe.Question{25+ "needs_review": typesafe.NoulQuestion{26+ Instructions: "Does this need human review?",27+ Criteria: &typesafe.NoulCriteria{28+ True: "The request has risk or ambiguity.",29+ False: "The request is routine and safe to automate.",30+ },31+ },32+ "intent": typesafe.ChoiceQuestion{33+ Instructions: "Classify the customer's intent.",34+ Criteria: map[string]any{35+ "refund": "The customer wants money returned.",36+ "question": "The customer is asking for information.",37+ },38+ },39+ "priority": typesafe.ScoreQuestion{40+ Instructions: "Score urgency from low to high.",41+ Criteria: []any{"low", "medium", "high"},42+ },43+ },44+ })45+ if err != nil {46+ return err47+ }48+ needsReview, err := response.Noul("needs_review")49+ if err != nil {50+ return err51+ }52+ intent, err := response.Choice("intent")53+ if err != nil {54+ return err55+ }56+ priority, err := response.Score("priority")57+ if err != nil {58+ return err59+ }60+ fmt.Printf("review probability: %.3f\nintent: %s\npriority: %.1f\n", needsReview.Noul, intent.Choice, priority.Score)61+ return nil62+}
A go.mod +3 -0
A integration_test.go +55 -0
@@ -0,0 +1,55 @@
1+package typesafe_test2+3+import (4+ "context"5+ "os"6+ "testing"7+ "time"8+9+ "github.com/FelineStateMachine/typesafe-go"10+)11+12+// TestLiveAPI makes billable requests only when explicitly enabled.13+func TestLiveAPI(t *testing.T) {14+ if os.Getenv("TYPESAFE_LIVE_TEST") != "1" {15+ t.Skip("set TYPESAFE_LIVE_TEST=1 and TYPESAFE_API_KEY to run live API checks")16+ }17+ client, err := typesafe.NewClient()18+ if err != nil {19+ t.Fatal(err)20+ }21+ ctx, cancel := context.WithTimeout(t.Context(), time.Minute)22+ defer cancel()23+ models, err := client.ListModels(ctx)24+ if err != nil || len(models.Models) == 0 {25+ t.Fatalf("list models: response=%v err=%v", models, err)26+ }27+ response, err := client.SystemOne(ctx, liveRequest())28+ if err != nil {29+ t.Fatal(err)30+ }31+ if _, err := response.Answer[typesafe.NoulAnswer]("greeting"); err != nil {32+ t.Fatal(err)33+ }34+ if _, err := response.Choice("tone"); err != nil {35+ t.Fatal(err)36+ }37+ if _, err := response.Score("urgency"); err != nil {38+ t.Fatal(err)39+ }40+}41+42+func liveRequest() typesafe.SystemOneRequest {43+ return typesafe.SystemOneRequest{44+ State: "Hello! Could you send me a receipt whenever you have a moment?",45+ Questions: map[string]typesafe.Question{46+ "greeting": typesafe.NoulQuestion{Instructions: "Does the message contain a greeting?"},47+ "tone": typesafe.ChoiceQuestion{48+ Instructions: "What is the tone?", Criteria: map[string]any{"polite": nil, "hostile": nil},49+ },50+ "urgency": typesafe.ScoreQuestion{51+ Instructions: "How urgent is the request?", Criteria: []any{"not urgent", "urgent"},52+ },53+ },54+ }55+}
A options.go +170 -0
@@ -0,0 +1,170 @@
1+package typesafe2+3+import (4+ "fmt"5+ "net/http"6+ "net/url"7+ "os"8+ "strings"9+ "time"10+)11+12+// DefaultBaseURL is the hosted TypeSafe API root.13+const DefaultBaseURL = "https://api.typesafe.ai"14+15+// DefaultModel is used when neither the request nor the client specifies a model.16+const DefaultModel = "jev-latest"17+18+// Version is the SDK version sent in diagnostic request headers.19+const Version = "0.1.0"20+21+type clientConfig struct {22+ apiKey string23+ baseURL string24+ model string25+ httpClient *http.Client26+ timeout time.Duration27+ retry RetryPolicy28+ headers http.Header29+}30+31+// Option configures a client. Use the With functions to construct options.32+// Options are applied in order, after environment variables and defaults.33+type Option func(*clientConfig) error34+35+func defaultConfig() clientConfig {36+ return clientConfig{37+ apiKey: strings.TrimSpace(os.Getenv("TYPESAFE_API_KEY")),38+ baseURL: environmentOr("TYPESAFE_BASE_URL", DefaultBaseURL),39+ model: environmentOr("TYPESAFE_DEFAULT_MODEL", DefaultModel),40+ httpClient: &http.Client{},41+ timeout: 10 * time.Second,42+ retry: DefaultRetryPolicy(),43+ headers: make(http.Header),44+ }45+}46+47+func environmentOr(key, fallback string) string {48+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {49+ return value50+ }51+ return fallback52+}53+54+// WithAPIKey sets the API key, overriding TYPESAFE_API_KEY.55+func WithAPIKey(key string) Option {56+ return func(config *clientConfig) error {57+ config.apiKey = strings.TrimSpace(key)58+ return nil59+ }60+}61+62+// WithBaseURL sets the API root, overriding TYPESAFE_BASE_URL. A path prefix is63+// supported. The URL must use HTTP or HTTPS and contain no credentials, query,64+// or fragment. Use HTTPS for remote services.65+func WithBaseURL(baseURL string) Option {66+ return func(config *clientConfig) error {67+ config.baseURL = strings.TrimSpace(baseURL)68+ return nil69+ }70+}71+72+// WithModel sets the default model, overriding TYPESAFE_DEFAULT_MODEL.73+// A nonempty SystemOneRequest.Model takes precedence over this setting.74+func WithModel(model string) Option {75+ return func(config *clientConfig) error {76+ config.model = strings.TrimSpace(model)77+ return nil78+ }79+}80+81+// WithHTTPClient sets the HTTP client. The SDK takes a shallow copy and disables82+// redirects; it does not mutate or close the caller's client or transport.83+// A shorter HTTP client timeout also applies to each attempt.84+func WithHTTPClient(client *http.Client) Option {85+ return func(config *clientConfig) error {86+ if client == nil {87+ return fmt.Errorf("typesafe: HTTP client must not be nil")88+ }89+ config.httpClient = client90+ return nil91+ }92+}93+94+// WithTimeout sets the positive timeout for one HTTP attempt, including reading95+// the response body. The default is 10 seconds. Use a context deadline to bound96+// the whole operation, including retries and backoff.97+func WithTimeout(timeout time.Duration) Option {98+ return func(config *clientConfig) error {99+ if timeout <= 0 {100+ return fmt.Errorf("typesafe: timeout must be positive")101+ }102+ config.timeout = timeout103+ return nil104+ }105+}106+107+// WithMaxRetries sets retries after the initial attempt. Zero disables retries.108+// It changes only MaxRetries in the current retry policy.109+func WithMaxRetries(maxRetries int) Option {110+ return func(config *clientConfig) error {111+ if maxRetries < 0 {112+ return fmt.Errorf("typesafe: max retries must not be negative")113+ }114+ config.retry.MaxRetries = maxRetries115+ return nil116+ }117+}118+119+// WithRetryPolicy replaces the complete retry policy. Start from120+// DefaultRetryPolicy to change selected settings while retaining the defaults.121+func WithRetryPolicy(policy RetryPolicy) Option {122+ return func(config *clientConfig) error {123+ if err := policy.validate(); err != nil {124+ return err125+ }126+ config.retry = policy127+ return nil128+ }129+}130+131+// WithHeaders copies additional headers. Authentication, JSON content headers,132+// SDK identity headers, and retry count are always controlled by the SDK.133+func WithHeaders(headers http.Header) Option {134+ cloned := canonicalHeaders(headers)135+ return func(config *clientConfig) error {136+ config.headers = cloned.Clone()137+ return nil138+ }139+}140+141+func canonicalHeaders(headers http.Header) http.Header {142+ result := make(http.Header, len(headers))143+ for key, values := range headers {144+ for _, value := range values {145+ result.Add(key, value)146+ }147+ }148+ return result149+}150+151+func (config *clientConfig) validate() error {152+ if config.apiKey == "" {153+ return fmt.Errorf("typesafe: API key required; use WithAPIKey or TYPESAFE_API_KEY")154+ }155+ if strings.ContainsAny(config.apiKey, "\r\n") {156+ return fmt.Errorf("typesafe: API key must not contain line breaks")157+ }158+ if config.model == "" {159+ return fmt.Errorf("typesafe: default model must not be empty")160+ }161+ parsed, err := url.Parse(config.baseURL)162+ if err != nil {163+ return fmt.Errorf("typesafe: invalid base URL")164+ }165+ if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {166+ return fmt.Errorf("typesafe: base URL must be an HTTP(S) URL without credentials, query, or fragment")167+ }168+ config.baseURL = strings.TrimRight(config.baseURL, "/")169+ return config.retry.validate()170+}
A options_test.go +73 -0
@@ -0,0 +1,73 @@
1+package typesafe2+3+import (4+ "context"5+ "net/http"6+ "testing"7+ "time"8+)9+10+func clearEnvironment(t *testing.T) {11+ t.Helper()12+ for _, key := range []string{"TYPESAFE_API_KEY", "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL"} {13+ t.Setenv(key, "")14+ }15+}16+17+func TestClientOptionsValidate(t *testing.T) {18+ clearEnvironment(t)19+ if _, err := NewClient(); err == nil {20+ t.Fatal("missing API key must fail")21+ }22+ tests := map[string]Option{23+ "empty key": WithAPIKey(" "), "negative timeout": WithTimeout(-time.Second),24+ "zero timeout": WithTimeout(0), "negative retries": WithMaxRetries(-1),25+ "nil transport": WithHTTPClient(nil), "empty model": WithModel(" "),26+ "invalid URL": WithBaseURL(":bad"), "URL credentials": WithBaseURL("https://a:b@example.com"),27+ "URL query": WithBaseURL("https://example.com?x=y"), "URL fragment": WithBaseURL("https://example.com/#x"),28+ "URL scheme": WithBaseURL("file:///tmp/x"), "nil option": nil,29+ "bad policy": WithRetryPolicy(RetryPolicy{Jitter: 2}),30+ }31+ for name, option := range tests {32+ t.Run(name, func(t *testing.T) {33+ if _, err := NewClient(WithAPIKey("key"), option); err == nil {34+ t.Fatal("invalid configuration accepted")35+ }36+ })37+ }38+}39+40+func TestEnvironmentAndExplicitPrecedence(t *testing.T) {41+ clearEnvironment(t)42+ t.Setenv("TYPESAFE_API_KEY", " env-key ")43+ t.Setenv("TYPESAFE_DEFAULT_MODEL", " env-model ")44+ t.Setenv("TYPESAFE_BASE_URL", "https://env.example/")45+ client, err := NewClient()46+ if err != nil {47+ t.Fatal(err)48+ }49+ if client.config.apiKey != "env-key" || client.config.model != "env-model" || client.config.baseURL != "https://env.example" {50+ t.Fatal("environment configuration not resolved")51+ }52+ client, err = NewClient(WithAPIKey("explicit"), WithModel("explicit"), WithBaseURL("https://explicit.example"))53+ if err != nil || client.config.apiKey != "explicit" || client.config.model != "explicit" || client.config.baseURL != "https://explicit.example" {54+ t.Fatal("explicit configuration did not take precedence")55+ }56+}57+58+func TestHeadersAreCopiedAndProtected(t *testing.T) {59+ headers := http.Header{"X-Custom": {"original"}, "authorization": {"injected"}, "X-Typesafe-Retry-Count": {"999"}}60+ client := testClient(t, func(w http.ResponseWriter, r *http.Request) {61+ if r.Header.Get("X-Custom") != "original" || r.Header.Get("Authorization") != "Bearer test-key" {62+ t.Error("headers were mutated or authentication overridden")63+ }64+ if r.Header.Get("X-TypeSafe-Retry-Count") != "" {65+ t.Error("retry count overridden")66+ }67+ writeResponse(t, w, `{"models":[]}`)68+ }, WithHeaders(headers))69+ headers.Set("X-Custom", "mutated")70+ if _, err := client.ListModels(context.Background()); err != nil {71+ t.Fatal(err)72+ }73+}
A questions.go +200 -0
@@ -0,0 +1,200 @@
1+package typesafe2+3+import (4+ "bytes"5+ "encoding/json/jsontext"6+ jsonv2 "encoding/json/v2"7+ "fmt"8+)9+10+// Question is a typed System One question.11+//12+// The concrete question types are NoulQuestion, ChoiceQuestion, and13+// ScoreQuestion. Question values always include their wire type discriminator.14+type Question interface {15+ questionType() string16+ jsonv2.Marshaler17+}18+19+// NoulCriteria describes the true and false outcomes of a Noul question.20+type NoulCriteria struct {21+ True any `json:"true"`22+ False any `json:"false"`23+}24+25+// NoulQuestion asks for the probability of a yes answer.26+type NoulQuestion struct {27+ Instructions any `json:"instructions"`28+ Criteria *NoulCriteria `json:"criteria"`29+}30+31+func (NoulQuestion) questionType() string { return "noul" }32+33+// ChoiceQuestion selects one label from Criteria.34+type ChoiceQuestion struct {35+ Instructions any `json:"instructions"`36+ Criteria map[string]any `json:"criteria"`37+}38+39+func (ChoiceQuestion) questionType() string { return "choice" }40+41+// ScoreQuestion estimates a score using ordered rubric levels in Criteria.42+type ScoreQuestion struct {43+ Instructions any `json:"instructions"`44+ Criteria []any `json:"criteria"`45+}46+47+func (ScoreQuestion) questionType() string { return "score" }48+49+func marshalQuestion(kind string, value any) ([]byte, error) {50+ payload, err := jsonv2.Marshal(value)51+ if err != nil {52+ return nil, err53+ }54+ var object map[string]jsontext.Value55+ if err := jsonv2.Unmarshal(payload, &object); err != nil {56+ return nil, fmt.Errorf("marshal %s question: %w", kind, err)57+ }58+ object["type"] = jsontext.Value(fmt.Sprintf("%q", kind))59+ return jsonv2.Marshal(object)60+}61+62+// MarshalJSON encodes a NoulQuestion with its discriminator.63+func (q NoulQuestion) MarshalJSON() ([]byte, error) {64+ return marshalQuestion(q.questionType(), struct {65+ Instructions any `json:"instructions"`66+ Criteria *NoulCriteria `json:"criteria"`67+ }{q.Instructions, q.Criteria})68+}69+70+// MarshalJSON encodes a ChoiceQuestion with its discriminator.71+func (q ChoiceQuestion) MarshalJSON() ([]byte, error) {72+ return marshalQuestion(q.questionType(), struct {73+ Instructions any `json:"instructions"`74+ Criteria map[string]any `json:"criteria"`75+ }{q.Instructions, q.Criteria})76+}77+78+// MarshalJSON encodes a ScoreQuestion with its discriminator.79+func (q ScoreQuestion) MarshalJSON() ([]byte, error) {80+ return marshalQuestion(q.questionType(), struct {81+ Instructions any `json:"instructions"`82+ Criteria []any `json:"criteria"`83+ }{q.Instructions, q.Criteria})84+}85+86+// UnmarshalQuestion decodes a discriminated question.87+func UnmarshalQuestion(data []byte) (Question, error) {88+ var envelope struct {89+ Type string `json:"type"`90+ }91+ if err := jsonv2.Unmarshal(data, &envelope); err != nil {92+ return nil, fmt.Errorf("decode question discriminator: %w", err)93+ }94+ switch envelope.Type {95+ case "noul":96+ var value NoulQuestion97+ if err := jsonv2.Unmarshal(data, &value); err != nil {98+ return nil, err99+ }100+ return value, nil101+ case "choice":102+ var value ChoiceQuestion103+ if err := jsonv2.Unmarshal(data, &value); err != nil {104+ return nil, err105+ }106+ return value, nil107+ case "score":108+ var value ScoreQuestion109+ if err := jsonv2.Unmarshal(data, &value); err != nil {110+ return nil, err111+ }112+ return value, nil113+ default:114+ return nil, fmt.Errorf("unknown question type %q", envelope.Type)115+ }116+}117+118+func validateQuestion(name string, question Question) error {119+ if question == nil {120+ return fmt.Errorf("question %q is nil", name)121+ }122+ switch value := question.(type) {123+ case *NoulQuestion:124+ if value == nil {125+ return fmt.Errorf("question %q is typed nil", name)126+ }127+ return validateQuestion(name, *value)128+ case *ChoiceQuestion:129+ if value == nil {130+ return fmt.Errorf("question %q is typed nil", name)131+ }132+ return validateQuestion(name, *value)133+ case *ScoreQuestion:134+ if value == nil {135+ return fmt.Errorf("question %q is typed nil", name)136+ }137+ return validateQuestion(name, *value)138+ }139+ return validateQuestionValue(name, question)140+}141+142+func validateQuestionValue(name string, question Question) error {143+ switch value := question.(type) {144+ case NoulQuestion:145+ if err := validateTopLevelValue(value.Instructions); err != nil {146+ return fmt.Errorf("question %q instructions: %w", name, err)147+ }148+ if value.Criteria != nil {149+ if err := validateTopLevelValue(value.Criteria.True); err != nil {150+ return fmt.Errorf("question %q criteria.true: %w", name, err)151+ }152+ if err := validateTopLevelValue(value.Criteria.False); err != nil {153+ return fmt.Errorf("question %q criteria.false: %w", name, err)154+ }155+ }156+ case ChoiceQuestion:157+ if len(value.Criteria) < 2 {158+ return fmt.Errorf("question %q choice criteria must contain at least two labels", name)159+ }160+ if err := validateTopLevelValue(value.Instructions); err != nil {161+ return fmt.Errorf("question %q instructions: %w", name, err)162+ }163+ for label, criterion := range value.Criteria {164+ if err := validateTopLevelValue(criterion); err != nil {165+ return fmt.Errorf("question %q criteria[%q]: %w", name, label, err)166+ }167+ }168+ case ScoreQuestion:169+ if len(value.Criteria) < 2 {170+ return fmt.Errorf("question %q score criteria must contain at least two levels", name)171+ }172+ if err := validateTopLevelValue(value.Instructions); err != nil {173+ return fmt.Errorf("question %q instructions: %w", name, err)174+ }175+ for index, criterion := range value.Criteria {176+ if err := validateTopLevelValue(criterion); err != nil {177+ return fmt.Errorf("question %q criteria[%d]: %w", name, index, err)178+ }179+ }180+ default:181+ return fmt.Errorf("question %q has unsupported type %T", name, question)182+ }183+ return nil184+}185+186+func rawObject(data []byte) (map[string]jsontext.Value, error) {187+ var object map[string]jsontext.Value188+ if err := jsonv2.Unmarshal(data, &object); err != nil {189+ return nil, err190+ }191+ if object == nil {192+ return nil, fmt.Errorf("expected JSON object")193+ }194+ return object, nil195+}196+197+func hasRaw(object map[string]jsontext.Value, key string) bool {198+ value, ok := object[key]199+ return ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null"))200+}
A questions_test.go +117 -0
@@ -0,0 +1,117 @@
1+package typesafe2+3+import (4+ jsonv2 "encoding/json/v2"5+ "reflect"6+ "strings"7+ "testing"8+)9+10+func TestQuestionMarshal(t *testing.T) {11+ tests := []struct {12+ name string13+ q Question14+ want string15+ }{16+ {"noul", NoulQuestion{Instructions: "Is it urgent?", Criteria: &NoulCriteria{True: "urgent", False: nil}}, `{"type":"noul","instructions":"Is it urgent?","criteria":{"true":"urgent","false":null}}`},17+ {"choice", ChoiceQuestion{Instructions: "Pick one", Criteria: map[string]any{"a": nil, "b": "B"}}, `{"type":"choice","instructions":"Pick one","criteria":{"a":null,"b":"B"}}`},18+ {"score", ScoreQuestion{Instructions: "Rate it", Criteria: []any{"low", "high"}}, `{"type":"score","instructions":"Rate it","criteria":["low","high"]}`},19+ }20+ for _, tt := range tests {21+ t.Run(tt.name, func(t *testing.T) {22+ got, err := jsonv2.Marshal(tt.q)23+ if err != nil {24+ t.Fatal(err)25+ }26+ var gotValue, wantValue any27+ if err := jsonv2.Unmarshal(got, &gotValue); err != nil {28+ t.Fatal(err)29+ }30+ if err := jsonv2.Unmarshal([]byte(tt.want), &wantValue); err != nil {31+ t.Fatal(err)32+ }33+ if !reflect.DeepEqual(gotValue, wantValue) {34+ t.Fatalf("got %s, want %s", got, tt.want)35+ }36+ })37+ }38+}39+40+func TestQuestionUnmarshal(t *testing.T) {41+ q, err := UnmarshalQuestion([]byte(`{"type":"choice","instructions":"Pick","criteria":{"yes":null,"no":null}}`))42+ if err != nil {43+ t.Fatal(err)44+ }45+ if _, ok := q.(ChoiceQuestion); !ok {46+ t.Fatalf("got %T", q)47+ }48+ if _, err := UnmarshalQuestion([]byte(`{"type":"future","instructions":"x"}`)); err == nil || !strings.Contains(err.Error(), "unknown question type") {49+ t.Fatalf("got %v", err)50+ }51+}52+53+func TestRequestValidate(t *testing.T) {54+ valid := SystemOneRequest{State: map[string]any{"x": "y"}, Questions: map[string]Question{"q": NoulQuestion{Instructions: "x"}}}55+ if err := valid.Validate(); err != nil {56+ t.Fatal(err)57+ }58+ cases := []SystemOneRequest{59+ {State: 1, Questions: map[string]Question{"q": NoulQuestion{Instructions: "x"}}},60+ {State: nil, Questions: map[string]Question{}},61+ {State: nil, Questions: map[string]Question{"q": ChoiceQuestion{Criteria: map[string]any{"only": nil}}}},62+ {State: nil, Questions: map[string]Question{"q": ScoreQuestion{Criteria: []any{"only"}}}},63+ }64+ for i, request := range cases {65+ if err := request.Validate(); err == nil {66+ t.Fatalf("case %d: expected error", i)67+ }68+ }69+}70+71+type requestState struct {72+ Name string `json:"name"`73+}74+75+type scalarState struct{}76+77+func (scalarState) MarshalJSON() ([]byte, error) { return []byte("true"), nil }78+79+func TestRequestValidateStructuredState(t *testing.T) {80+ for _, state := range []any{requestState{Name: "ok"}, &requestState{Name: "ok"}, []string{"ok"}} {81+ request := SystemOneRequest{State: state, Questions: map[string]Question{"q": NoulQuestion{Instructions: "x"}}}82+ if err := request.Validate(); err != nil {83+ t.Fatalf("state %T: %v", state, err)84+ }85+ }86+ request := SystemOneRequest{State: scalarState{}, Questions: map[string]Question{"q": NoulQuestion{Instructions: "x"}}}87+ if err := request.Validate(); err == nil {88+ t.Fatal("custom scalar state should be rejected")89+ }90+}91+92+func TestQuestionRejectsDuplicateAndInvalidUTF8(t *testing.T) {93+ for _, data := range [][]byte{94+ []byte(`{"type":"noul","type":"choice"}`),95+ []byte{'{', '"', 't', 'y', 'p', 'e', '"', ':', '"', 0xff, '"', '}'},96+ } {97+ if _, err := UnmarshalQuestion(data); err == nil {98+ t.Fatalf("expected malformed JSON error for %q", data)99+ }100+ }101+}102+103+func TestRequestValidateQuestionPointers(t *testing.T) {104+ noul := &NoulQuestion{Instructions: "x"}105+ choice := &ChoiceQuestion{Instructions: "x", Criteria: map[string]any{"a": nil, "b": nil}}106+ score := &ScoreQuestion{Instructions: "x", Criteria: []any{"a", "b"}}107+ for name, question := range map[string]Question{"noul": noul, "choice": choice, "score": score} {108+ request := SystemOneRequest{Questions: map[string]Question{name: question}}109+ if err := request.Validate(); err != nil {110+ t.Fatalf("%s: %v", name, err)111+ }112+ }113+ var nilNoul *NoulQuestion114+ if err := (SystemOneRequest{Questions: map[string]Question{"q": nilNoul}}).Validate(); err == nil {115+ t.Fatal("typed nil question should be rejected")116+ }117+}
A responses.go +351 -0
@@ -0,0 +1,351 @@
1+package typesafe2+3+import (4+ "bytes"5+ "encoding/json/jsontext"6+ jsonv2 "encoding/json/v2"7+ "errors"8+ "fmt"9+ "math"10+)11+12+// ErrAnswerNotFound indicates that a named answer is absent.13+var ErrAnswerNotFound = errors.New("answer not found")14+15+// ErrAnswerType indicates that an answer exists but has another type.16+var ErrAnswerType = errors.New("answer has unexpected type")17+18+// Answer is a discriminated answer returned by System One.19+type Answer interface {20+ answerType() string21+ jsonv2.Marshaler22+}23+24+// NoulAnswer contains the probability of a yes answer.25+type NoulAnswer struct {26+ Noul float64 `json:"noul"`27+}28+29+func (NoulAnswer) answerType() string { return "noul" }30+31+// ChoiceAnswer contains the selected label, confidence, and label probabilities.32+type ChoiceAnswer struct {33+ Choice string `json:"choice"`34+ Confidence float64 `json:"confidence"`35+ Probabilities map[string]float64 `json:"probabilities"`36+}37+38+func (ChoiceAnswer) answerType() string { return "choice" }39+40+// ScoreAnswer contains an expected score, rubric legend, confidence, and probabilities.41+type ScoreAnswer struct {42+ Score float64 `json:"score"`43+ Confidence float64 `json:"confidence"`44+ Legend map[string]any `json:"legend"`45+ Probabilities map[string]float64 `json:"probabilities"`46+}47+48+func (ScoreAnswer) answerType() string { return "score" }49+50+// UnknownAnswer preserves an answer type this SDK does not know yet.51+type UnknownAnswer struct {52+ Type string53+ Raw jsontext.Value54+}55+56+func (a UnknownAnswer) answerType() string { return a.Type }57+func (a UnknownAnswer) MarshalJSON() ([]byte, error) { return append([]byte(nil), a.Raw...), nil }58+59+func marshalAnswer(kind string, value any) ([]byte, error) {60+ payload, err := jsonv2.Marshal(value)61+ if err != nil {62+ return nil, err63+ }64+ object, err := rawObject(payload)65+ if err != nil {66+ return nil, err67+ }68+ object["type"] = jsontext.Value(fmt.Sprintf("%q", kind))69+ return jsonv2.Marshal(object)70+}71+72+// MarshalJSON encodes a NoulAnswer with its discriminator.73+func (a NoulAnswer) MarshalJSON() ([]byte, error) {74+ return marshalAnswer(a.answerType(), struct {75+ Noul float64 `json:"noul"`76+ }{a.Noul})77+}78+79+// MarshalJSON encodes a ChoiceAnswer with its discriminator.80+func (a ChoiceAnswer) MarshalJSON() ([]byte, error) {81+ return marshalAnswer(a.answerType(), struct {82+ Choice string `json:"choice"`83+ Confidence float64 `json:"confidence"`84+ Probabilities map[string]float64 `json:"probabilities"`85+ }{a.Choice, a.Confidence, a.Probabilities})86+}87+88+// MarshalJSON encodes a ScoreAnswer with its discriminator.89+func (a ScoreAnswer) MarshalJSON() ([]byte, error) {90+ return marshalAnswer(a.answerType(), struct {91+ Score float64 `json:"score"`92+ Confidence float64 `json:"confidence"`93+ Legend map[string]any `json:"legend"`94+ Probabilities map[string]float64 `json:"probabilities"`95+ }{a.Score, a.Confidence, a.Legend, a.Probabilities})96+}97+98+// Usage reports token counts for a System One request.99+type Usage struct {100+ InputTokens int `json:"input_tokens"`101+ OutputTokens int `json:"output_tokens"`102+}103+104+// SystemOneResponse contains all named answers and request usage metadata.105+type SystemOneResponse struct {106+ Model string `json:"model"`107+ Answers map[string]Answer `json:"answers"`108+ Usage Usage `json:"usage"`109+ RequestID string `json:"-"`110+}111+112+// UnmarshalAnswer decodes a discriminated answer and preserves unknown types.113+func UnmarshalAnswer(data []byte) (Answer, error) {114+ object, err := rawObject(data)115+ if err != nil {116+ return nil, err117+ }118+ tag, ok := object["type"]119+ if !ok {120+ return nil, fmt.Errorf("answer type is required")121+ }122+ var kind string123+ if err := jsonv2.Unmarshal(tag, &kind); err != nil || kind == "" {124+ return nil, fmt.Errorf("answer type must be a non-empty string")125+ }126+ switch kind {127+ case "noul":128+ var value NoulAnswer129+ if err := decodeKnownAnswer(data, object, &value, kind); err != nil {130+ return nil, err131+ }132+ return value, nil133+ case "choice":134+ var value ChoiceAnswer135+ if err := decodeKnownAnswer(data, object, &value, kind); err != nil {136+ return nil, err137+ }138+ return value, nil139+ case "score":140+ var value ScoreAnswer141+ if err := decodeKnownAnswer(data, object, &value, kind); err != nil {142+ return nil, err143+ }144+ return value, nil145+ default:146+ return UnknownAnswer{Type: kind, Raw: append(jsontext.Value(nil), data...)}, nil147+ }148+}149+150+func decodeKnownAnswer(data []byte, object map[string]jsontext.Value, target any, kind string) error {151+ if err := jsonv2.Unmarshal(data, target); err != nil {152+ return fmt.Errorf("decode %s answer: %w", kind, err)153+ }154+ switch value := target.(type) {155+ case *NoulAnswer:156+ if !hasRaw(object, "noul") || !finiteProbability(value.Noul) {157+ return fmt.Errorf("invalid noul answer")158+ }159+ case *ChoiceAnswer:160+ if !validChoiceAnswer(*value, object) {161+ return fmt.Errorf("invalid choice answer")162+ }163+ case *ScoreAnswer:164+ if !validScoreAnswer(*value, object) {165+ return fmt.Errorf("invalid score answer")166+ }167+ }168+ return nil169+}170+171+func validChoiceAnswer(value ChoiceAnswer, object map[string]jsontext.Value) bool {172+ return hasRaw(object, "choice") && hasRaw(object, "confidence") &&173+ finiteProbability(value.Confidence) && value.Choice != "" &&174+ mapHasKey(value.Probabilities, value.Choice) &&175+ validProbabilities(value.Probabilities, object, "probabilities")176+}177+178+func validScoreAnswer(value ScoreAnswer, object map[string]jsontext.Value) bool {179+ return hasRaw(object, "score") && hasRaw(object, "confidence") &&180+ hasRaw(object, "legend") && finiteProbability(value.Confidence) &&181+ sameKeys(value.Legend, value.Probabilities) &&182+ validProbabilities(value.Probabilities, object, "probabilities")183+}184+185+func finiteProbability(value float64) bool {186+ return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 && value <= 1187+}188+189+func validProbabilities(values map[string]float64, object map[string]jsontext.Value, key string) bool {190+ raw, ok := object[key]191+ if !ok || !hasRaw(object, key) || len(values) == 0 {192+ return false193+ }194+ var rawValues map[string]jsontext.Value195+ if err := jsonv2.Unmarshal(raw, &rawValues); err != nil || len(rawValues) != len(values) {196+ return false197+ }198+ for _, rawValue := range rawValues {199+ if bytes.Equal(bytes.TrimSpace(rawValue), []byte("null")) {200+ return false201+ }202+ }203+ for _, value := range values {204+ if !finiteProbability(value) {205+ return false206+ }207+ }208+ return true209+}210+211+func mapHasKey(values map[string]float64, key string) bool { _, ok := values[key]; return ok }212+213+func sameKeys(legend map[string]any, probabilities map[string]float64) bool {214+ if len(legend) != len(probabilities) {215+ return false216+ }217+ for key := range legend {218+ if _, ok := probabilities[key]; !ok {219+ return false220+ }221+ }222+ return true223+}224+225+// UnmarshalJSON decodes and validates a SystemOneResponse.226+func (r *SystemOneResponse) UnmarshalJSON(data []byte) error {227+ object, err := rawObject(data)228+ if err != nil {229+ return err230+ }231+ if !hasRaw(object, "model") || !hasRaw(object, "answers") || !hasRaw(object, "usage") {232+ return fmt.Errorf("response requires model, answers, and usage")233+ }234+ var usageObject map[string]jsontext.Value235+ if err := jsonv2.Unmarshal(object["usage"], &usageObject); err != nil || !hasRaw(usageObject, "input_tokens") || !hasRaw(usageObject, "output_tokens") {236+ return fmt.Errorf("usage requires input_tokens and output_tokens")237+ }238+ var envelope struct {239+ Model string `json:"model"`240+ Answers map[string]jsontext.Value `json:"answers"`241+ Usage *Usage `json:"usage"`242+ }243+ if err := jsonv2.Unmarshal(data, &envelope); err != nil {244+ return err245+ }246+ if envelope.Model == "" || envelope.Answers == nil || envelope.Usage == nil {247+ return fmt.Errorf("response has invalid required fields")248+ }249+ answers := make(map[string]Answer, len(envelope.Answers))250+ for name, raw := range envelope.Answers {251+ answer, err := UnmarshalAnswer(raw)252+ if err != nil {253+ return fmt.Errorf("answer %q: %w", name, err)254+ }255+ answers[name] = answer256+ }257+ *r = SystemOneResponse{Model: envelope.Model, Answers: answers, Usage: *envelope.Usage}258+ return nil259+}260+261+// Noul returns the named Noul answer.262+func (r SystemOneResponse) Noul(name string) (NoulAnswer, error) {263+ value, err := answerAs[NoulAnswer](r.Answers, name)264+ return value, err265+}266+267+// Choice returns the named Choice answer.268+func (r SystemOneResponse) Choice(name string) (ChoiceAnswer, error) {269+ value, err := answerAs[ChoiceAnswer](r.Answers, name)270+ return value, err271+}272+273+// Score returns the named Score answer.274+func (r SystemOneResponse) Score(name string) (ScoreAnswer, error) {275+ value, err := answerAs[ScoreAnswer](r.Answers, name)276+ return value, err277+}278+279+// Answer returns a named answer as the requested concrete answer type.280+func (r SystemOneResponse) Answer[T Answer](name string) (T, error) {281+ return answerAs[T](r.Answers, name)282+}283+284+func answerAs[T Answer](answers map[string]Answer, name string) (T, error) {285+ var zero T286+ answer, ok := answers[name]287+ if !ok {288+ return zero, fmt.Errorf("%w: %s", ErrAnswerNotFound, name)289+ }290+ value, ok := answer.(T)291+ if !ok {292+ return zero, fmt.Errorf("%w: %s", ErrAnswerType, name)293+ }294+ return value, nil295+}296+297+// ModelCard describes an available model.298+type ModelCard struct {299+ Name string `json:"name"`300+ Description string `json:"description"`301+ ReleaseDate string `json:"release_date"`302+}303+304+// ListModelsResponse contains the models available to the account.305+type ListModelsResponse struct {306+ Models []ModelCard `json:"models"`307+ RequestID string `json:"-"`308+}309+310+// UnmarshalJSON decodes and validates a model list response.311+func (r *ListModelsResponse) UnmarshalJSON(data []byte) error {312+ object, err := rawObject(data)313+ if err != nil {314+ return err315+ }316+ if !hasRaw(object, "models") {317+ return fmt.Errorf("response requires models")318+ }319+ var rawModels []jsontext.Value320+ if err := jsonv2.Unmarshal(object["models"], &rawModels); err != nil {321+ return err322+ }323+ if rawModels == nil {324+ return fmt.Errorf("models must be an array")325+ }326+ models := make([]ModelCard, len(rawModels))327+ for index, rawModel := range rawModels {328+ modelObject, err := rawObject(rawModel)329+ if err != nil {330+ return fmt.Errorf("models[%d]: %w", index, err)331+ }332+ if !hasRaw(modelObject, "name") || !hasRaw(modelObject, "description") || !hasRaw(modelObject, "release_date") {333+ return fmt.Errorf("models[%d]: missing required fields", index)334+ }335+ if err := jsonv2.Unmarshal(rawModel, &models[index]); err != nil {336+ return err337+ }338+ if models[index].Name == "" {339+ return fmt.Errorf("models[%d]: name must not be empty", index)340+ }341+ }342+ *r = ListModelsResponse{Models: models}343+ return nil344+}345+346+var _ jsonv2.Marshaler = NoulAnswer{}347+var _ jsonv2.Marshaler = ChoiceAnswer{}348+var _ jsonv2.Marshaler = ScoreAnswer{}349+var _ jsonv2.Marshaler = NoulQuestion{}350+var _ jsonv2.Marshaler = ChoiceQuestion{}351+var _ jsonv2.Marshaler = ScoreQuestion{}
A responses_test.go +99 -0
@@ -0,0 +1,99 @@
1+package typesafe2+3+import (4+ jsonv2 "encoding/json/v2"5+ "errors"6+ "testing"7+)8+9+func TestResponseUnmarshalAndAccessors(t *testing.T) {10+ input := []byte(`{"model":"jev-latest","answers":{"a":{"type":"noul","noul":0},"b":{"type":"choice","choice":"yes","confidence":0.5,"probabilities":{"yes":1}},"c":{"type":"score","score":0,"confidence":0,"legend":{"0":"none","1":"yes"},"probabilities":{"0":1,"1":0}},"future":{"type":"future","value":0}},"usage":{"input_tokens":0,"output_tokens":2}}`)11+ var response SystemOneResponse12+ if err := jsonv2.Unmarshal(input, &response); err != nil {13+ t.Fatal(err)14+ }15+ if got, err := response.Noul("a"); err != nil || got.Noul != 0 {16+ t.Fatalf("noul: %#v %v", got, err)17+ }18+ if got, err := response.Choice("b"); err != nil || got.Choice != "yes" {19+ t.Fatalf("choice: %#v %v", got, err)20+ }21+ if got, err := response.Score("c"); err != nil || got.Score != 0 {22+ t.Fatalf("score: %#v %v", got, err)23+ }24+ var roundtrip map[string]any25+ encoded, err := jsonv2.Marshal(response)26+ if err != nil {27+ t.Fatal(err)28+ }29+ if err := jsonv2.Unmarshal(encoded, &roundtrip); err != nil {30+ t.Fatal(err)31+ }32+ if _, ok := roundtrip["answers"].(map[string]any)["future"]; !ok {33+ t.Fatal("unknown answer was not preserved")34+ }35+}36+37+func TestResponseRejectsMissingAndInvalidFields(t *testing.T) {38+ cases := []string{39+ `{"answers":{},"usage":{"input_tokens":0,"output_tokens":0}}`,40+ `{"model":"x","answers":{},"usage":{"input_tokens":0}}`,41+ `{"model":"x","answers":{"a":{"type":"noul"}},"usage":{"input_tokens":0,"output_tokens":0}}`,42+ `{"model":"x","answers":{"a":{"type":"noul","noul":1.1}},"usage":{"input_tokens":0,"output_tokens":0}}`,43+ }44+ for _, input := range cases {45+ var response SystemOneResponse46+ if err := jsonv2.Unmarshal([]byte(input), &response); err == nil {47+ t.Fatalf("expected error for %s", input)48+ }49+ }50+}51+52+func TestResponseRejectsDuplicateAndInvalidUTF8(t *testing.T) {53+ duplicate := []byte(`{"model":"x","model":"y","answers":{},"usage":{"input_tokens":0,"output_tokens":0}}`)54+ if err := jsonv2.Unmarshal(duplicate, new(SystemOneResponse)); err == nil {55+ t.Fatal("duplicate field should be rejected")56+ }57+ invalidUTF8 := []byte{'{', '"', 'm', 'o', 'd', 'e', 'l', '"', ':', '"', 0xff, '"', ',', '"', 'a', 'n', 's', 'w', 'e', 'r', 's', '"', ':', '{', '}', ',', '"', 'u', 's', 'a', 'g', 'e', '"', ':', '{', '"', 'i', 'n', 'p', 'u', 't', '_', 't', 'o', 'k', 'e', 'n', 's', '"', ':', '0', ',', '"', 'o', 'u', 't', 'p', 'u', 't', '_', 't', 'o', 'k', 'e', 'n', 's', '"', ':', '0', '}', '}'}58+ if err := jsonv2.Unmarshal(invalidUTF8, new(SystemOneResponse)); err == nil {59+ t.Fatal("invalid UTF-8 should be rejected")60+ }61+}62+63+func TestResponseAccessorErrors(t *testing.T) {64+ response := SystemOneResponse{Answers: map[string]Answer{"a": ChoiceAnswer{Choice: "x"}}}65+ if _, err := response.Noul("missing"); !errors.Is(err, ErrAnswerNotFound) {66+ t.Fatalf("missing: %v", err)67+ }68+ if _, err := response.Noul("a"); !errors.Is(err, ErrAnswerType) {69+ t.Fatalf("type: %v", err)70+ }71+}72+73+func TestGenericAnswerAccessor(t *testing.T) {74+ response := SystemOneResponse{Answers: map[string]Answer{"a": NoulAnswer{Noul: 0}}}75+ got, err := response.Answer[NoulAnswer]("a")76+ if err != nil || got.Noul != 0 {77+ t.Fatalf("got %#v, %v", got, err)78+ }79+ if _, err := response.Answer[ChoiceAnswer]("a"); !errors.Is(err, ErrAnswerType) {80+ t.Fatalf("wrong type: %v", err)81+ }82+}83+84+func TestResponseRejectsInvalidProbabilityMaps(t *testing.T) {85+ for _, probabilities := range []string{`{}`, `{"yes":null}`} {86+ input := `{"model":"x","answers":{"a":{"type":"choice","choice":"yes","confidence":0,"probabilities":` + probabilities + `}},"usage":{"input_tokens":0,"output_tokens":0}}`87+ if err := jsonv2.Unmarshal([]byte(input), new(SystemOneResponse)); err == nil {88+ t.Fatalf("expected invalid probabilities error for %s", probabilities)89+ }90+ }91+}92+93+func TestModelListRejectsMalformedCards(t *testing.T) {94+ for _, input := range []string{`{"models":[{}]}`, `{"models":[null]}`, `{"models":[{"name":"","description":"x","release_date":"today"}]}`} {95+ if err := jsonv2.Unmarshal([]byte(input), new(ListModelsResponse)); err == nil {96+ t.Fatalf("expected malformed model error for %s", input)97+ }98+ }99+}
A retry.go +124 -0
@@ -0,0 +1,124 @@
1+package typesafe2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "math"8+ "math/rand/v2"9+ "net/http"10+ "strconv"11+ "time"12+)13+14+// RetryPolicy controls retries after transient failures. All delays must be15+// nonnegative, MaxDelay must be at least InitialDelay, and Jitter is in [0, 1].16+// Retryable HTTP statuses are 408, 429, and 500–599. A caller's cancellation or17+// deadline always stops the operation, regardless of this policy.18+type RetryPolicy struct {19+ MaxRetries int20+ InitialDelay time.Duration21+ MaxDelay time.Duration22+ MaxRetryAfter time.Duration23+ Jitter float6424+ RetryConnectionErrors bool25+ RetryTimeouts bool26+}27+28+// DefaultRetryPolicy returns an independent copy of the default settings:29+// two retries, 500ms initial backoff, 5s maximum backoff, up to 25% downward30+// jitter, and server-requested delays up to 60s. Network failures and attempt31+// timeouts are retried. No total time budget is imposed; use a context deadline.32+func DefaultRetryPolicy() RetryPolicy {33+ return RetryPolicy{34+ MaxRetries: 2, InitialDelay: 500 * time.Millisecond,35+ MaxDelay: 5 * time.Second, MaxRetryAfter: 60 * time.Second,36+ Jitter: 0.25, RetryConnectionErrors: true, RetryTimeouts: true,37+ }38+}39+40+func (p RetryPolicy) validate() error {41+ if p.MaxRetries < 0 || p.InitialDelay < 0 || p.MaxDelay < p.InitialDelay || p.MaxRetryAfter < 0 {42+ return fmt.Errorf("typesafe: invalid retry count or delays")43+ }44+ if math.IsNaN(p.Jitter) || p.Jitter < 0 || p.Jitter > 1 {45+ return fmt.Errorf("typesafe: retry jitter must be between 0 and 1")46+ }47+ return nil48+}49+50+func retryableStatus(status int) bool {51+ return status == 408 || status == 429 || (status >= 500 && status <= 599)52+}53+54+func (p RetryPolicy) retriesError(err error) bool {55+ if _, ok := errors.AsType[*ResponseError](err); ok {56+ return false57+ }58+ if transport, ok := errors.AsType[*transportError](err); ok {59+ if transport.timeout {60+ return p.RetryTimeouts61+ }62+ return p.RetryConnectionErrors63+ }64+ return false65+}66+67+func retryDelay(policy RetryPolicy, attempt int, headers http.Header) time.Duration {68+ if delay, ok := serverDelay(headers, policy.MaxRetryAfter); ok {69+ return delay70+ }71+ delay := policy.InitialDelay72+ for range attempt {73+ if delay == 0 {74+ break75+ }76+ if delay >= policy.MaxDelay-delay {77+ delay = policy.MaxDelay78+ break79+ }80+ delay *= 281+ }82+ return delay - time.Duration(float64(delay)*rand.Float64()*policy.Jitter)83+}84+85+func serverDelay(headers http.Header, maximum time.Duration) (time.Duration, bool) {86+ if raw := headers.Get("retry-after-ms"); raw != "" {87+ if delay, ok := numericDelay(raw, time.Millisecond, maximum); ok {88+ return delay, true89+ }90+ }91+ raw := headers.Get("Retry-After")92+ if delay, ok := numericDelay(raw, time.Second, maximum); ok {93+ return delay, true94+ }95+ date, err := http.ParseTime(raw)96+ if err != nil {97+ return 0, false98+ }99+ delay := max(time.Until(date), 0)100+ return delay, delay <= maximum101+}102+103+func numericDelay(raw string, unit, maximum time.Duration) (time.Duration, bool) {104+ number, err := strconv.ParseFloat(raw, 64)105+ if err != nil || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {106+ return 0, false107+ }108+ nanoseconds := number * float64(unit)109+ if nanoseconds >= float64(math.MaxInt64) || nanoseconds > float64(maximum) {110+ return 0, false111+ }112+ return time.Duration(nanoseconds), true113+}114+115+func waitForRetry(ctx context.Context, delay time.Duration) error {116+ timer := time.NewTimer(delay)117+ defer timer.Stop()118+ select {119+ case <-ctx.Done():120+ return ctx.Err()121+ case <-timer.C:122+ return ctx.Err()123+ }124+}
A retry_test.go +170 -0
@@ -0,0 +1,170 @@
1+package typesafe2+3+import (4+ "context"5+ "errors"6+ "io"7+ "math"8+ "net/http"9+ "strings"10+ "testing"11+ "testing/synctest"12+ "time"13+)14+15+type roundTripFunc func(*http.Request) (*http.Response, error)16+17+func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }18+19+func mockResponse(status int, header http.Header) *http.Response {20+ return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(`{"models":[]}`))}21+}22+23+func retryClient(t *testing.T, transport roundTripFunc, policy RetryPolicy) *Client {24+ t.Helper()25+ client, err := NewClient(WithAPIKey("key"), WithBaseURL("https://test.invalid"),26+ WithHTTPClient(&http.Client{Transport: transport}), WithRetryPolicy(policy))27+ if err != nil {28+ t.Fatal(err)29+ }30+ return client31+}32+33+func TestRetryStatusAndCount(t *testing.T) {34+ for _, status := range []int{400, 401, 403, 404, 408, 422, 429, 500, 503, 529, 599} {35+ t.Run(http.StatusText(status), func(t *testing.T) {36+ calls := 037+ policy := DefaultRetryPolicy()38+ policy.InitialDelay, policy.MaxDelay = 0, 039+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {40+ calls++41+ if calls > 1 && request.Header.Get("X-TypeSafe-Retry-Count") == "" {42+ t.Error("retry identity header missing")43+ }44+ return mockResponse(status, make(http.Header)), nil45+ }, policy)46+ _, err := client.ListModels(context.Background())47+ if _, ok := errors.AsType[*APIError](err); !ok {48+ t.Fatalf("expected APIError, got %v", err)49+ }50+ want := 151+ if status == 408 || status == 429 || status >= 500 {52+ want = 353+ }54+ if calls != want {55+ t.Fatalf("attempts=%d, want %d", calls, want)56+ }57+ })58+ }59+}60+61+func TestRetryAfterAndCancellation(t *testing.T) {62+ synctest.Test(t, func(t *testing.T) {63+ calls := 064+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {65+ calls++66+ if calls == 1 {67+ return mockResponse(429, http.Header{"Retry-After": {"3"}}), nil68+ }69+ return mockResponse(200, make(http.Header)), nil70+ }, DefaultRetryPolicy())71+ start := time.Now()72+ if _, err := client.ListModels(t.Context()); err != nil {73+ t.Fatal(err)74+ }75+ if calls != 2 || time.Since(start) != 3*time.Second {76+ t.Fatalf("calls=%d delay=%s", calls, time.Since(start))77+ }78+ calls = 079+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)80+ defer cancel()81+ if _, err := client.ListModels(ctx); !errors.Is(err, context.DeadlineExceeded) || calls != 1 {82+ t.Fatalf("backoff ignored cancellation: calls=%d err=%v", calls, err)83+ }84+ })85+}86+87+func TestConnectionRetries(t *testing.T) {88+ for _, enabled := range []bool{false, true} {89+ calls := 090+ policy := DefaultRetryPolicy()91+ policy.RetryConnectionErrors = enabled92+ policy.InitialDelay, policy.MaxDelay = 0, 093+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {94+ calls++95+ if calls == 1 {96+ return nil, io.ErrUnexpectedEOF97+ }98+ return mockResponse(200, make(http.Header)), nil99+ }, policy)100+ _, err := client.ListModels(context.Background())101+ if enabled && (err != nil || calls != 2) {102+ t.Fatalf("expected recovery: calls=%d err=%v", calls, err)103+ }104+ if !enabled && (!errors.Is(err, io.ErrUnexpectedEOF) || calls != 1) {105+ t.Fatalf("unexpected retry: calls=%d err=%v", calls, err)106+ }107+ }108+}109+110+func TestRetryTimeoutPolicy(t *testing.T) {111+ synctest.Test(t, func(t *testing.T) {112+ for _, enabled := range []bool{false, true} {113+ calls := 0114+ policy := DefaultRetryPolicy()115+ policy.RetryTimeouts = enabled116+ policy.InitialDelay, policy.MaxDelay = 0, 0117+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {118+ calls++119+ <-request.Context().Done()120+ return nil, request.Context().Err()121+ }, policy)122+ _, err := client.ListModels(t.Context())123+ want := 1124+ if enabled {125+ want = 3126+ }127+ if !errors.Is(err, context.DeadlineExceeded) || calls != want {128+ t.Fatalf("calls=%d want=%d err=%v", calls, want, err)129+ }130+ }131+ })132+}133+134+func TestRetryDelay(t *testing.T) {135+ policy := DefaultRetryPolicy()136+ policy.Jitter = 0137+ tests := []struct {138+ name string139+ headers http.Header140+ attempt int141+ want time.Duration142+ }{143+ {"initial", nil, 0, 500 * time.Millisecond},144+ {"exponential", nil, 2, 2 * time.Second},145+ {"capped", nil, 10000, 5 * time.Second},146+ {"milliseconds", http.Header{"Retry-After-Ms": {"1250"}, "Retry-After": {"10"}}, 0, 1250 * time.Millisecond},147+ {"zero", http.Header{"Retry-After": {"0"}}, 0, 0},148+ {"invalid", http.Header{"Retry-After": {"garbage"}}, 0, 500 * time.Millisecond},149+ {"too large", http.Header{"Retry-After": {"9999999999999999999999"}}, 0, 500 * time.Millisecond},150+ {"negative", http.Header{"Retry-After": {"-1"}}, 0, 500 * time.Millisecond},151+ }152+ for _, test := range tests {153+ t.Run(test.name, func(t *testing.T) {154+ if got := retryDelay(policy, test.attempt, test.headers); got != test.want {155+ t.Fatalf("delay=%s want=%s", got, test.want)156+ }157+ })158+ }159+}160+161+func TestRetryDelayDurationBoundaries(t *testing.T) {162+ policy := RetryPolicy{InitialDelay: time.Duration(math.MaxInt64), MaxDelay: time.Duration(math.MaxInt64)}163+ if got := retryDelay(policy, 0, nil); got != policy.MaxDelay {164+ t.Fatalf("maximum duration overflowed: %s", got)165+ }166+ policy.InitialDelay, policy.MaxDelay = 0, time.Nanosecond167+ if got := retryDelay(policy, 1, nil); got != 0 {168+ t.Fatalf("zero backoff became positive: %s", got)169+ }170+}
A transport.go +149 -0
@@ -0,0 +1,149 @@
1+package typesafe2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "io"9+ "net"10+ "net/http"11+ "runtime"12+ "strconv"13+)14+15+const maxResponseBytes = 16 << 2016+17+type wireResponse struct {18+ status int19+ header http.Header20+ body []byte21+}22+23+func (r *wireResponse) requestID() string {24+ for _, name := range []string{"x-request-id", "request-id", "x-typesafe-request-id"} {25+ if value := r.header.Get(name); value != "" {26+ return value27+ }28+ }29+ return ""30+}31+32+type transportRequest struct {33+ method string34+ url string35+ body []byte36+}37+38+type transportError struct {39+ err error40+ timeout bool41+}42+43+func (e *transportError) Error() string { return "typesafe: HTTP transport failed: " + e.err.Error() }44+func (e *transportError) Unwrap() error { return e.err }45+46+func (c *Client) send(ctx context.Context, method, path string, body []byte) (*wireResponse, error) {47+ request := transportRequest{method: method, url: c.config.baseURL + path, body: body}48+ for attempt := 0; ; attempt++ {49+ if err := ctx.Err(); err != nil {50+ return nil, err51+ }52+ response, err := c.attempt(ctx, request, attempt)53+ if ctx.Err() != nil {54+ return nil, ctx.Err()55+ }56+ var headers http.Header57+ if err == nil {58+ if response.status >= 200 && response.status < 300 {59+ return response, nil60+ }61+ err = response.apiError(request)62+ if !retryableStatus(response.status) {63+ return nil, err64+ }65+ headers = response.header66+ } else if !c.config.retry.retriesError(err) {67+ return nil, err68+ }69+ if attempt >= c.config.retry.MaxRetries {70+ return nil, err71+ }72+ if err := waitForRetry(ctx, retryDelay(c.config.retry, attempt, headers)); err != nil {73+ return nil, err74+ }75+ }76+}77+78+func (r *wireResponse) apiError(request transportRequest) *APIError {79+ return &APIError{80+ StatusCode: r.status, RequestID: r.requestID(), Header: r.header,81+ Body: r.body, Method: request.method, URL: request.url,82+ Message: http.StatusText(r.status),83+ }84+}85+86+func (c *Client) attempt(ctx context.Context, spec transportRequest, attempt int) (*wireResponse, error) {87+ attemptCtx, cancel := context.WithTimeout(ctx, c.config.timeout)88+ defer cancel()89+ request, err := http.NewRequestWithContext(attemptCtx, spec.method, spec.url, bytes.NewReader(spec.body))90+ if err != nil {91+ return nil, fmt.Errorf("typesafe: construct HTTP request: %w", err)92+ }93+ request.Header = c.requestHeaders(spec.body != nil, attempt)94+ response, err := c.config.httpClient.Do(request)95+ if err != nil {96+ return nil, wrapTransportError(attemptCtx, err)97+ }98+ wire, err := readResponse(response)99+ if err != nil {100+ if _, ok := errors.AsType[*ResponseError](err); ok {101+ return nil, err102+ }103+ return nil, wrapTransportError(attemptCtx, err)104+ }105+ return wire, nil106+}107+108+func (c *Client) requestHeaders(hasBody bool, attempt int) http.Header {109+ headers := c.config.headers.Clone()110+ headers.Set("Authorization", "Bearer "+c.config.apiKey)111+ headers.Set("Accept", "application/json")112+ headers.Set("User-Agent", "typesafe-go/"+Version)113+ headers.Set("X-TypeSafe-SDK", "typesafe-go/"+Version)114+ headers.Set("X-TypeSafe-Runtime", runtime.Version()+" "+runtime.GOOS+"/"+runtime.GOARCH)115+ headers.Del("Content-Type")116+ if hasBody {117+ headers.Set("Content-Type", "application/json")118+ }119+ headers.Del("X-TypeSafe-Retry-Count")120+ if attempt > 0 {121+ headers.Set("X-TypeSafe-Retry-Count", strconv.Itoa(attempt))122+ }123+ return headers124+}125+126+func readResponse(response *http.Response) (*wireResponse, error) {127+ wire := &wireResponse{status: response.StatusCode, header: response.Header.Clone()}128+ body, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))129+ closeErr := response.Body.Close()130+ if err := errors.Join(readErr, closeErr); err != nil {131+ return nil, err132+ }133+ if len(body) > maxResponseBytes {134+ return nil, &ResponseError{RequestID: wire.requestID(), Err: fmt.Errorf("response exceeds 16 MiB limit")}135+ }136+ wire.body = body137+ return wire, nil138+}139+140+func wrapTransportError(ctx context.Context, err error) error {141+ if ctx.Err() != nil {142+ err = ctx.Err()143+ }144+ timeout := errors.Is(err, context.DeadlineExceeded)145+ if network, ok := errors.AsType[net.Error](err); ok {146+ timeout = timeout || network.Timeout()147+ }148+ return &transportError{err: err, timeout: timeout}149+}
A transport_test.go +126 -0
@@ -0,0 +1,126 @@
1+package typesafe2+3+import (4+ "context"5+ "errors"6+ "io"7+ "net/http"8+ "strings"9+ "testing"10+ "testing/synctest"11+ "time"12+)13+14+type brokenBody struct{ closed bool }15+16+func (*brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }17+func (b *brokenBody) Close() error {18+ b.closed = true19+ return nil20+}21+22+func TestInterruptedResponseIsClosedAndRetried(t *testing.T) {23+ body := &brokenBody{}24+ calls := 025+ policy := DefaultRetryPolicy()26+ policy.InitialDelay, policy.MaxDelay = 0, 027+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {28+ calls++29+ response := mockResponse(200, nil)30+ if calls == 1 {31+ response.Body = body32+ }33+ return response, nil34+ }, policy)35+ if _, err := client.ListModels(t.Context()); err != nil || calls != 2 || !body.closed {36+ t.Fatalf("calls=%d closed=%v err=%v", calls, body.closed, err)37+ }38+}39+40+type delayedBody struct{ ctx context.Context }41+42+func (b delayedBody) Read([]byte) (int, error) {43+ <-b.ctx.Done()44+ return 0, b.ctx.Err()45+}46+func (delayedBody) Close() error { return nil }47+48+func TestTimeoutIncludesResponseBody(t *testing.T) {49+ synctest.Test(t, func(t *testing.T) {50+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {51+ response := mockResponse(200, nil)52+ response.Body = delayedBody{ctx: request.Context()}53+ return response, nil54+ }, RetryPolicy{})55+ start := time.Now()56+ if _, err := client.ListModels(t.Context()); !errors.Is(err, context.DeadlineExceeded) {57+ t.Fatalf("body read was not canceled: %v", err)58+ }59+ if time.Since(start) != 10*time.Second {60+ t.Fatalf("attempt timeout was not applied: %s", time.Since(start))61+ }62+ })63+}64+65+func TestResponseBodyLimitDoesNotRetry(t *testing.T) {66+ calls := 067+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {68+ calls++69+ response := mockResponse(200, http.Header{"X-Request-Id": {"req_large"}})70+ response.Body = io.NopCloser(strings.NewReader(strings.Repeat(" ", maxResponseBytes+1)))71+ return response, nil72+ }, DefaultRetryPolicy())73+ _, err := client.ListModels(t.Context())74+ responseErr, ok := errors.AsType[*ResponseError](err)75+ if !ok || responseErr.RequestID != "req_large" || calls != 1 {76+ t.Fatalf("calls=%d err=%v", calls, err)77+ }78+}79+80+func TestInvalidRequestsNeverUseTransport(t *testing.T) {81+ client := retryClient(t, func(request *http.Request) (*http.Response, error) {82+ t.Error("invalid request reached the transport")83+ return mockResponse(200, nil), nil84+ }, RetryPolicy{})85+ if _, err := client.SystemOne(t.Context(), SystemOneRequest{}); err == nil {86+ t.Fatal("empty questions accepted")87+ }88+ if _, err := client.ListModels(nil); err == nil {89+ t.Fatal("nil context accepted")90+ }91+ var zero Client92+ if _, err := zero.ListModels(t.Context()); err == nil {93+ t.Fatal("zero client accepted")94+ }95+ var absent *Client96+ if _, err := absent.SystemOne(t.Context(), testRequest()); err == nil {97+ t.Fatal("nil client accepted")98+ }99+}100+101+func TestCustomClientIsNotMutated(t *testing.T) {102+ sentinel := errors.New("caller redirect policy")103+ custom := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return sentinel }}104+ client, err := NewClient(WithAPIKey("key"), WithHTTPClient(custom))105+ if err != nil {106+ t.Fatal(err)107+ }108+ if client.config.httpClient == custom || custom.CheckRedirect(nil, nil) != sentinel {109+ t.Fatal("caller client was changed")110+ }111+}112+113+func TestRetryAfterDatesAndJitter(t *testing.T) {114+ synctest.Test(t, func(t *testing.T) {115+ policy := DefaultRetryPolicy()116+ future := time.Now().Add(8 * time.Second).UTC().Format(http.TimeFormat)117+ if got := retryDelay(policy, 0, http.Header{"Retry-After": {future}}); got != 8*time.Second {118+ t.Fatalf("date delay=%s", got)119+ }120+ for range 100 {121+ if got := retryDelay(policy, 0, nil); got < 375*time.Millisecond || got > 500*time.Millisecond {122+ t.Fatalf("jitter delay out of bounds: %s", got)123+ }124+ }125+ })126+}
A types.go +45 -0