Part is the element type of a message's content. It has exactly four
implementations, and the interface is closed so that only skyl can add a fifth.
That constraint is the point.
You will learn
- The four part types and when each appears
- Why a closed interface turns a runtime bug into a compile error
- Which parts each adapter accepts, and on which roles
- What happens when an adapter cannot represent a part
The interface#
type Part interface {
isPart()
}type Part interface {
isPart()
}The marker method is unexported, so no package outside skyl can satisfy it. The
implementations are Text, Image, ToolCall and ToolResult.
Deep diveWhy closed?
An open interface would let you construct a part no adapter knows how to render.
Every adapter's type switch would hit its default branch and have to either
drop it silently or fail at runtime — a bug that appears in production, against
one vendor and not another, long after the code was written.
Closed means the compiler rejects it at the point you type it. The cost is that
adding a part type requires a change to skyl. The benefit is that every part you
can construct is one every adapter has a defined answer for, even if that
answer is a clean ErrUnsupported.
Text#
type Text struct{ Text string }type Text struct{ Text string }A run of plain text. Message.Text() concatenates every Text part and ignores
the others.
Image#
type Image struct {
MediaType string // required when Data is set, e.g. "image/png"
Data []byte // raw, NOT base64-encoded
URL string // a remotely hosted image
}type Image struct {
MediaType string // required when Data is set, e.g. "image/png"
Data []byte // raw, NOT base64-encoded
URL string // a remotely hosted image
}Provide exactly one of Data or URL.
Data is raw bytes. The adapters base64-encode it themselves — Anthropic into a
source block, the OpenAI-format adapters into a synthesised data: URI,
Gemini into inlineData. Encoding it yourself produces double-encoded garbage.
ToolCall#
type ToolCall struct {
ID string
Name string
Arguments json.RawMessage
}type ToolCall struct {
ID string
Name string
Arguments json.RawMessage
}The model's request to invoke a tool. Arguments is raw JSON because skyl
cannot know your tool's schema — you unmarshal it into your own type.
ID correlates the call with its result. On Gemini there are no call IDs on the
wire, so the adapter sets ID to the function name — which means two
parallel calls to the same tool are indistinguishable there.
ToolResult#
type ToolResult struct {
CallID string
Content string
IsError bool
}type ToolResult struct {
CallID string
Content string
IsError bool
}CallID must match the ToolCall.ID it answers. Every provider rejects a tool
result that does not follow its call.
What each adapter accepts#
Support varies by part and by the role of the message carrying it.
| Part (role) | anthropic | openai / compat | gemini |
|---|---|---|---|
| Text (user, assistant) | mapped | mapped | mapped |
| Text (tool role) | becomes user content | rejected | becomes user content |
| Image URL (user) | mapped | mapped | rejected — needs inline data |
| Image data (user) | mapped | mapped | mapped |
| Image (assistant) | accepted | rejected | accepted |
| ToolCall (assistant) | mapped | mapped | mapped |
| ToolCall (user) | accepted | rejected | accepted |
| ToolResult (tool role) | mapped | mapped | mapped |
"Rejected" means ErrUnsupported, returned before any request is made, with
a message naming exactly what could not be represented:
_, err := client.Complete(ctx, req)
if errors.Is(err, skyl.ErrUnsupported) {
// e.g. "gemini: Gemini requires inline image data, not a URL"
log.Println(err)
}_, err := client.Complete(ctx, req)
if errors.Is(err, skyl.ErrUnsupported) {
// e.g. "gemini: Gemini requires inline image data, not a URL"
log.Println(err)
}That is the whole design principle in one behaviour: skyl would rather tell you it cannot do something than do something else quietly.
Recap
- Four parts:
Text,Image,ToolCall,ToolResult. The interface is closed. - Closed means an unrenderable part is a compile error, not a production surprise.
Imagetakes exactly one ofDataorURL; setting both silently drops one.Image.Datais raw bytes — the adapter does the base64 encoding.ToolResult.IsErroronly reaches the model faithfully on Anthropic.- An adapter that cannot represent a part returns
ErrUnsupportednaming it, before sending anything.
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 image ambiguity impossible
Write a constructor that makes it structurally impossible to set both Data and
URL.
Show hint
Two functions beat one function with two optional fields.
Show solution
func imageFromData(mediaType string, data []byte) skyl.Part {
return skyl.Image{MediaType: mediaType, Data: data}
}
func imageFromURL(url string) skyl.Part {
return skyl.Image{URL: url}
}func imageFromData(mediaType string, data []byte) skyl.Part {
return skyl.Image{MediaType: mediaType, Data: data}
}
func imageFromURL(url string) skyl.Part {
return skyl.Image{URL: url}
}Neither can produce the ambiguous value, so the silent-drop case cannot arise in
your codebase. This is the general shape of defending against a documented
⚠️ row: constrain it at your boundary.
Report an unsupported part usefully
Turn an ErrUnsupported into a message a user of your application can act on.
Show hint
*skyl.Error carries the provider name and the specific message.
Show solution
var e *skyl.Error
if errors.As(err, &e) && errors.Is(err, skyl.ErrUnsupported) {
return fmt.Errorf("this model (%s) cannot handle that input: %s", e.Provider, e.Message)
}var e *skyl.Error
if errors.As(err, &e) && errors.Is(err, skyl.ErrUnsupported) {
return fmt.Errorf("this model (%s) cannot handle that input: %s", e.Provider, e.Message)
}Because the adapter names the part, the message is specific enough to be shown to a user — "cannot handle that input: Gemini requires inline image data, not a URL" tells them to upload the file instead of pasting a link.