You can work through every page of this documentation without a credential.
skyl ships skyl-sandbox, a local server that speaks Anthropic's Messages API,
OpenAI's chat-completions API, Gemini's generateContent, and the
OpenAI-compatible shape — all at once, with no network and no cost.
You will learn
- How to start the sandbox and point an adapter at it
- Which models each mount serves, and why the list is short on purpose
- How to force a 429, a truncated stream, or a mid-stream error on demand
- What the sandbox proves — and the much more important thing it does not
Starting it#
go run ./cmd/skyl-sandboxskyl sandbox listening on http://127.0.0.1:8099
api key sandbox-key
anthropic http://127.0.0.1:8099/anthropic
openai http://127.0.0.1:8099/openai/v1
gemini http://127.0.0.1:8099/gemini/v1beta
openaicompat http://127.0.0.1:8099/compat/v1It binds to loopback by default. That is a deliberate choice, not a default nobody thought about — the sandbox authenticates nothing meaningfully, so it must not be reachable from a network you do not control.
Pointing an adapter at it#
Every adapter takes a WithBaseURL option. Set it to the matching mount and
the adapter behaves exactly as it would against the real host.
p := anthropic.New("sandbox-key",
anthropic.WithBaseURL("http://127.0.0.1:8099/anthropic"))
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "claude-opus-5",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})p := anthropic.New("sandbox-key",
anthropic.WithBaseURL("http://127.0.0.1:8099/anthropic"))
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "claude-opus-5",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})p := openai.New("sandbox-key",
openai.WithBaseURL("http://127.0.0.1:8099/openai/v1"))
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "gpt-5.6",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})p := openai.New("sandbox-key",
openai.WithBaseURL("http://127.0.0.1:8099/openai/v1"))
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "gpt-5.6",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})p := gemini.New("sandbox-key",
gemini.WithBaseURL("http://127.0.0.1:8099/gemini/v1beta"))
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "gemini-3.6-flash",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})p := gemini.New("sandbox-key",
gemini.WithBaseURL("http://127.0.0.1:8099/gemini/v1beta"))
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "gemini-3.6-flash",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})// The compat mount also accepts no credential at all, because that is how
// Ollama, LM Studio and llama.cpp behave.
p := openaicompat.New(
openaicompat.WithBaseURL("http://127.0.0.1:8099/compat/v1"),
openaicompat.WithName("sandbox"),
)
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "gpt-5.6",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})// The compat mount also accepts no credential at all, because that is how
// Ollama, LM Studio and llama.cpp behave.
p := openaicompat.New(
openaicompat.WithBaseURL("http://127.0.0.1:8099/compat/v1"),
openaicompat.WithName("sandbox"),
)
resp, err := skyl.New(p).Complete(ctx, &skyl.Request{
Model: "gpt-5.6",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("What is the capital of France?")},
})Each mount checks the header its real counterpart uses — x-api-key for
Anthropic, Authorization: Bearer for OpenAI, x-goog-api-key for Gemini — so
the adapters' credential handling is genuinely exercised rather than bypassed.
The mounts#
| Mount | Base URL | Auth header | Models |
|---|---|---|---|
anthropic | http://127.0.0.1:8099/anthropic | x-api-key | claude-opus-5, claude-sonnet-5, claude-haiku-4-5 |
openai | http://127.0.0.1:8099/openai/v1 | Authorization: Bearer | gpt-5.6, gpt-5.4-nano |
gemini | http://127.0.0.1:8099/gemini/v1beta | x-goog-api-key | gemini-3.6-flash, gemini-3.6-pro |
openaicompat | http://127.0.0.1:8099/compat/v1 | Authorization: Bearer (or none) | gpt-5.6, gpt-5.4-nano |
The model catalogue is short, and anything outside it gets that provider's own 404. That is deliberate: skyl passes model IDs through unvalidated, so the provider's not-found error is the only thing standing between a typo and an unactionable failure. A sandbox that accepted every string would never exercise that path.
Forcing failures#
This is the part you cannot get from a real provider on demand. Three special model IDs make the sandbox fail in specific ways.
| Model ID | Effect | What it exercises |
|---|---|---|
sandbox-status-<code> | Returns that HTTP status in the provider's own error shape. | Error classification and the retry loop. 429 also carries a Retry-After header, so backoff's preference for the provider's own hint is covered.sandbox-status-429 → errors.Is(err, skyl.ErrRateLimit) |
sandbox-stream-truncate | Ends the stream mid-generation with no terminal event. | Truncation detection. A connection dropped mid-generation reaches EOF with no reader error, so without this check a partial answer looks like a complete one.stream.Err() reports the response is truncated |
sandbox-stream-error | Emits an error frame after the stream has started. | Mid-stream error handling — structurally unreachable via sandbox-status-NNN, since the status is fixed once the SSE header is written.stream.Err() returns a classified provider error |
So you can test your rate-limit handling in a unit test:
_, err := client.Complete(ctx, &skyl.Request{
Model: "sandbox-status-429",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("hi")},
})
if !errors.Is(err, skyl.ErrRateLimit) {
t.Fatalf("expected a rate limit, got %v", err)
}_, err := client.Complete(ctx, &skyl.Request{
Model: "sandbox-status-429",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("hi")},
})
if !errors.Is(err, skyl.ErrRateLimit) {
t.Fatalf("expected a rate limit, got %v", err)
}The 429 response also carries a Retry-After header, so skyl's preference for
the provider's own hint over its computed backoff is covered too.
Deep diveWhy sandbox-status-NNN cannot model a mid-stream failure
Once an SSE response has written its header, the HTTP status is fixed. A stream that dies halfway through is not a 500 — it is a 200 that stops producing frames, or one that emits an error frame in the body.
That is a structurally different failure, and before
sandbox-stream-truncate and sandbox-stream-error existed, every adapter's
mid-stream error handling was unreachable by any test. A connection dropped
mid-generation reaches EOF with no reader error, so all three adapters used to
emit a clean terminal event over a partial answer — a truncated response that
looked complete.
What the sandbox does not prove#
There is also no model here. Replies come from a lookup table and token counts are word counts — enough to prove that usage is parsed and carried, useless for reasoning about cost or quality.
Only the live suite settles the question, and it needs your own key:
export ANTHROPIC_API_KEY=... OPENAI_API_KEY=... GEMINI_API_KEY=...
go test -tags=integration -v -run TestLive ./provider/Absent keys skip cleanly rather than failing, so you can run it with whichever you have. See Validating against real providers.
The three suites#
| Command | Needs | In CI | Proves |
|---|---|---|---|
go test ./... | nothing | Yes | Mapping logic, in process. |
go test -tags=sandbox ./... | nothing | Yes | The full stack over real sockets — chunked SSE, connection reuse, status codes, cancellation landing mid-backoff. |
go test -tags=integration ./... | real API keys, money | No | That the field names are actually right. This is the one only you can run. |
The first two are the ladder CI climbs. The third is the one only you can run, and it is the one that decides whether skyl is ready to depend on.
Recap
go run ./cmd/skyl-sandboxserves all four wire protocols on127.0.0.1:8099.- Point any adapter at a mount with
WithBaseURL; the default key issandbox-key. - Each mount checks the auth header its real counterpart uses, so credential handling is exercised.
sandbox-status-NNN,sandbox-stream-truncateandsandbox-stream-errorforce failures on demand.- The model catalogue is deliberately small so the not-found path stays reachable.
- It proves the stack works over real sockets. It cannot prove the field names are right.
Try out some challenges
Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.
Prove that retries actually happen
Use the sandbox to show that Client retries a 429 rather than failing
immediately — and count the attempts.
Show hint
A hook fires once per attempt, including retried ones. HookEvent.Attempt is
zero-based.
Show solution
var attempts int
client := skyl.New(p,
skyl.WithMaxRetries(3),
skyl.WithRetryDelay(time.Millisecond, 10*time.Millisecond),
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
attempts++
}),
)
_, err := client.Complete(ctx, &skyl.Request{
Model: "sandbox-status-429",
MaxTokens: 16,
Messages: []skyl.Message{skyl.UserText("hi")},
})
fmt.Println(attempts, errors.Is(err, skyl.ErrRateLimit)) // 4 truevar attempts int
client := skyl.New(p,
skyl.WithMaxRetries(3),
skyl.WithRetryDelay(time.Millisecond, 10*time.Millisecond),
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
attempts++
}),
)
_, err := client.Complete(ctx, &skyl.Request{
Model: "sandbox-status-429",
MaxTokens: 16,
Messages: []skyl.Message{skyl.UserText("hi")},
})
fmt.Println(attempts, errors.Is(err, skyl.ErrRateLimit)) // 4 trueFour attempts: the original plus three retries. Shortening the delays keeps the test fast — the defaults are 500ms and 30s, which are right for production and wrong for a test.
Catch a truncated stream
Show that a stream cut short is reported as an error rather than as a short but complete answer.
Show hint
Next() returning false means the stream either finished or failed. Only
Err() distinguishes them — which is exactly the bug this fault model exists to
catch.
Show solution
stream, err := client.Stream(ctx, &skyl.Request{
Model: "sandbox-stream-truncate",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("count to ten")},
})
if err != nil {
log.Fatal(err)
}
defer stream.Close()
var got string
for stream.Next() {
if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
got += ev.Text
}
}
// The text read so far is still delivered — but Err is non-nil, so you know
// not to treat it as the whole answer.
fmt.Println(got)
fmt.Println(stream.Err()) // stream ended without a terminal event; the response is truncatedstream, err := client.Stream(ctx, &skyl.Request{
Model: "sandbox-stream-truncate",
MaxTokens: 64,
Messages: []skyl.Message{skyl.UserText("count to ten")},
})
if err != nil {
log.Fatal(err)
}
defer stream.Close()
var got string
for stream.Next() {
if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
got += ev.Text
}
}
// The text read so far is still delivered — but Err is non-nil, so you know
// not to treat it as the whole answer.
fmt.Println(got)
fmt.Println(stream.Err()) // stream ended without a terminal event; the response is truncatedIf you skip the stream.Err() check, this failure is invisible. That is why
every streaming example on this site ends with it.