Drivers

The engine is provider-neutral; drivers are where the vendor lives. elelem ships three, and if you’re adding another one, the rest of this page is the contract.

Contents

Using the shipped drivers

  drivers/openai drivers/anthropic drivers/zaicoding
Talks to OpenAI and anything OpenAI-shaped, including vLLM, Ollama, OpenRouter, LM Studio, and compatible proxies The Anthropic Messages API Z.ai Coding’s OpenAI-shaped endpoint
Model discovery ListModels(ctx) live, plus KnownModels() / LookupModel(id) same same
Unknown model ids accepted, the provider decides accepted accepted when no reasoning control is set

The OpenAI-compatible and Anthropic drivers take the same options:

openai.NewDriver(
	openai.WithAPIKey(apiKey),
	openai.WithBaseURL("https://your-openai-compatible-endpoint/v1"),
	openai.WithHTTPClient(httpClient),
	openai.WithSDKOptions(/* raw SDK options */),
)

NewDriver is convenient for a one-provider program, because the official SDK can read its usual environment variables (OPENAI_* or ANTHROPIC_*). A multi-provider application that owns credential resolution should start every configured driver with WithoutEnvironmentDefaults(), then pass only that upstream’s own key and HTTP client plus a URL when it is not the driver’s official default. That stops one provider’s environment credential from quietly reaching a keyless local endpoint. zaicoding.NewDriver never reads the environment and has no such option.

WithBaseURL strips userinfo credentials from the URL before the SDK sees it, because the SDKs put the request URL into the text of every error they build. The OpenAI driver also has WithAssistantMessageExtra and WithFinishReasonMapper for specialized drivers built on its transport; the Z.ai Coding driver is one.

Capabilities are per MODEL, not per provider. Driver.Capabilities(model) reports what one model supports: seed, tool choice, parallel tool calls, strict tool arguments, JSON schema, sampling parameters, reasoning effort and its ceiling. Anthropic rejects a non-default temperature on newer models while accepting it on older ones, so a single per-provider table would be wrong. The engine reads that struct and rejects an unsupported parameter locally, before any network call, instead of sending it and getting a cryptic 400 back.

Streaming is on by default. Some OpenAI- and Anthropic-compatible backends can’t do it, so WithStreaming(false) sends the same request with streaming off and feeds the finished response through the same callbacks. See Requests.

The interface

type Driver interface {
	Stream(context.Context, DriverRequest, func(Delta) error) (Usage, error)
	Complete(context.Context, DriverRequest, func(Delta) error) (Usage, error)
	ListModels(context.Context) ([]string, error)
	Capabilities(Model) Capabilities
	TokenCounter() TokenCounter
}

Five methods. TokenCounter may return nil, in which case the engine falls back through its own resolution order (see history.md).

A driver’s job: translate portable requests and streams, validate provider transcript constraints, normalize finish reasons and usage, and report conservative model capabilities.

Complete is the same call with streaming off, and it takes the same delta callback deliberately. Everything downstream of a driver is delta-shaped (the engine’s tool-call assembler, every On* callback, the content-block protocol a consumer builds on top), and none of it has a second code path for a non-streaming turn. So Complete issues the finished request, then feeds the response through the callback as one delta per piece of content, and the caller cannot tell which path ran except by timing. Returning a whole Message instead would have forced a parallel implementation of all of it.

Two details a new driver gets wrong in the same way both shipped ones did:

  • Tool-call indices are the ordinal among TOOL CALLS, not the position of the block in the response. Number them by their position in the content array and the engine pairs results to calls that do not exist.
  • Validate the transcript on this path too. Skipping it ships an orphaned tool result that the provider rejects on the NEXT request, which is the worst possible place to find out.

If the provider cannot stream at all, say so with Capabilities.StreamingUnsupported and the engine takes Complete regardless of what the caller asked for. See requests.md.

The one rule

Provider-specific behavior must not leak into the engine API.

