Skip to content
skyl

Client

Wraps a Provider with validation, retry, timeouts and hooks.

Client is what you call. It wraps a Provider and adds the cross-cutting behaviour every production caller needs — written and tested once, rather than once per vendor.

Reference#

type Client struct

Construct one with New. The zero value is not usable.

your code

skyl.Client

validation · retry/backoff · timeout · hooks

skyl.Provider

the seam — four methods

anthropic

api.anthropic.com

openai

api.openai.com

gemini

generativelanguage…

openaicompat

any OpenAI-shaped host

Everything cross-cutting lives in Client, so it is written and tested once.

Methods#

MethodSignature
CompleteComplete(ctx context.Context, req *Request) (*Response, error)
StreamStream(ctx context.Context, req *Request) (Stream, error)
ModelsModels(ctx context.Context) ([]ModelInfo, error)
ProviderProvider() Provider

Caveats

  • A Client is safe for concurrent use by multiple goroutines. It holds no mutable per-request state; everything scoped to a call lives on the stack.
  • The underlying *http.Client is shared, which is correct and intended — it is how connection pooling happens.
  • A Stream, by contrast, is not concurrency-safe. One stream, one consuming goroutine.
  • Hooks run synchronously on the calling goroutine, so a slow hook slows the request.

What Client adds#

BehaviourApplies to
Local request validationComplete, Stream
Retry with exponential backoff and full jitterComplete, Models, and the Stream handshake only
Per-attempt timeout (WithTimeout)Complete, Models — not Stream
Hook events, one per attemptAll four operations

WithTimeout is deliberately not applied to Stream: the stream outlives the call, so a per-attempt deadline would kill one that is working perfectly.

Usage#

One client per process

goCompiles
type Service struct{ ai *skyl.Client }

func New(key string) *Service {
	// Concurrency-safe and pooling-aware: build it once.
	return &Service{ai: skyl.New(openai.New(key))}
}

func (s *Service) Summarise(ctx context.Context, text string) (string, error) {
	resp, err := s.ai.Complete(ctx, &skyl.Request{
		Model:     "gpt-5.6",
		MaxTokens: 256,
		System:    "Summarise in one sentence.",
		Messages:  []skyl.Message{skyl.UserText(text)},
	})
	if err != nil {
		return "", err
	}
	return resp.Text(), nil
}
type Service struct{ ai *skyl.Client }

func New(key string) *Service {
	// Concurrency-safe and pooling-aware: build it once.
	return &Service{ai: skyl.New(openai.New(key))}
}

func (s *Service) Summarise(ctx context.Context, text string) (string, error) {
	resp, err := s.ai.Complete(ctx, &skyl.Request{
		Model:     "gpt-5.6",
		MaxTokens: 256,
		System:    "Summarise in one sentence.",
		Messages:  []skyl.Message{skyl.UserText(text)},
	})
	if err != nil {
		return "", err
	}
	return resp.Text(), nil
}

Several clients for several workloads

goCompiles
type Clients struct {
	Fast  *skyl.Client // high volume, low stakes
	Smart *skyl.Client // low volume, worth the money
}
type Clients struct {
	Fast  *skyl.Client // high volume, low stakes
	Smart *skyl.Client // low volume, worth the money
}

Both build the same Request type, so the code that constructs a prompt does not need to know which will serve it.

Troubleshooting#

Latency is higher than the provider's, by a lot

Check whether you are constructing a client per request. Each new client brings a new *http.Client, so every call pays a fresh TLS handshake instead of reusing a pooled connection.

Construct once and reuse.

Requests hang far longer than my timeout

WithTimeout bounds a single attempt. With three retries and backoff, the worst case is roughly four attempts plus the delays between them.

Bound the whole call with your own context:

goCompiles
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()

My hook slowed everything down

Hooks run synchronously on the calling goroutine. Do metrics and logging; buffer anything that does I/O, with a non-blocking send so a full buffer drops events rather than blocking a model call.

Edit this page on GitHub