Skip to content
skyl

Setup

Editor, credentials, and a development loop that costs nothing.

skyl needs no configuration file, no init call, and no code generation. The setup worth doing is about your environment rather than the library: catching mistakes in the editor, keeping credentials out of your repository, and being able to iterate without spending money.

You will learn

  • The editor tooling that catches skyl mistakes before you run anything
  • Where credentials should live, and where they must not
  • A development loop that costs nothing per iteration
  • How to structure a project that uses more than one provider

A project from scratch#

Terminal
mkdir myapp && cd myapp
go mod init example.com/myapp
go get github.com/BAGOMBEKA-JOB-DEV/skyl

That is the whole setup. skyl has no skyl.Init(), no config struct to fill in, and no global state — a *Client is an ordinary value you construct and pass around.

The three things worth configuring#

Read the pages below in order; each is short.

Structuring a project#

The shape that scales is a small package that owns provider construction, so the rest of your code depends on *skyl.Client and never on a vendor package.

internal/ai/ai.goCompiles
// Package ai owns every decision about which model serves which workload.
package ai

import (
	"fmt"
	"os"
	"time"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
	"github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openai"
	"github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openaicompat"
)

type Clients struct {
	// Fast serves classification and extraction: high volume, low stakes.
	Fast *skyl.Client
	// Smart serves reasoning: low volume, worth the money.
	Smart *skyl.Client
}

func New() (Clients, error) {
	key := os.Getenv("OPENAI_API_KEY")
	if key == "" {
		return Clients{}, fmt.Errorf("ai: OPENAI_API_KEY is not set")
	}

	opts := []skyl.Option{
		skyl.WithMaxRetries(4),
		skyl.WithTimeout(2 * time.Minute),
	}

	return Clients{
		Fast:  skyl.New(openai.New(key), opts...),
		Smart: skyl.New(openai.New(key), opts...),
	}, nil
}

// Local swaps every client onto a local runtime, for development.
func Local() Clients {
	p := openaicompat.New(
		openaicompat.WithBaseURL("http://localhost:11434/v1"),
		openaicompat.WithName("ollama"),
	)
	c := skyl.New(p)
	return Clients{Fast: c, Smart: c}
}
// Package ai owns every decision about which model serves which workload.
package ai

import (
	"fmt"
	"os"
	"time"

	"github.com/BAGOMBEKA-JOB-DEV/skyl"
	"github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openai"
	"github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openaicompat"
)

type Clients struct {
	// Fast serves classification and extraction: high volume, low stakes.
	Fast *skyl.Client
	// Smart serves reasoning: low volume, worth the money.
	Smart *skyl.Client
}

func New() (Clients, error) {
	key := os.Getenv("OPENAI_API_KEY")
	if key == "" {
		return Clients{}, fmt.Errorf("ai: OPENAI_API_KEY is not set")
	}

	opts := []skyl.Option{
		skyl.WithMaxRetries(4),
		skyl.WithTimeout(2 * time.Minute),
	}

	return Clients{
		Fast:  skyl.New(openai.New(key), opts...),
		Smart: skyl.New(openai.New(key), opts...),
	}, nil
}

// Local swaps every client onto a local runtime, for development.
func Local() Clients {
	p := openaicompat.New(
		openaicompat.WithBaseURL("http://localhost:11434/v1"),
		openaicompat.WithName("ollama"),
	)
	c := skyl.New(p)
	return Clients{Fast: c, Smart: c}
}

Two things this buys you. Your handlers take a *skyl.Client and cannot accidentally couple to OpenAI. And Local() makes "develop against a local model" a one-line change rather than a second code path that is always slightly under-tested.

Failing fast on missing configuration#

Because skyl reads no environment itself, a missing key is not an error until the first request — which might be minutes after startup, in front of a user. Check at construction instead, as New above does.

Deep diveWhy skyl does not read the environment for you

A library that reads OPENAI_API_KEY behind your back has made three decisions on your behalf: that the credential lives in the environment, that it is process-global, and that it is read at a time you did not choose.

That breaks the moment you fetch credentials from a secret manager, rotate them without restarting, or run two accounts in one process. Taking the key as an argument costs you one line and keeps all three decisions yours.

The gateway does read the environment — but the gateway is a program, and a program is exactly the layer where that decision belongs.

Testing your integration#

Provider is an interface, so a fake is a struct with four methods and needs no mocking framework:

internal/ai/fake_test.goCompiles
type fakeProvider struct{ text string }

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

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	return &skyl.Response{
		Provider:   "fake",
		Model:      "fake-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: []skyl.Part{skyl.Text{Text: p.text}}},
		StopReason: skyl.StopEndTurn,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return nil, skyl.ErrUnsupported
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return nil, skyl.ErrUnsupported
}
type fakeProvider struct{ text string }

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

func (p fakeProvider) Complete(context.Context, *skyl.Request) (*skyl.Response, error) {
	return &skyl.Response{
		Provider:   "fake",
		Model:      "fake-model",
		Message:    skyl.Message{Role: skyl.RoleAssistant, Parts: []skyl.Part{skyl.Text{Text: p.text}}},
		StopReason: skyl.StopEndTurn,
		Usage:      skyl.Usage{InputTokens: 8, OutputTokens: 3},
		Raw:        json.RawMessage(`{}`),
	}, nil
}

func (fakeProvider) Stream(context.Context, *skyl.Request) (skyl.Stream, error) {
	return nil, skyl.ErrUnsupported
}

func (fakeProvider) Models(context.Context) ([]skyl.ModelInfo, error) {
	return nil, skyl.ErrUnsupported
}

Wrap it in a real skyl.New(...) and your test exercises the actual validation and retry code, not a mock of it. For anything that needs real HTTP — SSE arriving in chunks, cancellation landing mid-backoff — use the sandbox instead.

Recap

  • There is no setup step: go get and construct a client.
  • Own provider construction in one package so your code depends on *skyl.Client.
  • Construct clients once — they are concurrency-safe and pool connections.
  • Validate credentials at startup, because skyl will not notice until the first call.
  • A fake Provider is four methods and needs no mocking library.

Try out some challenges

Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.

Make the local path impossible to forget

Extend the ai package so the choice between real and local is driven by one environment variable, and an unknown value is a startup error rather than a silent default.

Show hint

Return an error for anything you do not recognise. A default: branch that silently picks production is how a developer ends up billing a real account from their laptop.

Show solution
func FromEnv() (Clients, error) {
	switch mode := os.Getenv("AI_MODE"); mode {
	case "", "production":
		return New()
	case "local":
		return Local(), nil
	case "sandbox":
		return Sandbox(), nil
	default:
		return Clients{}, fmt.Errorf("ai: unknown AI_MODE %q", mode)
	}
}
func FromEnv() (Clients, error) {
	switch mode := os.Getenv("AI_MODE"); mode {
	case "", "production":
		return New()
	case "local":
		return Local(), nil
	case "sandbox":
		return Sandbox(), nil
	default:
		return Clients{}, fmt.Errorf("ai: unknown AI_MODE %q", mode)
	}
}

Empty defaults to production because that is the safe failure for a deployed service — but a typo like AI_MODE=locl is rejected loudly instead of quietly billing you.

Edit this page on GitHub