Skip to content
skyl

Adding skyl to an Existing Project

Introducing skyl beside a vendor SDK you already use, without a rewrite.

You probably already have a working integration against one vendor's SDK. You do not have to replace it in one commit — skyl can sit beside it, take one call path, and prove itself before it takes the rest.

You will learn

  • How to migrate one call path at a time
  • How your existing vendor SDK maps onto Request and Response
  • How to keep a vendor feature that skyl does not model
  • How to verify the migration produced identical requests

Start with one call#

Pick your least critical model call — a summariser, a classifier, something whose output you can eyeball. Convert only that.

Before, using a vendor SDK directly:

resp, err := oaClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
	Model:     "gpt-5.6",
	MaxTokens: openai.Int(512),
	Messages: []openai.ChatCompletionMessageParamUnion{
		openai.SystemMessage("Summarise in one sentence."),
		openai.UserMessage(article),
	},
})
text := resp.Choices[0].Message.Content
resp, err := oaClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
	Model:     "gpt-5.6",
	MaxTokens: openai.Int(512),
	Messages: []openai.ChatCompletionMessageParamUnion{
		openai.SystemMessage("Summarise in one sentence."),
		openai.UserMessage(article),
	},
})
text := resp.Choices[0].Message.Content

After:

goCompiles
resp, err := client.Complete(ctx, &skyl.Request{
	Model:     "gpt-5.6",
	MaxTokens: 512,
	System:    "Summarise in one sentence.",
	Messages:  []skyl.Message{skyl.UserText(article)},
})
text := resp.Text()
resp, err := client.Complete(ctx, &skyl.Request{
	Model:     "gpt-5.6",
	MaxTokens: 512,
	System:    "Summarise in one sentence.",
	Messages:  []skyl.Message{skyl.UserText(article)},
})
text := resp.Text()

Two differences worth noticing. The system prompt moved from a message to a field, because providers place it differently and skyl puts it where each one expects. And there is no Choices[0] — skyl does not model multiple completions, because the overwhelming majority of callers want one and the ones who do not can read Response.Raw.

The mapping#

Vendor conceptskyl
System / developer messageRequest.System
messages / contentsRequest.Messages
content stringskyl.UserText(...)
content blocks / partsMessage.Parts
max_tokens / maxOutputTokensRequest.MaxTokens
tools / functionDeclarationsRequest.Tools
tool_choiceRequest.ToolChoice
finish_reason / stop_reasonResponse.StopReason
usageResponse.Usage
the whole response bodyResponse.Raw

Keeping a feature skyl does not model#

This is the usual reason a migration stalls, and it should not stall it. If you were setting a vendor field skyl has no equivalent for, send it through the escape hatch:

goCompiles
req := &skyl.Request{
	Model:     "gpt-5.6",
	MaxTokens: 512,
	Messages:  []skyl.Message{skyl.UserText(article)},
	ProviderOptions: map[string]any{
		"seed":            7,
		"presence_penalty": 0.4,
	},
}
req := &skyl.Request{
	Model:     "gpt-5.6",
	MaxTokens: 512,
	Messages:  []skyl.Message{skyl.UserText(article)},
	ProviderOptions: map[string]any{
		"seed":            7,
		"presence_penalty": 0.4,
	},
}

Your keys are merged over the payload skyl built, so you win.

And if you were reading a response field skyl does not model:

goCompiles
var full struct {
	SystemFingerprint string `json:"system_fingerprint"`
}
if err := json.Unmarshal(resp.Raw, &full); err != nil {
	return err
}
var full struct {
	SystemFingerprint string `json:"system_fingerprint"`
}
if err := json.Unmarshal(resp.Raw, &full); err != nil {
	return err
}

Response.Raw is always populated, on every adapter, on every call.

Verifying the migration#

The most convincing check is that the bytes on the wire did not change. Point both the old and new code at a local recording proxy, or diff the requests with a custom transport:

