Skip to content
skyl

The fourteen things skyl silently ignores

· skyl maintainers

Every place the library accepts something and then does not do it — with the workaround for each.

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.

Edit this page on GitHub