Client.Complete is the whole non-streaming API. This page walks through one
call in detail: what is required, what is checked locally, and what the response
carries.
You will learn
- The three fields a request needs
- What
Validaterejects before any network call happens - What
Completedoes that a raw provider call would not - How to read the answer, the cost, and why generation stopped
The smallest working program#
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/BAGOMBEKA-JOB-DEV/skyl"
"github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openai"
)
func main() {
client := skyl.New(openai.New(os.Getenv("OPENAI_API_KEY")))
resp, err := client.Complete(context.Background(), &skyl.Request{
Model: "gpt-5.6",
MaxTokens: 1024,
Messages: []skyl.Message{skyl.UserText("Explain Go channels in two sentences.")},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Text())
fmt.Printf("%d in / %d out\n", resp.Usage.InputTokens, resp.Usage.OutputTokens)
}package main
import (
"context"
"fmt"
"log"
"os"
"github.com/BAGOMBEKA-JOB-DEV/skyl"
"github.com/BAGOMBEKA-JOB-DEV/skyl/provider/openai"
)
func main() {
client := skyl.New(openai.New(os.Getenv("OPENAI_API_KEY")))
resp, err := client.Complete(context.Background(), &skyl.Request{
Model: "gpt-5.6",
MaxTokens: 1024,
Messages: []skyl.Message{skyl.UserText("Explain Go channels in two sentences.")},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Text())
fmt.Printf("%d in / %d out\n", resp.Usage.InputTokens, resp.Usage.OutputTokens)
}What is required#
Only two fields are structurally required: Model and at least one message.
err := (&skyl.Request{Model: "", Messages: nil}).Validate()
fmt.Println(errors.Is(err, skyl.ErrBadRequest)) // true
fmt.Println(err) // skyl: invalid request: model is requirederr := (&skyl.Request{Model: "", Messages: nil}).Validate()
fmt.Println(errors.Is(err, skyl.ErrBadRequest)) // true
fmt.Println(err) // skyl: invalid request: model is requiredMaxTokens is not required by skyl — but it is required by Anthropic's API, so
the Anthropic adapter supplies 4096 when you leave it zero rather than
failing a request every other provider would accept. Set it explicitly if you
care about the cap.
What is checked locally#
Complete calls Request.Validate() before dispatching, so a malformed request
fails without costing a round trip. It rejects:
| Condition | Message |
|---|---|
| nil request | nil request |
| empty Model | model is required |
| empty Messages | at least one message is required |
| negative MaxTokens | max tokens must not be negative |
| unknown Role | message N has invalid role "x" |
| a message with no parts | message N has no parts |
| an Image with neither Data nor URL | image needs data or a URL |
| an Image with Data but no MediaType | image data needs a media type |
| a ToolCall missing ID or Name | tool call needs an ID and a name |
| a ToolResult missing CallID | tool result needs a call ID |
| a Tool with no name | tool N has no name |
| ToolChoiceSpecific with no Name | tool choice "tool" requires a name |
Every one of these wraps ErrBadRequest, so errors.Is(err, skyl.ErrBadRequest)
catches all of them.
What Complete adds#
Calling the provider directly would work. Complete adds four things on top:
- Validation, as above.
- Retry with jittered backoff for rate limits, server errors and connection failures — and only those.
- A per-attempt timeout, 10 minutes by default, because reasoning models legitimately take minutes on hard problems.
- Hook events, one per attempt including retried ones.
Deep diveWhy the timeout is per attempt and not per call
If the timeout bounded the whole retry sequence, then a request that failed twice would have less time left for its third attempt than its first — so the attempt most likely to be starved is the one you most want to succeed.
Bounding each attempt separately keeps them comparable. To bound the sequence as a whole, use the context you pass in:
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
resp, err := client.Complete(ctx, req)ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
resp, err := client.Complete(ctx, req)That is the one number that means "give up entirely", and it belongs to you rather than to the library.
What comes back#
resp, err := client.Complete(ctx, req)
if err != nil {
return err
}
fmt.Println(resp.Text()) // every Text part, concatenated
fmt.Println(resp.Provider) // "openai"
fmt.Println(resp.Model) // the model that ACTUALLY served it
fmt.Println(resp.StopReason) // end_turn, max_tokens, tool_use, refusal…
fmt.Println(resp.Usage.TotalTokens())resp, err := client.Complete(ctx, req)
if err != nil {
return err
}
fmt.Println(resp.Text()) // every Text part, concatenated
fmt.Println(resp.Provider) // "openai"
fmt.Println(resp.Model) // the model that ACTUALLY served it
fmt.Println(resp.StopReason) // end_turn, max_tokens, tool_use, refusal…
fmt.Println(resp.Usage.TotalTokens())Two of those are worth pausing on.
resp.Model is read from the response, not echoed from your request.
Providers can and do serve a different model than the one asked for — an alias
resolving to a dated snapshot, or a capacity fallback — and knowing which one
answered is what makes a cost report accurate.
resp.Raw is the provider's untouched body, and is always populated. Nothing
the provider sent is ever lost, only unmodelled.
Recap
Modeland at least one message are required; everything else has a sensible zero.Validateruns locally and rejects twelve shapes, all wrappingErrBadRequest.- The model string is deliberately not validated — a typo costs a round trip.
Completeadds validation, retry, a per-attempt timeout, and hook events.- Bound the whole sequence with your own context;
WithTimeoutbounds one attempt. resp.Modelis the model that answered, which may differ from the one you asked for.
Try out some challenges
Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.
Fail fast on a bad request
Write a helper that validates a request and returns a friendly error before any network call, so a CLI can report the problem immediately.
Show hint
Validate is exported and safe to call yourself.
Show solution
func check(req *skyl.Request) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("your request is not well-formed: %w", err)
}
return nil
}func check(req *skyl.Request) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("your request is not well-formed: %w", err)
}
return nil
}Complete calls Validate anyway, so this buys you nothing at runtime — but it
lets a CLI reject bad input at parse time rather than after constructing a
client, which is a better experience.
Detect a silent model substitution
Log a warning when the provider serves a different model than the one requested.
Show hint
Compare the request's model with resp.Model. Remember aliases legitimately
resolve to dated snapshots.
Show solution
if resp.Model != "" && resp.Model != req.Model {
log.Printf("note: asked for %q, served by %q", req.Model, resp.Model)
}if resp.Model != "" && resp.Model != req.Model {
log.Printf("note: asked for %q, served by %q", req.Model, resp.Model)
}Use log, not an error. Substitution is usually benign — gpt-5.6 resolving to
gpt-5.6-2026-07-09 — but when you are comparing cost or quality across a
fleet, knowing which snapshot answered is the difference between a real number
and a guess.