Provider is four methods, and nothing in skyl privileges the in-tree adapters.
An adapter in your own repository is a first-class citizen: pass it to
skyl.New and it gets validation, retry, timeouts, hooks and the gateway with
no changes to skyl.
You will learn
- The four methods and what each must guarantee
- The contract every adapter is expected to honour
- How to test yours against skyl's own expectations
- When writing one is the right answer
The interface#
type Provider interface {
Name() string
Complete(ctx context.Context, req *Request) (*Response, error)
Stream(ctx context.Context, req *Request) (Stream, error)
Models(ctx context.Context) ([]ModelInfo, error)
}type Provider interface {
Name() string
Complete(ctx context.Context, req *Request) (*Response, error)
Stream(ctx context.Context, req *Request) (Stream, error)
Models(ctx context.Context) ([]ModelInfo, error)
}Implementations must be safe for concurrent use by multiple goroutines.
The contract#
Beyond the signatures, an adapter is expected to honour these:
| Rule | Why |
|---|---|
| Honour ctx | Cancellation and deadlines must propagate, or Client cannot bound anything. |
Always populate Response.Raw | It is the caller's escape hatch; an empty Raw breaks the guarantee. |
Set Provider and Model from the actual response | Model is read, not echoed — providers substitute. |
Honour Request.ProviderOptions | The contract suite asserts this, so the rule cannot be met by some adapters and quietly missed by others. |
Return ErrUnsupported rather than dropping data | Silent data loss is the worst failure mode this library has. |
| Classify errors onto skyl sentinels | Client's retry loop branches on classification, not status codes. |
| Bind the stream to ctx and never leak a goroutine | A leaked goroutine per request is a 3am bug. |
| Report truncation rather than a clean end | A stream cut short must not look like a complete short answer. |
A skeleton#
package myprovider
type Provider struct {
apiKey string
baseURL string
hc *http.Client
}
func New(apiKey string, opts ...Option) *Provider {
p := &Provider{apiKey: apiKey, baseURL: defaultBaseURL, hc: http.DefaultClient}
for _, opt := range opts {
opt(p)
}
return p
}
func (p *Provider) Name() string { return "myprovider" }
func (p *Provider) Complete(ctx context.Context, req *skyl.Request) (*skyl.Response, error) {
payload, err := p.encode(req)
if err != nil {
return nil, err
}
// ProviderOptions must override anything we set. This is part of the
// contract, and the shared suite asserts it.
for k, v := range req.ProviderOptions {
payload[k] = v
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
res, err := p.hc.Do(httpReq)
if err != nil {
// Preserve the cause so errors.Is(err, context.DeadlineExceeded) works.
return nil, (&skyl.Error{Provider: p.Name()}).WithCause(err)
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode >= 400 {
return nil, skyl.NewError(p.Name(), res.StatusCode,
skyl.ClassifyStatus(res.StatusCode), extractMessage(raw), raw)
}
return p.decode(raw) // must set Provider, Model, and Raw
}package myprovider
type Provider struct {
apiKey string
baseURL string
hc *http.Client
}
func New(apiKey string, opts ...Option) *Provider {
p := &Provider{apiKey: apiKey, baseURL: defaultBaseURL, hc: http.DefaultClient}
for _, opt := range opts {
opt(p)
}
return p
}
func (p *Provider) Name() string { return "myprovider" }
func (p *Provider) Complete(ctx context.Context, req *skyl.Request) (*skyl.Response, error) {
payload, err := p.encode(req)
if err != nil {
return nil, err
}
// ProviderOptions must override anything we set. This is part of the
// contract, and the shared suite asserts it.
for k, v := range req.ProviderOptions {
payload[k] = v
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
res, err := p.hc.Do(httpReq)
if err != nil {
// Preserve the cause so errors.Is(err, context.DeadlineExceeded) works.
return nil, (&skyl.Error{Provider: p.Name()}).WithCause(err)
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode >= 400 {
return nil, skyl.NewError(p.Name(), res.StatusCode,
skyl.ClassifyStatus(res.StatusCode), extractMessage(raw), raw)
}
return p.decode(raw) // must set Provider, Model, and Raw
}skyl.NewError, skyl.ClassifyStatus, skyl.ParseRetryAfter and
skyl.Unsupportedf are exported precisely so that an out-of-tree adapter
produces errors indistinguishable from an in-tree one.
Returning ErrUnsupported#
for _, m := range req.Messages {
for _, part := range m.Parts {
if img, ok := part.(skyl.Image); ok && img.URL != "" {
// Name exactly what could not be represented.
return nil, skyl.Unsupportedf(p.Name(),
"myprovider requires inline image data, not a URL")
}
}
}for _, m := range req.Messages {
for _, part := range m.Parts {
if img, ok := part.(skyl.Image); ok && img.URL != "" {
// Name exactly what could not be represented.
return nil, skyl.Unsupportedf(p.Name(),
"myprovider requires inline image data, not a URL")
}
}
}Never drop it silently. A quietly discarded image looks like a model that ignored the question, and the caller will spend an afternoon on the prompt.
Testing it#
Wrap your provider in a real skyl.New(...) so your tests exercise the actual
validation and retry code rather than a mock of it. Then check the contract:
func TestHonoursProviderOptions(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&got)
_, _ = w.Write([]byte(`{"text":"ok"}`))
}))
defer srv.Close()
p := New("k", WithBaseURL(srv.URL))
_, _ = skyl.New(p).Complete(context.Background(), &skyl.Request{
Model: "m",
MaxTokens: 16,
Messages: []skyl.Message{skyl.UserText("hi")},
ProviderOptions: map[string]any{"top_k": 40},
})
if got["top_k"] != float64(40) {
t.Fatalf("ProviderOptions did not reach the wire: %v", got)
}
}func TestHonoursProviderOptions(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&got)
_, _ = w.Write([]byte(`{"text":"ok"}`))
}))
defer srv.Close()
p := New("k", WithBaseURL(srv.URL))
_, _ = skyl.New(p).Complete(context.Background(), &skyl.Request{
Model: "m",
MaxTokens: 16,
Messages: []skyl.Message{skyl.UserText("hi")},
ProviderOptions: map[string]any{"top_k": 40},
})
if got["top_k"] != float64(40) {
t.Fatalf("ProviderOptions did not reach the wire: %v", got)
}
}Also assert that streams do not leak, that a truncated stream reports an error, and that each sentinel is produced by the status that should produce it.
When to write one#
Deep diveUsually you want openaicompat instead
If your target speaks OpenAI's wire format — and a surprising proportion do —
openaicompat already reaches it with one constructor and no code:
p := openaicompat.New(
openaicompat.WithBaseURL("https://api.example.com/v1"),
openaicompat.WithName("example"),
)p := openaicompat.New(
openaicompat.WithBaseURL("https://api.example.com/v1"),
openaicompat.WithName("example"),
)Write your own adapter when the API is genuinely a different shape: a bespoke internal model service, a vendor with its own protocol, or a provider whose features you need at full fidelity rather than through the compatible subset.
If you build one worth sharing, the project welcomes it — see Contributing. But it is a first-class citizen living in your own repository too, which is the point of keeping the interface to four methods.
Recap
- Four methods, and implementations must be concurrency-safe.
- Honour
ctx, always populateRaw, and readModelfrom the response. - Honour
ProviderOptions— the shared contract suite asserts it. - Return
ErrUnsupportednaming the part rather than dropping data. - Use
NewError,ClassifyStatusandUnsupportedfso your errors match in-tree ones. - Prefer
openaicompatunless the API is genuinely a different shape.
Try out some challenges
Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.
Implement Models honestly
Your internal service has no model-listing endpoint. What should Models
return?
Show hint
There is a sentinel for exactly this, and Client treats it specially.
Show solution
func (p *Provider) Models(context.Context) ([]skyl.ModelInfo, error) {
return nil, skyl.Unsupportedf(p.Name(), "this service exposes no model-listing endpoint")
}func (p *Provider) Models(context.Context) ([]skyl.ModelInfo, error) {
return nil, skyl.Unsupportedf(p.Name(), "this service exposes no model-listing endpoint")
}Return ErrUnsupported, not an empty slice and not a hardcoded list.
An empty slice says "this provider offers no models", which is false. A hardcoded list is the curated-table failure mode that ADR-0004 exists to avoid — it rots silently.
Client.Models recognises ErrUnsupported and does not retry it, because a
provider that cannot list models will never be able to. Returning anything else
means burning four attempts on a capability that does not exist.