skyl

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

skyl

CI Go Reference

One Go interface for every AI model.

📖 skyl-docs.vercel.app — the documentation site.

skyl is a small, dependency-light Go library that lets you talk to Claude, GPT, Gemini, and 400+ other models through a single, stable interface — then switch between them by changing one string.

client := skyl.New(anthropic.New(os.Getenv("ANTHROPIC_API_KEY")))

resp, err := client.Complete(ctx, &skyl.Request{
	Model:    "claude-opus-5",
	Messages: []skyl.Message{skyl.UserText("Explain Go channels in two sentences.")},
})
fmt.Println(resp.Text())

Swapping to GPT is a one-line change:

client := skyl.New(openai.New(os.Getenv("OPENAI_API_KEY")))

Why skyl

Integrating an AI model into a Go service means writing the same 400 lines every time: request mapping, SSE parsing, retry with jitter, rate-limit backoff, token accounting, error classification. Then your provider ships a new model, or you want to A/B two vendors, and you write it again.

skyl does that once, properly, with tests.

Provider-agnostic One Request/Response shape across every vendor
Zero-day model support Model IDs are pass-through strings — new models work the day they launch, with no skyl release
No lock-in Every response carries Raw — the untouched provider JSON — so you are never blocked by our abstraction
Streaming that works Unified SSE with proper context cancellation and no goroutine leaks
Honest errors Typed, classified, errors.Is/errors.As-friendly
Small surface The core module has zero external dependencies. No router, no logger, no framework

Install

go get github.com/BAGOMBEKA-JOB-DEV/skyl

Requires Go 1.22+, and the core module has zero external dependencies — so it imposes neither a dependency graph nor a recent toolchain on you. The adapter modules below need Go 1.24+, because the vendor SDKs they wrap do.

The Anthropic adapter is a separate module, because it uses the official Anthropic SDK and that brings a dozen transitive dependencies nobody else should pay for (ADR-0006):

go get github.com/BAGOMBEKA-JOB-DEV/skyl/provider/anthropic

Provider coverage

Native adapters — full fidelity, including provider-specific features (extended thinking, prompt caching, native tool calling):

Adapter Reaches
provider/anthropic ⁽*⁾ Claude Opus 5, Fable 5, Sonnet 5, Haiku 4.5, all 4.x
provider/openai GPT-5.6 (Sol/Terra/Luna), 5.5, 5.4 nano, o-series, gpt-oss
provider/gemini Gemini 3.6 Flash, 3.5/3.1 Flash-Lite, 3 Pro

⁽*⁾ separate module — install it explicitly, as shown above.

provider/openaicompat — one adapter, configured per host, reaches the long tail of vendors that speak OpenAI's wire format:

xAI (Grok) · DeepSeek · Mistral · Groq · Together · Fireworks · OpenRouter · Perplexity · Cerebras · DeepInfra · Qwen/DashScope · Moonshot (Kimi) · Z.ai (GLM) · Nvidia NIM · Ollama · vLLM · LM Studio · llama.cpp

// Any OpenAI-compatible endpoint, including local ones.
client := skyl.New(openaicompat.New(
	openaicompat.WithBaseURL("http://localhost:11434/v1"),
	openaicompat.WithName("ollama"),
))

Full details and the model-discovery story: docs/providers.md.

On GitHub Copilot: skyl deliberately does not ship a Copilot provider. Copilot has no public completions API — its REST endpoints are administrative only, and the Copilot SDK's BYOK mode forwards to other vendors' keys. A "Copilot provider" could only be a relabelled OpenAI call, which would be dishonest. See ADR-0005.

The optional gateway

gateway/ is a separate module (its own go.mod) that exposes skyl over HTTP using go-chi — one REST + SSE endpoint that fans out to any configured provider. Use it when non-Go services need model access, or when you want API keys held in exactly one place.

go run github.com/BAGOMBEKA-JOB-DEV/skyl/gateway/cmd/skyl-gateway

Importing the core library never pulls in chi. See docs/gateway.md and ADR-0003.

Deploying it

The gateway builds as a multi-architecture container image — gcr.io/distroless/static:nonroot, no shell, configuration entirely from the environment. Build and run it from a clone:

docker build -t skyl-gateway .
docker run --rm -p 8080:8080 \
  -e SKYL_AUTH_TOKEN="$(openssl rand -hex 32)" \
  -e ANTHROPIC_API_KEY=sk-ant-... \
  skyl-gateway

Or with no API key at all — docker compose up --build runs it against the local sandbox, which speaks every provider's wire protocol.

Released images are published to ghcr.io/bagombeka-job-dev/skyl-gateway on each gateway/v* tag — multi-architecture, signed with cosign, and carrying an SBOM and a build-provenance attestation.

cosign verify ghcr.io/bagombeka-job-dev/skyl-gateway:1.0.0 \
  --certificate-identity-regexp '^https://github.com/BAGOMBEKA-JOB-DEV/skyl/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Deploy by digest rather than by tag: a tag is a mutable pointer, so pinning one makes a rollback a guess about which bytes it lands on.

For a cluster, BAGOMBEKA-JOB-DEV/skyl_infrastructure is a separate repository holding Terraform that stands up AWS, GCP or Azure behind one interface, and a Helm chart that runs identically on all three. It encodes the operational details that are easy to get wrong and expensive to discover in production — liveness on /healthz and readiness on /readyz, a 40-second termination grace period for the drain described in docs/gateway.md, secrets from the cloud's own secret store rather than from git, and images pinned by digest so a rollback lands on the bytes that were signed.

Deployment lives in its own repository for the same reason the docs site does: so that infrastructure changes never touch this repository's release history.

The three repositories

Repository What it is
skyl This one — the Go library, the adapters, and the gateway
skyl_docs The documentation site (Next.js)
skyl_infrastructure Deployment: Terraform for three clouds, the Helm chart, CI

Documentation

The documentation site is the friendliest way in: a Learn track to read in order, and a Reference track with one page per symbol. It is built from BAGOMBEKA-JOB-DEV/skyl_docs — a separate repository, so a docs change never touches the library's release history. Corrections to the site belong there; corrections to the files below belong here.

The documents in this repository stay authoritative for anything a decision depends on — the feature matrix, the threat model, the ADRs — because they are versioned with the code they describe.

