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
RequestandResponse - 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.Contentresp, 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.ContentAfter:
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 concept | skyl |
|---|---|
| System / developer message | Request.System |
| messages / contents | Request.Messages |
| content string | skyl.UserText(...) |
| content blocks / parts | Message.Parts |
| max_tokens / maxOutputTokens | Request.MaxTokens |
| tools / functionDeclarations | Request.Tools |
| tool_choice | Request.ToolChoice |
| finish_reason / stop_reason | Response.StopReason |
| usage | Response.Usage |
| the whole response body | Response.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:
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:
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:
// 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]. ProviderOptionscarries anything skyl does not model — but the merge is shallow on three of four adapters.Response.Rawis 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
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.