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#
Construct one with New. The zero value is not usable.
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
Methods#
| Method | Signature |
|---|---|
| Complete | Complete(ctx context.Context, req *Request) (*Response, error) |
| Stream | Stream(ctx context.Context, req *Request) (Stream, error) |
| Models | Models(ctx context.Context) ([]ModelInfo, error) |
| Provider | Provider() Provider |
Caveats
- A
Clientis 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.Clientis 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#
| Behaviour | Applies to |
|---|---|
| Local request validation | Complete, Stream |
| Retry with exponential backoff and full jitter | Complete, Models, and the Stream handshake only |
Per-attempt timeout (WithTimeout) | Complete, Models — not Stream |
| Hook events, one per attempt | All 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
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
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:
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.