Document What it covers
docs/idea.md The problem, the goals, and the explicit non-goals
docs/getting-started.md Install → first call → streaming → tools
docs/architecture.md How the pieces fit and why
docs/providers.md Provider coverage and model discovery
docs/gateway.md The chi HTTP gateway
docs/project-plan.md Milestones, scope, and status
docs/roadmap.md What stands between this and production use
docs/feature-matrix.md Which adapter supports what — including what each one silently ignores
docs/data-handling.md What leaves your process, what is kept, what is logged
docs/threat-model.md Trust boundaries, and what an authenticated gateway caller can do
docs/benchmarks.md Measured allocation and throughput figures
docs/migrating.md Coming from openai-go, anthropic-sdk-go, or langchaingo
docs/rules.md Engineering rules every change must satisfy
docs/adr/ Architecture decision records
CONTRIBUTING.md How to propose and land a change
SECURITY.md Reporting vulnerabilities; credential handling

Deployment is documented in the infrastructure repository rather than here: architecture and quickstart, the cluster runbook, and what each cloud costs.

Status

v1.0.0 — released 2026-09-06. The API is stable. Everything here is implemented, unit-tested, contract-tested, exercised end to end over real sockets, and CI-green — and as of 2026-08-05 the integration suite has been run against the real OpenAI, Anthropic and Gemini endpoints and passes.

That last part is the one that took longest to be able to write. Every fake in this test suite was written from the same provider documentation as the adapter it tests, so a wrong field name is wrong identically in both and CI stays green regardless. Only a real call settles it, and one has now been made against each adapter: completions, streaming, tool calls, the multi-turn tool round trip, truncation, and error classification.

What v1.0.0 promises, and what it does not. It promises the exported API: Provider, Client, Message, Part, Request, Response, Stream, the error sentinels and every functional option are frozen, and none of them changes without a v2 (docs/rules.md §1.2). Upgrading from v0.1.0 breaks nothing.

It does not promise complete provider coverage. The feature matrix still lists what each adapter silently ignores, and docs/roadmap.md still lists what is deliberately deferred — embeddings, prompt-caching control, batch APIs, token counting, failover. A frozen API and a finished feature surface are different claims, and only the first is being made.

Nor is live validation a subscription. It is a snapshot: providers change, and docs/validating.md is how you re-run it yourself against your own account and models.

There are three suites, and the third is the one CI cannot run for you:

go test ./...                  # mapping logic, in process
go test -tags=sandbox ./...    # the full stack over real sockets — no keys needed
go test -tags=integration ./...# real providers — needs your keys, costs money

CI runs the first two on every change. The third needs credentials, so it runs by hand — reproduce it with your own:

export ANTHROPIC_API_KEY=... OPENAI_API_KEY=... GEMINI_API_KEY=...
go test -tags=integration ./provider/
cd provider/anthropic && go test -tags=integration ./...

Eight checks per provider, about nine upstream requests, pennies on a small model. docs/validating.md covers what each one proves and how to tell an adapter bug from a model being unhelpful.

To develop against skyl before you have any key, run the local sandbox — it speaks all four providers' wire protocols and costs nothing. See docs/sandbox.md.

go run ./cmd/skyl-sandbox

See docs/project-plan.md for what is done and what is next, and CHANGELOG.md for release history.

License

Apache License 2.0 — see LICENSE.

Documentation

Overview

Package skyl provides one Go interface for every AI model.

It lets you talk to Claude, GPT, Gemini, and hundreds of other models through a single stable API, then switch between them by changing one string.

client := skyl.New(anthropic.New(os.Getenv("ANTHROPIC_API_KEY")))

resp, err := client.Complete(ctx, &skyl.Request{
	Model:     "claude-opus-5",
	MaxTokens: 1024,
	Messages:  []skyl.Message{skyl.UserText("Hello")},
})

Models

skyl never validates model IDs against a list. Request.Model is passed to the provider untouched, so a model released after your skyl build works immediately. Ask a provider what it currently offers with Client.Models.

Escape hatches

Every abstraction over a fast-moving API is wrong somewhere, so skyl is never the last word: Request.ProviderOptions sends fields skyl does not model, and Response.Raw exposes the untouched provider reply.

Example