The moment elelem.Request grows a field only one vendor understands, you have a wrapper instead of an engine, and the caller may as well have imported that SDK directly. Anything genuinely vendor-shaped goes through WithParam / WithParams rather than into a portable type.

The shipped drivers keep their SDK types entirely inside their own packages. Nothing from openai-go or anthropic-sdk-go appears in a signature the caller touches.

Reasoning on compatible endpoints

The OpenAI-compatible driver preserves returned reasoning, reasoning_content, reasoning_details, and thinking_blocks extensions in Message.ProviderReasoning, in both streaming and non-streaming mode. Tool continuation replays those fields with the assistant message. Streamed text and signature fragments are assembled in block order; signed and encrypted state stays opaque to the engine. No endpoint name or model-name heuristic enables this behavior. The driver preserves only fields the upstream actually returns. Ordinary responses add no reasoning fields to later requests.

Persist ProviderReasoning unchanged alongside the message. Its envelope binds replay to the transport and requested model ID; foreign or mismatched state is omitted. Malformed matching state and unsupported replay fields fail locally with ErrInvalidTranscript. Plain Message.Reasoning is display text and is never promoted into a replay field by guessing a provider contract. Specialized transports may continue using WithAssistantMessageExtra to supply their own replay dialect.

Each driver’s counter budgets only the reasoning its translator replays. These counts remain local estimates, not provider billing. See history counting.

Z.ai Coding

drivers/zaicoding is the provider-specific choice for Z.ai Coding’s OpenAI-shaped endpoint. It uses the Coding endpoint by default, sends Z.ai’s thinking object, and preserves reasoning_content on the next request after a tool call.

driver := zaicoding.NewDriver(zaicoding.WithAPIKey(apiKey))
model := zaicoding.LookupModel("glm-5.3")

The generic OpenAI driver is still the one to use for ordinary OpenAI-compatible endpoints. The maintained Coding Plan catalog contains GLM 5.3 and GLM 5.3 Flash, both with Z.ai’s documented 1,000,000-token context window. LookupModel also gives GLM 5.3 Flash X and the retired IDs the Coding Plan routes (glm-5.2 and glm-5.1 to GLM 5.3, glm-4.7 to GLM 5.3 Flash) the metadata of the model that serves them.

What the driver sends and checks:

  • Thinking is always enabled with clear_thinking: false, so earlier turns’ reasoning stays in the conversation. Pass WithParam("thinking", map[string]any{"clear_thinking": true}) to drop it. Disabling thinking is refused.
  • Both models take reasoning effort with the Coding Plan mapping: minimal and low send low, medium and high send high, xhigh and max send max. none is refused, because thinking cannot be turned off.
  • MaxOutputTokens is sent as max_tokens, the field the endpoint honors, and may not exceed 131,072 on a known model.
  • A streaming request with tools asks for tool_stream, so tool-call arguments arrive in fragments.
  • Tool choice is not claimed. The endpoint treats required and a named tool as auto, and the driver refuses them locally. More than four stop words are refused.
  • GLM 5.3 is text-only. GLM 5.3 Flash takes images, audio, and files.

Z.ai’s own finish reasons map onto the portable ones: sensitive is FinishReasonContentFilter and model_context_window_exceeded is FinishReasonContextExceeded. network_error ends the call with a retryable server error. Business codes become sentinels. The balance, plan-window, plan-expiry and plan-entitlement 429s (1113, 1308 to 1311, 1313 to 1321) are elelem.ErrQuotaExhausted and are never retried, 1261 is elelem.ErrContextExceeded, and 1301 is zaicoding.ErrSensitiveContent. Request rate limits (1302) and overload (1305) stay retryable.

Unsupported values fail locally with zaicoding.ErrUnsupportedParameter. Specialized drivers over the OpenAI transport add provider finish reasons with openai.WithFinishReasonMapper, which the transport consults only for values it does not recognize.

