Go

A net/http client with a submit, a poll loop and typed responses, in one file.

silvertext.go
package silvertext

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"time"
)

const base = "https://api.silvertext.com"

type Client struct {
	Key  string
	HTTP *http.Client
}

func New() *Client {
	return &Client{Key: os.Getenv("SILVERTEXT_API_KEY"), HTTP: &http.Client{Timeout: 60 * time.Second}}
}

type APIError struct {
	Status  int
	Code    string `json:"code"`
	Message string `json:"message"`
}

func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }

type Summary struct {
	ID        string   `json:"id"`
	Status    string   `json:"status"`
	Progress  float64  `json:"progress"`
	Phase     string   `json:"phase"`
	WordCount *int     `json:"wordCount"`
	Scores    *struct {
		Plagiarism      float64  `json:"plagiarism"`
		GrammarSpelling *float64 `json:"grammarSpelling"`
		Citation        *float64 `json:"citation"`
		AIContent       *float64 `json:"aiContent"`
		Clarity         *float64 `json:"clarity"`
	} `json:"scores"`
	Rejection *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"rejection"`
}

func (c *Client) do(ctx context.Context, method, path string, body any, idem string, out any) error {
	var payload []byte
	if body != nil {
		payload, _ = json.Marshal(body)
	}
	delay := time.Second
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(payload))
		req.Header.Set("Authorization", "Bearer "+c.Key)
		req.Header.Set("Content-Type", "application/json")
		if idem != "" {
			req.Header.Set("Idempotency-Key", idem)
		}
		res, err := c.HTTP.Do(req)
		if err == nil && res.StatusCode < 400 {
			defer res.Body.Close()
			if out != nil && res.ContentLength != 0 {
				return json.NewDecoder(res.Body).Decode(out)
			}
			return nil
		}
		retryable := err != nil || res.StatusCode == 429 || res.StatusCode >= 500
		if !retryable || attempt >= 4 {
			if err != nil {
				return err
			}
			defer res.Body.Close()
			var wrapped struct{ Error APIError `json:"error"` }
			_ = json.NewDecoder(res.Body).Decode(&wrapped)
			wrapped.Error.Status = res.StatusCode
			return &wrapped.Error
		}
		wait := delay
		if res != nil {
			if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && s > 0 {
				wait = time.Duration(s) * time.Second
			}
			res.Body.Close()
		}
		time.Sleep(wait)
		if delay < 30*time.Second {
			delay *= 2
		}
	}
}

func (c *Client) CreateCheck(ctx context.Context, body map[string]any, idem string) (string, error) {
	var out struct{ ID string `json:"id"` }
	err := c.do(ctx, http.MethodPost, "/v1/checks", body, idem, &out)
	return out.ID, err
}

func (c *Client) GetSummary(ctx context.Context, id string) (*Summary, error) {
	var s Summary
	err := c.do(ctx, http.MethodGet, "/v1/checks/"+id+"?view=summary", nil, "", &s)
	return &s, err
}

// GetReport decodes the parts you ask for into out (a struct with a `report` field).
func (c *Client) GetReport(ctx context.Context, id string, include string, out any) error {
	return c.do(ctx, http.MethodGet, "/v1/checks/"+id+"?include="+include, nil, "", out)
}

func (c *Client) Cancel(ctx context.Context, id string) error {
	return c.do(ctx, http.MethodPost, "/v1/checks/"+id+"/cancel", nil, "", nil)
}

var final = map[string]bool{"done": true, "rejected": true, "failed": true, "cancelled": true}

func (c *Client) Wait(ctx context.Context, id string) (*Summary, error) {
	delay := 3 * time.Second
	started := time.Now()
	for {
		s, err := c.GetSummary(ctx, id)
		if err != nil {
			return nil, err
		}
		if final[s.Status] {
			return s, nil
		}
		select {
		case <-ctx.Done():
			_ = c.Cancel(context.Background(), id)
			return nil, ctx.Err()
		case <-time.After(delay):
		}
		if time.Since(started) > time.Minute && delay < 10*time.Second {
			delay += 2 * time.Second
		}
	}
}

Using it

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

st := silvertext.New()
id, err := st.CreateCheck(ctx, map[string]any{
	"title":   "Q3 launch post",
	"text":    draft,
	"options": map[string]any{"citations": false},
}, "post:"+postID+":v3")
if err != nil {
	log.Fatal(err)
}
summary, err := st.Wait(ctx, id)
if err != nil || summary.Status != "done" {
	log.Fatal(err, summary.Status)
}
fmt.Printf("plagiarism %.0f%%\n", summary.Scores.Plagiarism)

The context deadline doubles as the give-up point: when it expires, Wait cancels the check so its credits are released.

On this page