The shortest useful program: wrap a provider and ask it something.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	client := skyl.New(fakeProvider{text: "Paris"})

	resp, err := client.Complete(context.Background(), &skyl.Request{
		Model:     "example-model",
		MaxTokens: 64,
		Messages:  []skyl.Message{skyl.UserText("What is the capital of France?")},
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(resp.Text())
	fmt.Printf("%d in / %d out\n", resp.Usage.InputTokens, resp.Usage.OutputTokens)
}
Output:
Paris
8 in / 3 out

Index

Examples

Constants

View Source
const (
	// OpComplete is a non-streaming request.
	OpComplete = "complete"

	// OpStream is a streaming request's handshake. It fires as soon as the
	// provider accepts the request, before any token exists, so it carries no
	// usage — see [OpStreamEnd].
	OpStream = "stream"

	// OpStreamEnd fires once when a stream finishes, and is where streaming
	// token usage is reported. Duration covers the whole stream rather than
	// the handshake.
	OpStreamEnd = "stream_end"

	// OpModels is a model-listing request.
	OpModels = "models"
)

Operations reported in HookEvent.Operation.

Variables

View Source
var (
	// ErrAuth means the credential was missing, malformed, or rejected.
	// Never retried: the same key will fail again.
	ErrAuth = errors.New("skyl: authentication failed")

	// ErrRateLimit means the provider is throttling. Retried with backoff.
	ErrRateLimit = errors.New("skyl: rate limited")

	// ErrNotFound means the model or endpoint does not exist for this
	// account. Because skyl passes model IDs through unvalidated, a typo
	// arrives here rather than failing locally.
	ErrNotFound = errors.New("skyl: not found")

	// ErrBadRequest means the request was malformed. Never retried.
	ErrBadRequest = errors.New("skyl: invalid request")

	// ErrServer means the provider failed on its side. Retried with backoff.
	ErrServer = errors.New("skyl: provider server error")

	// ErrUnsupported means this provider cannot express part of the request.
	//
	// Adapters return it instead of silently dropping data, because a
	// quietly discarded image looks like a model that ignored the question.
	ErrUnsupported = errors.New("skyl: unsupported by this provider")

	// ErrRefusal means the model or its safety classifiers declined. Never
	// retried: the same prompt gets the same answer.
	ErrRefusal = errors.New("skyl: model declined the request")

	// ErrStreamClosed means the stream was used after being closed.
	ErrStreamClosed = errors.New("skyl: stream is closed")
)

Sentinel errors describing what went wrong, independent of provider.

Branch on these with errors.Is rather than inspecting message text — providers reword their messages, and string matching breaks silently when they do.

Functions

func ClassifyStatus

func ClassifyStatus(status int) error

ClassifyStatus maps an HTTP status code onto a skyl sentinel.

Adapters should prefer a provider's own error type where it is more precise, and fall back to this.

func ParseRetryAfter

func ParseRetryAfter(v string) time.Duration

ParseRetryAfter interprets a Retry-After header.

The header may be a delay in seconds or an HTTP date; both forms are handled. It returns zero when the value is absent or unparseable, and never returns a negative duration.

func Unsupportedf

func Unsupportedf(provider, format string, args ...any) error

Unsupportedf builds an ErrUnsupported error naming what could not be represented, so the caller can tell exactly which part failed.

Adapters use it instead of silently dropping data they cannot express.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client wraps a Provider with the behaviour every production caller needs: request validation, retry with jittered backoff, per-attempt timeouts, and observability hooks.

Keeping these in Client rather than in each adapter means they are written and tested once, so a new adapter inherits them for free.

A Client is safe for concurrent use by multiple goroutines.

func New

func New(p Provider, opts ...Option) *Client

New returns a Client that dispatches to p.

It panics if p is nil: a nil provider is a programmer error that would otherwise surface as a confusing nil dereference on the first request.

Example (RealProvider)

Switching providers is a one-line change, because everything below the seam is identical. This example needs a credential, so it is compiled but not run.

package main

import (
	"context"
	"encoding/json"
	"time"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	// import "github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openai"
	//
	//	client := skyl.New(openai.New(os.Getenv("OPENAI_API_KEY")))
	//
	// or, with the behaviour a production caller usually wants:
	client := skyl.New(
		fakeProvider{text: "..."},
		skyl.WithMaxRetries(5),
		skyl.WithRetryDelay(time.Second, time.Minute),
		skyl.WithTimeout(2*time.Minute),
	)
	_ = client
}

func (*Client) Complete

func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error)

Complete runs a request to completion, retrying retryable failures.

The request is validated locally first, so a malformed request fails without costing a round trip.

Example (Production)

Compiled but not run: it needs a real credential. Kept so the pattern in the README cannot drift from something that compiles.

package main

import (
	"context"
	"os"
	"time"
)

func main() {
	key := os.Getenv("OPENAI_API_KEY")
	if key == "" {
		return
	}
	// client := skyl.New(openai.New(key))
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	_ = ctx
}

func (*Client) Models

func (c *Client) Models(ctx context.Context) ([]ModelInfo, error)

Models lists the models the provider currently offers.

The answer comes from the provider, live, so it is never stale. Providers with no such endpoint return ErrUnsupported.

Example

Models are listed live from the provider, so the answer is never stale.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	models, err := skyl.New(fakeProvider{}).Models(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	for _, m := range models {
		fmt.Println(m.ID)
	}
}
Output:
example-model

func (*Client) Provider

func (c *Client) Provider() Provider

Provider returns the underlying provider, for callers who want to bypass Client's retry and validation.

func (*Client) Stream

func (c *Client) Stream(ctx context.Context, req *Request) (Stream, error)

Stream runs a request, returning events as the model produces them.

Only the initial handshake is retried. Once bytes are flowing, a mid-stream failure is surfaced through Stream.Err rather than retried, because replaying a partially consumed response would duplicate output the caller has already seen.

The returned Stream is bound to ctx and must be closed.

Example

Streaming is a pull iterator, so it composes with defer and with an early return the way a Go programmer expects.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	client := skyl.New(fakeProvider{})

	stream, err := client.Stream(context.Background(), &skyl.Request{
		Model:     "example-model",
		MaxTokens: 64,
		Messages:  []skyl.Message{skyl.UserText("Explain Go channels.")},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer stream.Close()

	for stream.Next() {
		if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
			fmt.Print(ev.Text)
		}
	}
	fmt.Println()

	// Always check Err after the loop: Next returning false means either the
	// stream finished or it failed, and only Err tells them apart.
	if err := stream.Err(); err != nil {
		log.Fatal(err)
	}
}
Output:
Go channels synchronise.

type Effort

type Effort string

Effort hints how much reasoning the model should spend.

Providers interpret it differently and some ignore it. It is a hint, not a contract.

const (
	EffortLow    Effort = "low"
	EffortMedium Effort = "medium"
	EffortHigh   Effort = "high"
	EffortMax    Effort = "max"
)

The effort levels skyl understands, in increasing order.

type Error

type Error struct {
	// Provider is the adapter that produced the failure.
	Provider string

	// StatusCode is the HTTP status, or 0 for transport-level failures.
	StatusCode int

	// Message is the provider's explanation, when it gave one.
	Message string

	// Kind is the sentinel this failure classifies as. Unwrap returns it.
	Kind error

	// RetryAfter is how long the provider asked us to wait. Zero when it did
	// not say.
	RetryAfter time.Duration

	// Body is the raw error payload, truncated. Useful when a provider
	// reports something skyl does not model.
	Body string
	// contains filtered or unexported fields
}

Error is a provider failure with enough context to act on.

It wraps one of the sentinel errors above, so errors.Is works through it, and errors.As recovers the detail:

var e *skyl.Error
if errors.As(err, &e) {
	log.Printf("%s returned %d", e.Provider, e.StatusCode)
}

An Error never contains credentials. See docs/rules.md §7.2.

Example

Errors are classified, so branch on the sentinel rather than on message text — providers reword their messages and string matching breaks silently when they do.

package main