Capabilities are a promise

Report conservatively. A capability you claim and don’t enforce is worse than one you don’t claim. The engine will let a request through on the strength of it and the provider will reject it.

  • Context and output limits are NOT capabilities. They live on Model (ContextSize, Pricing) because they’re caller-supplied facts, not driver-discovered ones.
  • MaxReasoningEffort is a CEILING, not a whitelist. A model’s supported effort set can be non-contiguous (one may accept max while rejecting xhigh), and a single ceiling can’t express that. Passing the rank check is necessary but not sufficient; the driver makes the final call and returns its own ErrUnsupportedParameter for a level inside the ceiling the model doesn’t actually take. That sentinel is per-driver. openai.ErrUnsupportedParameter and anthropic.ErrUnsupportedParameter are distinct values, since each names its own provider in the message.
  • Unknown model ids stay usable. If the provider accepts an id you’ve never heard of, let it through. Just don’t guess its optional capabilities.

Errors

Build failures as ProviderError carrying the provider’s own code, and join ProviderSentinel(status, code):

return &elelem.ProviderError{ /* ... */ }

The & is load-bearing. Error() is declared on the pointer receiver, so a bare elelem.ProviderError{} value does not satisfy error and will not compile.

That’s what makes this work for a caller who doesn’t know or care which provider served the request:

errors.Is(err, commonerrors.ErrRateLimited)

Helpers you should use rather than reimplement:

Helper Does
ParseRetryAfter Both Retry-After forms; never returns a negative or unbounded delay.
SanitizeBaseURL Strips userinfo credentials from an endpoint before the SDK can bake them into error text that gets logged.

Remember that both shipped providers report mid-stream failures in band inside an HTTP 200. Surface the provider’s error code; the retry decorator consults it ahead of the status precisely because the status is lying in that case. See retries.md.

The conformance suite

elelemtest/conformance.Run is aimed at you, and all shipped drivers run it, so it’s a live contract, not a document that drifted.

func TestDriverConformance(t *testing.T) {
	server := httptest.NewServer(/* a fake provider endpoint */)
	t.Cleanup(server.Close)

	conformance.Run(t,
		func() elelem.Driver {
			return NewDriver(WithBaseURL(server.URL), WithAPIKey("test-key"))
		},
		conformance.Options{
			Request: elelem.DriverRequest{
				Model:    elelem.Model{ID: "some-model"},
				Messages: []elelem.Message{{
					Role:    elelem.RoleUser,
					Content: elelem.Text("hi"),
				}},
			},
			NetworkCalls: func() int64 { return atomic.LoadInt64(&calls) },
			Models:       []elelem.Model{modelA, modelB},
		},
	)
}

What it checks:

  • stream contract: delta ordering, usage invariants, normalized finish reasons, and that the deltas and the usage agree about how the run ended
  • model listing: skippable via Options.SkipListModels
  • cancelled context: a cancelled ctx stops work rather than completing
  • invalid transcript is local: a malformed transcript is rejected WITHOUT a network call, which is what NetworkCalls is for
  • capabilities are honest: every advertised capability is actually enforced, per model

Options.Models re-runs the capability contract against each model in place of Request.Model. Capabilities is a per-model function, so a suite pinned to one model can’t see a driver that gates correctly for the model it was handed and wrongly for its siblings. That is exactly where the real bugs are.

The subtests deliberately do NOT call t.Parallel(). NetworkCalls is a single shared counter and the transcript and capability cases assert on its delta across one call; running them concurrently would interleave increments and make those assertions meaningless. Serial is a correctness requirement here, not an oversight.

Wire tests

Use in-process fake HTTP servers and no live credentials. The shipped drivers’ tests replay recorded SSE against httptest.NewServer, which is fast, hermetic, and can reproduce the failure shapes a real provider only emits occasionally: mid-stream errors inside a 200, truncated streams, malformed tool-call fragments.