goCompiles
// A transport that copies every outbound body to a file, so the pre- and
// post-migration requests can be diffed byte for byte.
type recording struct {
	base http.RoundTripper
	out  io.Writer
}

func (r recording) RoundTrip(req *http.Request) (*http.Response, error) {
	if req.Body != nil {
		body, err := io.ReadAll(req.Body)
		if err != nil {
			return nil, err
		}
		_, _ = r.out.Write(append(body, '\n'))
		req.Body = io.NopCloser(bytes.NewReader(body))
	}
	return r.base.RoundTrip(req)
}
// A transport that copies every outbound body to a file, so the pre- and
// post-migration requests can be diffed byte for byte.
type recording struct {
	base http.RoundTripper
	out  io.Writer
}

func (r recording) RoundTrip(req *http.Request) (*http.Response, error) {
	if req.Body != nil {
		body, err := io.ReadAll(req.Body)
		if err != nil {
			return nil, err
		}
		_, _ = r.out.Write(append(body, '\n'))
		req.Body = io.NopCloser(bytes.NewReader(body))
	}
	return r.base.RoundTrip(req)
}

Wire it in with the provider's HTTP-client option:

p := openai.New(key, openai.WithHTTPClient(&http.Client{
	Transport: recording{base: http.DefaultTransport, out: f},
}))
p := openai.New(key, openai.WithHTTPClient(&http.Client{
	Transport: recording{base: http.DefaultTransport, out: f},
}))
Deep diveWhat you gain, concretely, from the migration

It is worth being specific rather than assuming the abstraction pays for itself.

You delete your retry loop, and get exponential backoff with full jitter — which matters because a fleet retrying on a fixed schedule reconverges into a thundering herd against a provider that is already struggling.

You delete your error handling and get classification: errors.Is(err, skyl.ErrRateLimit) instead of matching on message text that vendors reword.

You delete your SSE parser and get a stream that cannot leak a goroutine, and that reports truncation rather than presenting a partial answer as complete.

And you get the option to change vendor later without touching any of the code that builds prompts.

What to migrate last#

Leave anything using a vendor-specific feature heavily — extended thinking with a precise token budget, prompt caching with explicit breakpoints — until you have read the feature matrix row for it. Some of those are ProviderOptions one-liners; a couple are genuinely lossy today, and it is better to know which before you commit.

Recap

  • Migrate one call path at a time; skyl coexists with a vendor SDK fine.
  • The system prompt becomes a field, and there is no Choices[0].
  • ProviderOptions carries anything skyl does not model — but the merge is shallow on three of four adapters.
  • Response.Raw is always populated, so no response field is ever lost.
  • Verify by diffing outbound bodies through a recording transport.
  • Check the feature matrix before migrating a call that leans on a vendor-specific feature.

Try out some challenges

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

Port a tool-calling loop

Your existing code appends the assistant message and tool output using vendor types. What is the skyl equivalent, and what is the one thing you must not forget?

Show hint

Every provider rejects a tool result that does not follow the call it answers.

Show solution
goCompiles
resp, err := client.Complete(ctx, req)
if err != nil {
	return err
}

for _, call := range resp.ToolCalls() {
	out := run(call.Name, call.Arguments)
	req.Messages = append(req.Messages,
		resp.Message,                             // ← the assistant's turn, first
		skyl.ToolResultMessage(call.ID, out),     // ← then your answer
	)
}
resp, err := client.Complete(ctx, req)
if err != nil {
	return err
}

for _, call := range resp.ToolCalls() {
	out := run(call.Name, call.Arguments)
	req.Messages = append(req.Messages,
		resp.Message,                             // ← the assistant's turn, first
		skyl.ToolResultMessage(call.ID, out),     // ← then your answer
	)
}

The thing you must not forget is appending resp.Message before the result. That is why Response.Message exists in the same shape a request takes — so it can be replayed verbatim.

If you have several calls in one turn, append resp.Message once and then one tool message per call.

Edit this page on GitHub