import (
	"errors"
	"fmt"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

func main() {
	err := skyl.NewError("openai", 429, skyl.ErrRateLimit, "slow down", nil)

	if errors.Is(err, skyl.ErrRateLimit) {
		fmt.Println("rate limited; skyl already retried with backoff")
	}

	var e *skyl.Error
	if errors.As(err, &e) {
		fmt.Printf("%s returned %d\n", e.Provider, e.StatusCode)
		fmt.Println("retryable:", e.Retryable())
	}
}
Output:
rate limited; skyl already retried with backoff
openai returned 429
retryable: true

func NewError

func NewError(provider string, status int, kind error, message string, body []byte) *Error

NewError builds a classified provider error.

Adapters use it so that every provider failure reaches callers in the same shape.

func (*Error) Cause

func (e *Error) Cause() error

Cause returns the underlying error, or nil when there was none.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

func (*Error) Retryable

func (e *Error) Retryable() bool

Retryable reports whether retrying could plausibly succeed.

Rate limits, server errors, and transport failures are retryable. Authentication failures, malformed requests, missing models, and refusals are not — retrying those burns quota to receive the same answer.

func (*Error) Unwrap

func (e *Error) Unwrap() []error

Unwrap returns the errors this one wraps: the sentinel it classifies as, and the underlying cause when there was one.

Returning both means errors.Is matches a skyl sentinel and a wrapped standard error alike — errors.Is(err, ErrRateLimit) and errors.Is(err, context.DeadlineExceeded) both work through the same value.

func (*Error) WithCause

func (e *Error) WithCause(err error) *Error

WithCause returns a copy of e carrying err as its underlying cause.

Adapters use it for transport failures so that a caller can still write errors.Is(err, context.DeadlineExceeded) — flattening the cause into a message string loses exactly the information that tells a timeout apart from a DNS failure or a rejected certificate.

type EventType

type EventType string

EventType identifies what a StreamEvent carries.

const (
	// EventTextDelta carries the next fragment of assistant text in
	// [StreamEvent.Text]. This is the event most callers care about.
	EventTextDelta EventType = "text_delta"

	// EventThinkingDelta carries a fragment of the model's reasoning, when
	// the provider discloses it. Many models never emit this, and several
	// return reasoning only as an opaque summary.
	EventThinkingDelta EventType = "thinking_delta"

	// EventToolCall reports a completed tool call in [StreamEvent.ToolCall].
	// skyl buffers partial tool arguments and emits this once, when the
	// call's JSON is whole — a half-parsed tool call is not actionable.
	EventToolCall EventType = "tool_call"

	// EventDone is the final event of a successful stream. It carries
	// [StreamEvent.Usage] and [StreamEvent.StopReason] when the provider
	// reports them.
	EventDone EventType = "done"
)

The stream event types skyl emits. Adapters map provider events onto these and drop provider events that carry no information a caller can act on.

type Hook

type Hook func(ctx context.Context, ev HookEvent)

Hook observes attempts. Register one with WithHook.

Hooks run synchronously on the calling goroutine, so a slow hook slows the request. Do metrics and logging; do not do I/O without a timeout.

Two things to know about OpStreamEnd in particular. It fires from whichever of the stream's terminal event or its Close comes first, so on the Close path the hook runs inside the caller's defer and its latency lands there. And the ctx it receives is the one the stream was opened with, which is frequently already cancelled by then — a client hanging up is the ordinary reason a stream is abandoned. A hook that needs to record that event must not depend on that ctx being live.

type HookEvent

type HookEvent struct {
	// Provider and Model identify what was called. Model is the model that was
	// *asked for*; see ResponseModel for the one that answered.
	Provider string
	Model    string

	// Operation is one of [OpComplete], [OpStream], [OpStreamEnd], or
	// [OpModels].
	Operation string

	// Attempt is the zero-based retry attempt this event reports.
	Attempt int

	// Duration is how long the attempt took. For [OpStream] that is the
	// handshake alone, because the stream outlives the call; for
	// [OpStreamEnd] it is the whole stream, handshake included.
	Duration time.Duration

	// Err is the attempt's error, or nil on success. A non-nil Err on a
	// non-final attempt was retried.
	Err error

	// Usage is token consumption. Populated for successful [OpComplete] calls,
	// and for [OpStreamEnd] when the stream ran to completion.
	Usage Usage

	// ResponseID is the provider's identifier for the response, when it gave
	// one. It is what a provider's support team will ask for.
	ResponseID string

	// ResponseModel is the model that actually served the request, read from
	// the response rather than echoed from the request — providers can and do
	// serve a different model than the one asked for.
	ResponseModel string

	// StopReason explains why generation ended, for [OpComplete] and a
	// completed [OpStreamEnd].
	StopReason StopReason

	// Completed reports whether a stream ran to its terminal event. It is
	// meaningful only for [OpStreamEnd].
	//
	// False means the caller closed the stream early — an abandoned request,
	// a client hang-up, an error mid-flight. Those tokens were still generated
	// and still billed, so the event fires anyway; Usage is simply whatever
	// arrived before the stream was dropped, usually nothing.
	Completed bool

	// Request is the request that produced this event.
	//
	// It is supplied so a hook can report sampling parameters — the
	// OpenTelemetry GenAI conventions ask for temperature, top_p and
	// max_tokens — without this struct growing a field per parameter.
	//
	// It carries the prompt. Anything a hook does with it is a decision about
	// user data: logging it verbatim ships conversation content to wherever
	// the logs go. Treat it as read-only; skyl reuses it across retries.
	Request *Request
}

HookEvent describes one completed attempt against a provider.

type Image

type Image struct {
	// MediaType is the IANA media type, for example "image/png". Required
	// when Data is set.
	MediaType string

	// Data is the raw (not base64-encoded) image content.
	Data []byte

	// URL references a remotely hosted image.
	URL string
}

Image is an image supplied to the model.

Provide exactly one of Data or URL. Not every provider accepts URLs, and not every model accepts images at all; an adapter that cannot represent an image returns ErrUnsupported rather than dropping it silently.

type Message

type Message struct {
	Role  Role
	Parts []Part
}

Message is one turn in a conversation: a role and its ordered content.

func AssistantText

func AssistantText(text string) Message

AssistantText returns an assistant message containing a single run of text.

Use it to replay prior turns; skyl does not support prefilling an assistant turn to steer the next response, because several current models reject it.

func ToolErrorMessage

func ToolErrorMessage(callID, content string) Message

ToolErrorMessage is ToolResultMessage for a tool that failed.

func ToolResultMessage

func ToolResultMessage(callID, content string) Message

ToolResultMessage returns a tool message answering the call identified by callID.

Append it after the assistant message that requested the call. When several tools were called in one turn, append one message per call before the next Client.Complete.

func UserImage

func UserImage(mediaType string, data []byte, caption string) Message

UserImage returns a user message carrying an image and an optional caption.

func UserText

func UserText(text string) Message

UserText returns a user message containing a single run of text.

func (Message) Text

func (m Message) Text() string

Text returns every Text part concatenated, and ignores other parts.

It is a convenience for the common case where a caller wants the model's prose. Use Message.Parts directly when tool calls or images matter.

func (Message) ToolCalls

func (m Message) ToolCalls() []ToolCall

ToolCalls returns every ToolCall part, in order.

type ModelInfo

type ModelInfo struct {
	// ID is the identifier to put in [Request.Model].
	ID string

	// Provider is the adapter that offers it.
	Provider string

	// DisplayName is a human-readable name, when the provider supplies one.
	DisplayName string

	// ContextWindow is the maximum input size in tokens.
	ContextWindow int

	// MaxOutputTokens is the maximum response size in tokens.
	MaxOutputTokens int

	// Raw is the provider's untouched entry for this model.
	Raw json.RawMessage
}

ModelInfo describes a model a provider offers.

It is what Provider.Models returns. Fields beyond ID are best-effort: providers differ in what their model endpoints disclose, and zero means "not reported".

type Option

type Option func(*Client)

Option configures a Client. Options are applied in order.

func WithHook

func WithHook(h Hook) Option

WithHook registers an observer for completed attempts. Hooks accumulate; calling it twice registers both.

Example

Hooks observe every attempt, including retried ones. They run on the calling goroutine, so keep them cheap.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	client := skyl.New(
		fakeProvider{text: "Paris"},
		skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
			fmt.Printf("%s %s attempt=%d err=%v\n",
				ev.Provider, ev.Operation, ev.Attempt, ev.Err)
		}),
	)

	if _, err := client.Complete(context.Background(), &skyl.Request{
		Model:     "example-model",
		MaxTokens: 64,
		Messages:  []skyl.Message{skyl.UserText("hello")},
	}); err != nil {
		log.Fatal(err)
	}
}
Output:
example complete attempt=0 err=<nil>

