A library that rejects your input costs you a minute. A library that accepts your input and then quietly does not act on it costs you an afternoon — because the request succeeds, and the only symptom is a model behaving oddly. skyl has fourteen of those. Here they are.
Why publish this at all#
The obvious move is to fix them and say nothing. Most of them have been fixed:
provider/anthropic once ignored ProviderOptions entirely, leaving
cache_control, top_k and every beta feature unreachable with no workaround.
It once rebuilt tool schemas lossily enough to produce dangling $refs on one
provider while the same skyl.Tool arrived intact on another.
What remains is genuinely hard to fix — mostly because a provider's wire format has no field for the thing.
So the choice is between a matrix that lists only the green cells, and one with a column for this. skyl's own design principles reject the first:
Publishing the gaps is the point. It is better to say "we don't support this" than to ship something that looks supported and quietly does the wrong thing.
A gap you know about costs you minutes. A gap you do not costs you a day.
The list#
1.Thinking.Effort
Affects: anthropic
Why: The SDK's adaptive thinking config has no budget or effort field, so there is nothing to map it onto.
Reach it anyway: Set thinking.budget_tokens through ProviderOptions, which Anthropic applies by JSON path.
2.Thinking entirely, unless Enabled and Effort are both set
Affects: openai, openaicompat
Why: The adapter maps Effort onto reasoning_effort and has nothing to send when Effort is empty. So an explicit "off" does nothing.
Reach it anyway: Send reasoning_effort yourself through ProviderOptions.
3.ToolResult.IsError
Affects: gemini, openai, openaicompat
Why: Gemini has no wire field for it at all. The OpenAI-format adapters prefix "error: " to the content, and drop the flag entirely when the content is empty.
Reach it anyway: Put the failure in the result text itself so the model can read it.
4.A tool schema's top-level type, coerced to object
Affects: anthropic
Why: The SDK's typed schema struct forces the root type. A schema whose root is anything else is silently coerced.
Reach it anyway: Wrap a non-object schema in an object with a single property.
5.Non-string entries in a tool's required array
Affects: anthropic
Why: The adapter reads required as a list of strings and discards anything else.
Reach it anyway: Keep required a plain array of strings, which is what JSON Schema specifies anyway.
6.Image.URL when Data is also set, or Image.Data when URL is also set
Affects: all four adapters
Why: Each adapter picks the form its provider prefers and drops the other.
Reach it anyway: Set exactly one of Data or URL, which is what the field documentation asks for.
7.nextPageToken on model listing beyond 1000 entries
Affects: gemini
Why: The adapter does not follow the pagination cursor.
Reach it anyway: Call Gemini's models endpoint directly if you need the full list.
8.Streaming tool calls whose name never arrived
Affects: openai, openaicompat
Why: A call accumulated across frames with no name cannot be dispatched, so it is discarded.
Reach it anyway: Read StreamEvent.Raw if you need to see the malformed frames.
9.Non-text blocks in a response content array
Affects: openai, openaicompat
Why: The adapter extracts text blocks and ignores other block types.
Reach it anyway: Read Response.Raw.
10.Reasoning/thinking content in non-streaming responses
Affects: all four adapters
Why: skyl models an answer, not a transcript of how it was reached. The content stays in Response.Raw.
Reach it anyway: Parse Response.Raw, or stream and read EventThinkingDelta (Anthropic only).
11.Reasoning-token counts
Affects: all four adapters
Why: Usage has no field for them; they sit inside OutputTokens — except on Gemini, where they are excluded entirely.
Reach it anyway: Read the provider counters from Response.Raw for accurate cost accounting.
12.StreamEvent.Raw on the terminal EventDone
Affects: all four adapters
Why: The terminal event is assembled by skyl rather than copied from one provider frame.
Reach it anyway: Accumulate the Raw payloads of the preceding events.
13.Usage.CacheWriteTokens
Affects: openai, gemini, openaicompat
Why: Only Anthropic reports a cache-write counter on the wire.
Reach it anyway: None. Treat a zero as "not reported", not as "zero tokens".
14.Sibling keys of any object a ProviderOptions top-level key replaces
Affects: openai, gemini, openaicompat
Why: These adapters shallow-merge, so setting generationConfig replaces the whole object — destroying maxOutputTokens, temperature, topP, stopSequences and thinkingConfig.
Reach it anyway: Restate the whole object, including everything skyl would have set.
The three that will actually bite you#
ToolResult.IsError on Gemini. The flag never reaches the wire, so a failed
tool is indistinguishable from a successful one that happened to return that
text. A model that thinks a tool succeeded will confidently build an answer on
nothing. The fix is to put the failure in the content, where every provider
carries it faithfully:
skyl.ToolErrorMessage(call.ID,
"ERROR: get_weather is unavailable (HTTP 503). Do not call it again in this "+
"conversation. Tell the user live weather data is unavailable.")skyl.ToolErrorMessage(call.ID,
"ERROR: get_weather is unavailable (HTTP 503). Do not call it again in this "+
"conversation. Tell the user live weather data is unavailable.")That text does three things the boolean cannot: names the tool, says what to do next, and works everywhere.
&Thinking{Enabled: false} on OpenAI. It does nothing. If you are turning
reasoning off to control cost, it will not work there — the adapter ignores
Thinking unless both Enabled and Effort are set, and an explicit "off" has
no wire representation. Send reasoning_effort through ProviderOptions
instead.
The shallow ProviderOptions merge. On openai, openaicompat and
gemini, setting a nested object replaces the whole object:
// This destroys maxOutputTokens, temperature, topP, stopSequences AND
// thinkingConfig, because it replaces the entire generationConfig.
ProviderOptions: map[string]any{
"generationConfig": map[string]any{"seed": 7},
}// This destroys maxOutputTokens, temperature, topP, stopSequences AND
// thinkingConfig, because it replaces the entire generationConfig.
ProviderOptions: map[string]any{
"generationConfig": map[string]any{"seed": 7},
}You have to restate every sibling key skyl would have set. Anthropic is the
exception: it applies options by JSON path, so "thinking.budget_tokens": 4096
sets one nested field without disturbing its neighbours.
How to defend against them#
The general shape is to make the ambiguous value unconstructable at your own
boundary. Image takes exactly one of Data or URL, and setting both
silently drops one — so do not expose a struct literal:
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 function can produce the ambiguous case, so it cannot arise in your codebase.
One that looks like a bug and is not#
Unparseable SSE frames are skipped rather than treated as fatal. Providers interleave keep-alives and vendor-specific records, and failing an entire stream over one unrecognised line would make skyl brittle against every provider's next feature.
That is a design decision, and the one place skyl chooses tolerance over strictness.
Keeping this honest#
The feature matrix was written by reading the adapters, and it is accurate as of the commit that added it. If you find a cell that no longer matches the code, that is a bug — please report it. Making drift somebody's problem is the only way it gets fixed.