func WithMaxRetries

func WithMaxRetries(n int) Option

WithMaxRetries sets how many times a retryable failure is retried.

Zero disables retries. Negative values are ignored. The default is 3.

func WithRetryAfterCap

func WithRetryAfterCap(d time.Duration) Option

WithRetryAfterCap bounds how long a provider's own Retry-After hint may delay a retry.

It is separate from WithRetryDelay's max, which caps only skyl's computed backoff: a provider asking for 60 seconds is a normal rate-limit window and should be honoured, while a provider asking for an hour should not silently wedge the caller. Non-positive values are ignored. The default is 5 minutes.

func WithRetryDelay

func WithRetryDelay(base, max time.Duration) Option

WithRetryDelay sets the backoff bounds.

The delay for attempt n is sampled uniformly from [0, min(base*2^n, max)]. Non-positive values are ignored. Defaults are 500ms and 30s.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds a single attempt.

It applies per attempt, not to the whole retry sequence — bound that with the context you pass. Non-positive values disable the per-attempt timeout, leaving only the caller's context. The default is 10 minutes.

type Part

type Part interface {
	// contains filtered or unexported methods
}

Part is one element of a message's content.

The interface is deliberately closed — it has an unexported method, so only this package can implement it. An open interface would let callers construct parts that no adapter knows how to render, turning a compile-time error into a runtime one.

The implementations are Text, Image, ToolCall, and ToolResult.

type Provider

type Provider interface {
	// Name returns the adapter's short identifier, for example "anthropic".
	// It appears in errors, responses, and hook events.
	Name() string

	// Complete runs a request to completion.
	//
	// It must honour ctx, populate [Response.Raw], and set
	// [Response.Provider] and [Response.Model] from the actual response.
	Complete(ctx context.Context, req *Request) (*Response, error)

	// Stream runs a request, delivering incremental events.
	//
	// The returned [Stream] is bound to ctx: cancelling ctx terminates it.
	// Callers must Close it.
	Stream(ctx context.Context, req *Request) (Stream, error)

	// Models lists what this provider currently offers.
	//
	// It queries the provider live rather than returning a compiled-in list,
	// so the answer is never stale. Providers that expose no such endpoint
	// return [ErrUnsupported].
	Models(ctx context.Context) ([]ModelInfo, error)
}

Provider is the seam between skyl and a model vendor.

It is deliberately four methods. Everything cross-cutting — retry, backoff, timeouts, validation, hooks — lives in Client, which wraps a Provider, so that behaviour is written and tested once instead of once per vendor.

The interface is small enough to implement outside this repository. An adapter in your own module is a first-class citizen: pass it to New and it inherits retry, hooks, and the gateway with no changes to skyl.

Implementations must be safe for concurrent use by multiple goroutines.

type Request

type Request struct {
	// Model is the provider's model identifier, passed through untouched.
	//
	// skyl never validates this against a list of known models, so a model
	// released after your skyl build works immediately. A typo therefore
	// surfaces as the provider's own not-found error rather than a local one.
	Model string

	// System is the system prompt. Adapters place it where the provider
	// expects — a top-level field, a leading message, or systemInstruction.
	System string

	// Messages is the conversation so far. It must not be empty.
	//
	// skyl does not police the ordering of roles: providers disagree about
	// what is legal — a leading assistant turn, two user turns in a row — and
	// rejecting a shape one vendor accepts would be skyl deciding something it
	// has no business deciding. An ordering a provider dislikes comes back as
	// that provider's own error.
	Messages []Message

	// MaxTokens caps the response length. Zero means the provider's default,
	// which for some providers is an error — set it explicitly.
	MaxTokens int

	// Temperature and TopP are sampling controls, nil for the provider
	// default.
	//
	// A non-nil value is always sent. Several current reasoning models reject
	// these outright, and skyl does not second-guess that: silently dropping a
	// field you set would be worse than the provider's own error, because you
	// would have no way to tell it had happened. Leave them nil unless you
	// mean them.
	Temperature *float64
	TopP        *float64

	// Stop are sequences that end generation.
	Stop []string

	// Tools the model may call.
	Tools []Tool

	// ToolChoice constrains tool use. Nil means [ToolChoiceAuto].
	ToolChoice *ToolChoice

	// Thinking requests reasoning. Nil means the provider's default.
	Thinking *Thinking

	// ResponseFormat constrains the reply to JSON matching a schema. Nil means
	// unconstrained prose.
	ResponseFormat *ResponseFormat

	// ProviderOptions is an escape hatch: arbitrary vendor-specific fields
	// merged into the outbound payload, overriding anything skyl set.
	//
	// Use it to reach a feature skyl does not model. skyl does not validate
	// the contents — that is the point.
	ProviderOptions map[string]any
}

Request is a provider-agnostic model call.

The same Request can be sent to any provider. Fields a provider does not support are ignored rather than rejected, except where ignoring them would silently lose data — see ErrUnsupported.

Example (ProviderOptions)

ProviderOptions reaches a vendor feature skyl does not model. Values here override anything skyl set, which is the point of an escape hatch.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	req := &skyl.Request{
		Model:     "example-model",
		MaxTokens: 64,
		Messages:  []skyl.Message{skyl.UserText("hello")},
		ProviderOptions: map[string]any{
			"top_k": 40,
		},
	}

	if _, err := skyl.New(fakeProvider{text: "hi"}).Complete(context.Background(), req); err != nil {
		log.Fatal(err)
	}
	fmt.Println("sent with top_k")
}
Output:
sent with top_k
Example (ResponseFormat)

A schema constrains the reply to JSON you can unmarshal directly.

The document arrives in the ordinary text channel, so Text() is the JSON and the caller decodes it. skyl does not validate the reply against the schema it sent — see ADR-0008 — so a type mismatch is your json.Unmarshal error, which is where you want it.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	req := &skyl.Request{
		Model:     "example-model",
		MaxTokens: 256,
		Messages:  []skyl.Message{skyl.UserText("Who wrote the Go blog post on errors?")},
		ResponseFormat: &skyl.ResponseFormat{
			Name: "author",
			Schema: map[string]any{
				"type": "object",
				"properties": map[string]any{
					"name": map[string]any{"type": "string"},
					"year": map[string]any{"type": "integer"},
				},
				// OpenAI's strict mode requires every property to be listed
				// here and additionalProperties to be false. Gemini accepts an
				// OpenAPI subset instead — test a schema on the providers you
				// actually use, because skyl does not translate between them.
				"required":             []string{"name", "year"},
				"additionalProperties": false,
			},
		},
	}

	resp, err := skyl.New(fakeProvider{text: `{"name":"Rob Pike","year":2015}`}).
		Complete(context.Background(), req)
	if err != nil {
		log.Fatal(err)
	}

	var author struct {
		Name string `json:"name"`
		Year int    `json:"year"`
	}
	if err := json.Unmarshal([]byte(resp.Text()), &author); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s, %d\n", author.Name, author.Year)
}
Output:
Rob Pike, 2015

func (*Request) Validate

func (r *Request) Validate() error

Validate reports whether the request is well-formed.

Client calls it before dispatching, so a malformed request fails locally rather than costing a round trip. Adapters may impose further requirements.

Example

Validation happens locally, so a malformed request fails without costing a round trip.

package main

import (
	"errors"
	"fmt"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

func main() {
	err := (&skyl.Request{Model: "", Messages: nil}).Validate()

	fmt.Println(errors.Is(err, skyl.ErrBadRequest))
	fmt.Println(err)
}
Output:
true
skyl: invalid request: model is required

type Response

type Response struct {
	// ID is the provider's identifier for this response, when it supplies one.
	ID string

	// Provider is the adapter that produced this response, for example
	// "anthropic".
	Provider string

	// Model is the model that actually served the request, read from the
	// response rather than echoed from the request — providers can and do
	// serve a different model than the one asked for.
	Model string

	// Message is the assistant's turn. Append it to your conversation before
	// sending tool results.
	Message Message

	// StopReason explains why generation ended.
	StopReason StopReason

	// Usage reports token consumption.
	Usage Usage

	// Raw is the provider's untouched response body.
	//
	// It is always populated. Use it to read anything skyl does not model, so
	// that skyl's abstraction is never the reason you cannot ship.
	Raw json.RawMessage
}

Response is a provider-agnostic model reply.

func CollectStream

func CollectStream(s Stream, provider, model string) (*Response, error)

CollectStream drains a stream into a single Response.

It is a convenience for callers who want streaming's early-first-byte behaviour without handling events — a progress spinner, say, or a timeout guard on a long generation. It always closes the stream.

The returned Response has no Raw payload: it is assembled from events, not from one provider body.

Example

CollectStream drains a stream into one Response, for callers who want streaming's early first byte without handling events themselves.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	client := skyl.New(fakeProvider{})

	stream, err := client.Stream(context.Background(), &skyl.Request{
		Model:     "example-model",
		MaxTokens: 64,
		Messages:  []skyl.Message{skyl.UserText("Explain Go channels.")},
	})
	if err != nil {
		log.Fatal(err)
	}

	resp, err := skyl.CollectStream(stream, "example", "example-model")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(resp.Text())
	fmt.Println(resp.StopReason)
}
Output:
Go channels synchronise.
end_turn

func (*Response) Text

func (r *Response) Text() string

Text returns the response's text content.

It is shorthand for Response.Message.Text().

func (*Response) ToolCalls

func (r *Response) ToolCalls() []ToolCall

ToolCalls returns the tool calls the model requested, in order.

It returns nil when the model asked for none, so a plain range is safe.

Example

A tool call comes back on the response; run it and send the result as the next message.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

// fakeProvider is a stand-in for an adapter, so the examples below are
// deterministic and offline. A real program passes anthropic.New(key),
// openai.New(key), and so on — the interface is the same either way, which is
// the point of the seam.
type fakeProvider struct {
	text  string
	calls []skyl.ToolCall
}

func (fakeProvider) Name() string { return "example" }

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	parts := []skyl.Part{skyl.Text{Text: p.text}}
	stop := skyl.StopEndTurn
	for _, c := range p.calls {
		parts = append(parts, c)
		stop = skyl.StopToolUse
	}
	return &skyl.Response{
		Provider:   "example",
		Model:      "example-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: parts},
		StopReason: stop,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (p fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return &fakeStream{words: []string{"Go ", "channels ", "synchronise."}}, nil
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return []skyl.ModelInfo{{ID: "example-model", Provider: "example"}}, nil
}

type fakeStream struct {
	words []string
	i     int
	ev    skyl.StreamEvent
	done  bool
}

func (s *fakeStream) Next() bool {
	if s.i < len(s.words) {
		s.ev = skyl.StreamEvent{Type: skyl.EventTextDelta, Text: s.words[s.i]}
		s.i++
		return true
	}
	if !s.done {
		s.done = true
		s.ev = skyl.StreamEvent{
			Type:       skyl.EventDone,
			StopReason: skyl.StopEndTurn,
			Usage:      &skyl.Usage{InputTokens: 5, OutputTokens: 3},
		}
		return true
	}
	return false
}

func (s *fakeStream) Event() skyl.StreamEvent { return s.ev }
func (s *fakeStream) Err() error              { return nil }
func (s *fakeStream) Close() error            { return nil }

func main() {
	client := skyl.New(fakeProvider{
		text: "",
		calls: []skyl.ToolCall{{
			ID: "call_1", Name: "get_weather",
			Arguments: json.RawMessage(`{"city":"Kampala"}`),
		}},
	})

	resp, err := client.Complete(context.Background(), &skyl.Request{
		Model:     "example-model",
		MaxTokens: 64,
		Tools:     []skyl.Tool{{Name: "get_weather", Description: "Look up the weather."}},
		Messages:  []skyl.Message{skyl.UserText("Weather in Kampala?")},
	})
	if err != nil {
		log.Fatal(err)
	}

	for _, call := range resp.ToolCalls() {
		fmt.Printf("%s(%s)\n", call.Name, call.Arguments)

		// Append the assistant's turn and the result, then call again.
		_ = []skyl.Message{
			skyl.UserText("Weather in Kampala?"),
			{Role: skyl.RoleAssistant, Parts: []skyl.Part{call}},
			skyl.ToolResultMessage(call.ID, "22C and sunny"),
		}
	}
	fmt.Println(resp.StopReason)
}
Output:
get_weather({"city":"Kampala"})
tool_use

type ResponseFormat

type ResponseFormat struct {
	// Schema is the JSON Schema the reply must satisfy. It is required, and
	// it is sent verbatim.
	//
	// Portability is the sharp edge. OpenAI's strict mode requires
	// "additionalProperties": false and every property listed in "required";
	// Gemini accepts an OpenAPI 3.0 subset rather than JSON Schema, with no
	// $ref. A schema one provider accepts may come back as another's 400 —
	// which is skyl declining to guess at what your schema means, not skyl
	// failing to try.
	Schema map[string]any

	// Name identifies the schema. OpenAI requires one and the other providers
	// ignore it; empty means skyl supplies a placeholder rather than failing.
	Name string
}

ResponseFormat constrains the reply to JSON matching a schema.

Every provider skyl targets supports this, but they do not agree on the schema dialect, and skyl does not translate between them — see ADR-0008. Test a schema against the providers you actually use.

The reply arrives as ordinary assistant text, so Response.Text returns the JSON document and the caller unmarshals it:

resp, err := client.Complete(ctx, req)
if err != nil {
	return err
}
var out MyType
if err := json.Unmarshal([]byte(resp.Text()), &out); err != nil {
	return err
}

skyl does not validate the reply against the schema it sent, for the same reason it does not validate ToolCall.Arguments: the schema is yours, the target type is yours, and a partial check invites trust it has not earned.

type Role

type Role string

Role identifies who produced a Message.

skyl deliberately has no system role: system prompts live on Request.System because providers place them differently — a top-level parameter for Anthropic, a message for OpenAI, systemInstruction for Gemini.

const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

The roles skyl understands.

func (Role) Valid

func (r Role) Valid() bool

Valid reports whether r is a role skyl understands.

type StopReason

type StopReason string

StopReason explains why the model stopped generating.

const (
	// StopEndTurn means the model finished naturally.
	StopEndTurn StopReason = "end_turn"

	// StopMaxTokens means the output hit [Request.MaxTokens]. The response is
	// truncated — treat it as incomplete.
	StopMaxTokens StopReason = "max_tokens"

	// StopToolUse means the model wants a tool run. Execute the calls and
	// send the results back.
	StopToolUse StopReason = "tool_use"

	// StopStopSequence means a sequence from [Request.Stop] was produced.
	StopStopSequence StopReason = "stop_sequence"

	// StopRefusal means the model or its safety classifiers declined. The
	// content may be empty or partial; do not retry the same request.
	StopRefusal StopReason = "refusal"

	// StopUnknown means the provider reported something skyl does not model.
	// Read [Response.Raw] for the original value.
	StopUnknown StopReason = "unknown"
)

The stop reasons skyl understands. Adapters map provider-specific values onto these and fall back to StopUnknown rather than inventing a new one.

type Stream

type Stream interface {
	// Next advances to the next event, reporting whether one is available.
	// It returns false at end of stream and on error.
	Next() bool

	// Event returns the event Next just advanced to. It is only valid after
	// Next returns true.
	Event() StreamEvent

	// Err returns the error that stopped the stream, or nil if it ended
	// normally. Check it after Next returns false.
	Err() error

	// Close releases the stream's resources. It is safe to call more than
	// once, and safe to call before the stream is exhausted.
	Close() error
}

Stream delivers a response incrementally.

It is a pull iterator rather than a channel, because that composes with defer and context cancellation the way Go programmers expect — a channel would need a second channel for errors and is easy to leak on early return.

The usual shape:

stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()

for stream.Next() {
	if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
		fmt.Print(ev.Text)
	}
}
return stream.Err()

Always check Stream.Err after the loop: Next returning false means either the stream finished or it failed, and only Err distinguishes them.

Abandoning a stream early is safe — Close releases the connection, and the reader is bound to the request context — but Close is what makes the release prompt rather than eventual.

A Stream is NOT safe for concurrent use. One stream, one consuming goroutine.

type StreamEvent

type StreamEvent struct {
	Type EventType

	// Text is the fragment, for [EventTextDelta] and [EventThinkingDelta].
	Text string

	// ToolCall is the completed call, for [EventToolCall].
	ToolCall *ToolCall

	// Usage is token consumption, for [EventDone].
	Usage *Usage

	// StopReason is why generation ended, for [EventDone].
	StopReason StopReason

	// Raw is the provider's untouched event payload, when one exists.
	Raw json.RawMessage
}

StreamEvent is one incremental update from a streaming response.

Which fields are meaningful depends on StreamEvent.Type; the rest are zero.

type Text

type Text struct {
	Text string
}

Text is a run of plain text.

type Thinking

type Thinking struct {
	// Enabled requests reasoning. A zero Thinking value is not the same as a
	// nil *Thinking: nil means "provider default", &Thinking{} means
	// "explicitly off".
	Enabled bool

	// Effort hints at depth. Empty means the provider's default.
	Effort Effort
}

Thinking requests that the model reason before answering.

Support varies: some models always think, some never do, and some accept an effort hint. Adapters map this onto whatever the provider offers and ignore it where the provider has no equivalent.

type Tool

type Tool struct {
	// Name is the identifier the model uses to call the tool.
	Name string

	// Description tells the model when to use the tool. Be prescriptive about
	// *when* to call it, not only what it does — trigger conditions measurably
	// improve tool selection.
	Description string

	// Parameters is a JSON Schema object describing the tool's input.
	Parameters map[string]any
}

Tool describes a function the model may ask to invoke.

type ToolCall

type ToolCall struct {
	// ID correlates this call with its [ToolResult]. Providers generate it.
	ID string

	// Name is the tool the model wants to run.
	Name string

	// Arguments is the JSON object the model produced for the tool's
	// parameters. It is raw JSON because skyl cannot know the tool's schema.
	Arguments json.RawMessage
}

ToolCall is the model's request to invoke a tool.

type ToolChoice

type ToolChoice struct {
	Mode ToolChoiceMode

	// Name is the tool to force. Required when Mode is [ToolChoiceSpecific],
	// ignored otherwise.
	Name string
}

ToolChoice constrains the model's use of tools.

type ToolChoiceMode

type ToolChoiceMode string

ToolChoiceMode controls whether and how the model may call tools.

const (
	// ToolChoiceAuto lets the model decide. This is the default.
	ToolChoiceAuto ToolChoiceMode = "auto"

	// ToolChoiceNone forbids tool calls for this request.
	ToolChoiceNone ToolChoiceMode = "none"

	// ToolChoiceRequired forces at least one tool call.
	ToolChoiceRequired ToolChoiceMode = "required"

	// ToolChoiceSpecific forces a named tool. Set [ToolChoice.Name].
	ToolChoiceSpecific ToolChoiceMode = "tool"
)

The tool-choice modes skyl understands.

type ToolResult

type ToolResult struct {
	// CallID must match the [ToolCall.ID] this result answers.
	CallID string

	// Content is the tool's output, rendered as text.
	Content string

	// IsError reports that the tool failed. Providers surface this to the
	// model so it can adapt rather than assume success.
	IsError bool
}

ToolResult carries the outcome of a tool invocation back to the model.

type Usage

type Usage struct {
	// InputTokens is every token of input, including any served from or
	// written to a cache.
	InputTokens int

	// OutputTokens is every token the model generated.
	OutputTokens int

	// CacheReadTokens were served from a prompt cache, usually at a large
	// discount. They are part of InputTokens, not additional to it.
	CacheReadTokens int

	// CacheWriteTokens were written to a prompt cache, usually at a premium.
	// They are part of InputTokens, not additional to it.
	CacheWriteTokens int
}

Usage reports token consumption for a request.

Providers do not all report every field; zero means "not reported", not "zero tokens".

Inclusion semantics

Providers disagree about whether cached tokens are part of the input count. OpenAI and Gemini report a cache figure that is a subset of the prompt count; Anthropic reports cache figures that are disjoint from its input count. Summing the fields blindly therefore over-reports on some providers and not on others, which is exactly the kind of difference a caller should not have to know about.

skyl normalises to one rule, and every adapter obeys it:

  • InputTokens is the total input, cached tokens included.
  • CacheReadTokens and CacheWriteTokens are a breakdown OF InputTokens, not an addition to it.

So InputTokens is what to bill, and CacheReadTokens is how much of it was discounted.

func (Usage) Add

func (u Usage) Add(other Usage) Usage

Add returns the sum of two usage records, for accumulating across a multi-turn exchange.

Example

Usage accounting: cache figures break InputTokens down rather than adding to it, so a total is input plus output.

package main

import (
	"fmt"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
)

func main() {
	first := skyl.Usage{InputTokens: 1000, OutputTokens: 50, CacheReadTokens: 900}
	second := skyl.Usage{InputTokens: 1200, OutputTokens: 80, CacheReadTokens: 1100}

	total := first.Add(second)
	fmt.Println(total.InputTokens, total.OutputTokens, total.TotalTokens())
	fmt.Println("served from cache:", total.CacheReadTokens)
}
Output:
2200 130 2330
served from cache: 2000

func (Usage) TotalTokens

func (u Usage) TotalTokens() int

TotalTokens returns every token the provider reported.

Cache figures are a breakdown of InputTokens, so they are deliberately not added again — see the inclusion semantics on Usage.

Directories

Path Synopsis
cmd
skyl-sandbox command
Command skyl-sandbox serves every provider's wire protocol locally, with no credentials and no cost.
Command skyl-sandbox serves every provider's wire protocol locally, with no credentials and no cost.
gateway module
internal
cassette
Package cassette records real provider HTTP exchanges and replays them.
Package cassette records real provider HTTP exchanges and replays them.
httpx
Package httpx holds HTTP helpers shared by skyl's provider adapters.
Package httpx holds HTTP helpers shared by skyl's provider adapters.
oai
Package oai implements the OpenAI chat-completions wire format.
Package oai implements the OpenAI chat-completions wire format.
providertest
Package providertest is a contract suite every skyl adapter must pass.
Package providertest is a contract suite every skyl adapter must pass.
sandbox
Package sandbox serves each provider's wire protocol locally, with no credentials and no cost.
Package sandbox serves each provider's wire protocol locally, with no credentials and no cost.
sse
Package sse reads Server-Sent Events streams.
Package sse reads Server-Sent Events streams.
testutil
Package testutil holds helpers shared by skyl's tests.
Package testutil holds helpers shared by skyl's tests.
otel module
provider
gemini
Package gemini adapts the Google Gemini API.
Package gemini adapts the Google Gemini API.
openai
Package openai adapts the OpenAI API.
Package openai adapts the OpenAI API.
openaicompat
Package openaicompat adapts any endpoint that speaks OpenAI's chat-completions wire format.
Package openaicompat adapts any endpoint that speaks OpenAI's chat-completions wire format.
anthropic module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL