elelem

package module
v0.6.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: MIT Imports: 24 Imported by: 0

README

elelem

Go Reference CI coverage version license imported by

elelem is LLM, spelled out loud. Say it fast, you get it.

A Go engine for talking to LLMs that doesn't give a shit which one. Streaming, tool loops, history that actually fits in the context window, retries that don't hand your user the same fucking paragraph twice, and typed structured responses. Swap OpenAI for Anthropic by changing one line; the rest of your code never finds out it happened.

It's a library, not a framework — the difference being who owns the for loop. Here, you do. Run hands you the tool calls and stops; the engine only drives the loop if you explicitly ask for it with WithAutoToolCalls(). That order is deliberate, because the second a human has to approve a tool call, "just describe your goal, bro" abstractions stop being able to express the program you actually need.

So: no planner, no memory store, no chain-of-anything, no swarm, no crew, no graph of nodes that's secretly a for loop with extra steps. It also stores nothing, picks no driver, resolves no credentials, discovers no external tools, decides who may call which tool exactly fucking never, and renders nothing to a user — no config loader, no init() quietly rummaging through your environment. You wire it, it runs requests. Agent frameworks are one go get and several regrets away, and this is the layer they'd be sitting on.

Built on the official openai-go and anthropic-sdk-go, plus an embedded o200k_base tokenizer so budgeting doesn't need the network. 247 tests at 90%+ coverage, and both shipped drivers run the same conformance suite a third-party driver would — the Driver contract is executable, not aspirational bullshit in a markdown file.

driver := openai.NewDriver(openai.WithAPIKey(apiKey))
client := elelem.New(driver)

response, err := elelem.NewRequest(client).
	WithModel(elelem.Model{ID: "some-model-id", ContextSize: 200_000}).
	WithPrompt(elelem.NewPrompt().
		WithSystem("You are a concise operations assistant.").
		UserText("Summarize the current incident state.")).
	Run(ctx)

Contents

Quick start

go get github.com/psyb0t/elelem

Anthropic is the same as the example above, just a different constructor:

driver := anthropic.NewDriver(anthropic.WithAPIKey(apiKey))

Wrap the driver and you get retries with backoff:

client := elelem.New(elelem.WithRetry(driver, elelem.RetryConfig{MaxAttempts: 3}))

Streaming, a tool loop and a budget — still one chain, no ceremony:

response, err := elelem.NewRequest(client).
	WithModel(model).
	WithPrompt(elelem.NewPrompt().UserText(question)).
	WithTools(tools).
	WithAutoToolCalls().          // without this YOU drive the loop
	WithMaxRounds(8).
	WithMaxContextTokens(100_000).
	OnText(func(_ context.Context, delta elelem.TextDelta) error {
		fmt.Print(delta.Text)

		return nil
	}).
	Run(ctx)

Run sends the tools; WithAutoToolCalls is what makes the engine execute them. Manual driving is the default — drop that one line and the tool calls come back to you, to approve or tell to fuck off.

Want the model to fill in a struct instead? RunInto(ctx, &dst). Same builder, typed answer, and it validates before it assigns, so a half-decoded object never lands in your variable.

Every knob these examples don't show is in docs/requests.md.

The pieces

Area What you get
Requests Client + Request + the round loop. One chained builder for streaming, tools, history budgets, generation parameters, and per-provider escape hatches. Nothing here knows which vendor answers.
Prompts An immutable Prompt carrying the system message, the history and this turn — build it once, run it against several models from several goroutines. Images, audio and documents are content parts on a user message, and content the model can't read gets refused locally instead of by the provider a round trip later.
Tools Bounded concurrency, per-tool timeouts, a PreRun → Handler → OnSuccess|OnError → PostRun lifecycle, panic recovery that becomes a tool error instead of taking your process down with it, per-call denial, and tools that inject messages.
Callbacks Sixteen observation points — run and round lifecycle, text and reasoning deltas, tool-call start/fragment/result, retries, token limits. Delivery stays ordered even when tools run concurrently.
History Counts the transcript, drops whole units oldest-first, never orphans a tool result. Replace the default sliding window with your own compaction in one call.
Retries A decorator around any Driver. Classifies failures, honors Retry-After, stops the instant output starts streaming, and keeps a ledger of what the failed attempts cost you.
Structured output RunInto derives a JSON schema from your own struct, validates against it, and can spend one bounded repair request when the model shits out malformed JSON.
Drivers OpenAI-compatible and Anthropic transports. KnownModels() / LookupModel(id) for pre-filled models; unknown ids stay usable, so this morning's release works today.
Test doubles A scripted Driver that imports no test framework, a generated mock, and the conformance suite for when you write a third driver.

Drivers

Both drivers take the same four options and expose the same surface:

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: the official SDK can read its usual environment variables. 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.

drivers/openai drivers/anthropic
Talks to OpenAI and anything OpenAI-shaped — vLLM, Ollama, OpenRouter, LM Studio, whatever proxy you cobbled together The Anthropic Messages API
Model discovery ListModels(ctx) live, plus KnownModels() / LookupModel(id) same
Unknown model ids accepted — the provider decides accepted

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 happily eating it on older ones, so a single per-provider table would just be a lie you shipped. The engine reads that struct and rejects an unsupported parameter locally, before any network call, instead of firing it off and eating a cryptic 400.

Streaming is on by default. Some OpenAI- and Anthropic-compatible backends can't do it — an async job queue sitting in front of a model has nowhere to put a token stream — so WithStreaming(false) sends the same request with streaming off and feeds the finished response through the exact same callbacks. Your renderer never has to know. See docs/requests.md.

Writing a third driver is docs/drivers.md, and elelemtest/conformance.Run is the contract suite both shipped drivers run against — so it's alive, not a document that quietly drifted out of date two years ago.

Shit that can bite you

Two inputs are untrusted, and nobody has to be malicious for it to matter — a model that hallucinates, an OpenAI-compatible endpoint, or a proxy is plenty.

Provider output. Tool-call ids, names, arguments, indices and the finish reason are all model-chosen. The engine bounds distinct tool calls per round and accumulated argument bytes unconditionally. Tool-result size is bounded only if you askWithMaxToolResultTokens is unset by default, and until you set it a result goes through at whatever length it showed up. A call with no id, a duplicate id, or an index reused for two different calls gets dropped or split at ingest with a logged reason — because otherwise the provider rejects each of those on the NEXT request instead of the one that produced it, and good luck with that afternoon.

Tool results. A tool reads web pages, files and databases, so its output is attacker-influenced content going straight into the model's context. The engine does not sanitize it, and does not bound it unless you set WithMaxToolResultTokens: a result saying "ignore your instructions" is delivered exactly as written, at whatever length it arrived. Defending against that is your fucking job, not the library's.

Three specifics worth knowing before you ship:

  • A tool can inject a system message. That's the feature, and it means a tool is exactly as privileged as your system prompt — treat anything that can register one the way you'd treat a line in sudoers.
  • A handler's error text reaches the provider. Handler errors become tool results the model reads, so a lazy return err from a failed database call will cheerfully post your connection string to somebody else's inference cluster.
  • WithTimeout is the only bound on an endless stream, and it's unset by default. A provider that dribbles one token every thirty seconds will keep your goroutine company for as long as it damn well pleases.

API keys are never logged, and credentials embedded in a WithBaseURL endpoint get stripped before the SDK sees them — the SDKs stuff the request URL into the text of every error they build, and those errors get logged.

Full detail in docs/tools.md.

Logging

Structured log/slog through ctxscope, pulled from the context — the library never takes a logger parameter and never installs a global. Whatever you configured on slog.Default() at startup is where it writes, and any scope attributes you set (request_id, user_id) ride along on every line the engine emits.

It shuts up on purpose. DEBUG carries the per-round and per-tool detail. INFO is spent on exactly two events — a transcript compacted to fit the budget, and a stream that only survived because a retry saved its ass — because both are things you want in a production log without flipping DEBUG on, and neither is an error. WARN is the recoverable weirdness: a malformed tool call dropped at ingest, a decision matching no pending call, an unknown tool requested, the retry loop giving up. ERROR is shit that actually broke.

Compaction is INFO rather than WARN deliberately. It happens routinely on any long conversation, and a WARN that fires on the normal path is exactly how a log turns into noise nobody reads.

Every decision the engine makes quietly carries a reason field with a stable, greppable value — token_budget_exceeded, tool_call_denied, max_attempts_exhausted, finish_reason_unmapped, and so on. They're exported as elelem.LogReason* constants, so your alerting matches the same symbol the engine emits instead of some string you copy-pasted out of a log line and typo'd.

Package shape

A provider-neutral engine plus provider drivers. Your code holds elelem.Driver, elelem.Client and elelem.Request; provider SDK types stay locked inside their driver package and never leak out.

client.go, request.go, engine.go   request construction and execution
driver.go, errors.go               provider boundary and sentinels
message.go, transcript.go          transcript primitives and repair
usage.go                           token and retry accounting
tool.go                            tools, hooks, and message injection
limit.go, tokens.go                history budgeting
retry.go                           retry decorator
structured.go                      typed structured responses
elelemtest/                        scripted Driver (imports no test framework)
elelemtest/conformance/            driver contract suite
elelemtest/mocks/                  generated Driver mock
drivers/openai/                    OpenAI-compatible transport
drivers/anthropic/                 Anthropic transport

Three placements the filename alone won't give you: the round/tool loop lives in engine.go, every sentinel this package exports lives in errors.go (each driver package has its own), and structured.go holds RunInto together with the request-validation helpers it shares with request.go.

Documentation

Doc What's in it
requests.md Every builder method, what it sets, and what happens when you don't set it.
callbacks.md The sixteen observation points, their ordering guarantees, and a worked example.
tools.md Tools, the hook lifecycle, message injection, denial, and the bounds that keep a tool loop honest.
history.md Token budgets, transcript units, limiting handlers, counting, and what to persist.
retries.md The retry decorator, failure classification, and the sentinel taxonomy.
structured-output.md RunInto, JSON mode, JSON schema, validation and repair.
drivers.md The Driver contract and how to write a third one without guessing.
testing.md ScriptedDriver vs MockDriver vs the conformance suite.

Generated API reference on pkg.go.dev.

Development

make dep            # go mod tidy + vendor
make generate       # regenerate the Driver mock
make lint           # go fix + golangci-lint, strict as hell
make lint-fix       # lint + auto-fix
make test           # go test -race ./...
make test-coverage  # coverage with minimum threshold
make help           # every target

License

MIT. See LICENSE.

See CHANGELOG.md for release notes.

Documentation

Overview

Package elelem provides a provider-neutral streaming conversation engine.

Index

Constants

View Source
const (
	MediaTypeJPEG = "image/jpeg"
	MediaTypePNG  = "image/png"
	MediaTypeGIF  = "image/gif"
	MediaTypeWebP = "image/webp"
	MediaTypePDF  = "application/pdf"
	MediaTypeText = "text/plain"
)

Media types that mean something to a provider rather than to us.

View Source
const (
	ProviderErrorCodeContextLengthExceeded = "context_length_exceeded"
	ProviderErrorCodeOverloaded            = "overloaded_error"
	ProviderErrorCodeAPIError              = "api_error"
	ProviderErrorCodeRateLimit             = "rate_limit_error"
)

Provider error codes elelem understands. A provider's own code is the only trustworthy signal when a failure arrives IN BAND: both supported providers report a mid-stream failure inside an HTTP 200 response, so the transport status describes the connection rather than the outcome, and classifying by status alone reads a real server error as a success worth no retry.

Variables

View Source
var (
	ErrInvalidTranscript = errors.New("invalid transcript")
	ErrMaxRoundsExceeded = errors.New(
		"maximum conversation rounds exceeded",
	)
	ErrToolCallsAlreadyExecuted = errors.New("tool calls already executed")
	ErrResponseTruncated        = errors.New(
		"structured response was truncated",
	)
	ErrResponseSchemaMismatch = errors.New(
		"structured response does not match target",
	)
	ErrInvalidRequest          = commonerrors.ErrInvalidArgument
	ErrMaxOutputExceedsContext = errors.New(
		"maximum output tokens exceed model context",
	)
	ErrContextExceeded = errors.New("provider context limit exceeded")

	// ErrToolHandlerPanicked is what a recovered handler panic becomes. A
	// sentinel rather than a bare error so a caller can tell a panic apart
	// from a handler that returned a failure deliberately — the two say very
	// different things about the tool.
	ErrToolHandlerPanicked = errors.New("tool handler panicked")

	ErrPartTypeUnknown     = errors.New("unknown content part type")
	ErrPartPayloadMissing  = errors.New("content part has no payload")
	ErrPartPayloadMismatch = errors.New(
		"content part carries a payload its type does not use",
	)
	ErrImageSourceAmbiguous = errors.New(
		"image source needs exactly one of URL or Data",
	)
	ErrImageMediaTypeRequired = errors.New(
		"image source with Data requires MediaType",
	)
	ErrAudioDataRequired   = errors.New("audio source requires Data")
	ErrAudioFormatUnknown  = errors.New("unsupported audio format")
	ErrFileSourceAmbiguous = errors.New(
		"file source needs exactly one of Data or FileID",
	)
	ErrDataURIMalformed = errors.New("malformed data URI")

	// ErrUnsupportedContent is what a driver returns for a part type the
	// provider has no equivalent for — audio against Anthropic, say. Distinct
	// from ErrInvalidRequest: the content is well-formed, this provider just
	// cannot carry it, so the caller's fix is a different model, not a
	// different payload.
	ErrUnsupportedContent = errors.New("provider does not support content type")

	ErrRetryMaxAttempts = errors.New("retry max attempts must be positive")
	ErrRetryDelays      = errors.New("retry delays must not be negative")
	ErrRetryDelayOrder  = errors.New(
		"retry maximum delay must not be less than initial delay",
	)
	ErrRetryLoopExhausted = errors.New("retry loop exhausted")
)

Functions

func ParseRetryAfter

func ParseRetryAfter(value string) time.Duration

ParseRetryAfter reads a Retry-After header value in either RFC 7231 form: delay-seconds, or an HTTP-date. Returns 0 when absent, unparseable, or in the past — never a negative duration, which callers would treat as "wait forever" or "do not wait" depending on how they compare it.

Shared rather than per-driver: both drivers had a byte-identical copy, and duplicated parsing of an untrusted header is how one of them ends up hardened and the other not.

func ProviderSentinel

func ProviderSentinel(status int, code string) error

ProviderSentinel returns the portable sentinel for a provider failure, or nil when the condition has none. Drivers join it onto the error they build so a caller can ask errors.Is(err, commonerrors.ErrRateLimited) without knowing which provider answered.

Shared rather than per-driver because it once lived in only one: OpenAI joined sentinels and Anthropic did not, so the same condition satisfied errors.Is for one provider and not the other — invisible until a caller used a driver directly.

func SanitizeBaseURL

func SanitizeBaseURL(baseURL string) (string, bool)

SanitizeBaseURL strips userinfo credentials from an endpoint and reports whether it removed any. The SDKs embed the request URL in every error they build, and drivers log those errors, so a https://user:secret@host base URL leaks the password to the log aggregator on first failure. Stripped rather than rejected: these SDKs authenticate by header and ignore userinfo, so it never worked as credentials anyway.

func SetDefaultTokenCounter

func SetDefaultTokenCounter(counter TokenCounter)

SetDefaultTokenCounter replaces the process-wide fallback counter used when neither the Client nor the Driver supplies one. Safe to call concurrently; intended for startup wiring, not per-request swapping. Passing nil RESETS to the built-in estimator — it does not leave a previously installed counter in place.

Types

type AudioFormat added in v0.2.0

type AudioFormat = string

AudioFormat is the encoding of an AudioSource's bytes. OpenAI accepts only these two.

const (
	AudioFormatWAV AudioFormat = "wav"
	AudioFormatMP3 AudioFormat = "mp3"
)

type AudioSource added in v0.2.0

type AudioSource struct {
	Data   []byte      `json:"data,omitempty"`
	Format AudioFormat `json:"format,omitempty"`
}

AudioSource is audio input. OpenAI accepts it; Anthropic's messages API has no audio block at all, so its driver rejects the part before any request.

type CacheHint

type CacheHint = string

CacheHint marks a prompt-caching breakpoint on a message.

It is never rejected: a provider with explicit breakpoints honors it, and a provider that caches implicitly ignores it. Capabilities.SupportsPromptCaching DESCRIBES which of the two you get — it is deliberately not a gate, because there is nothing for an implicit-caching provider to refuse. Setting a hint is always safe, never a portability hazard.

const (
	CacheHintNone  CacheHint = ""
	CacheHintShort CacheHint = "short"
	CacheHintLong  CacheHint = "long"
)

type CallbackKind added in v0.3.0

type CallbackKind string

CallbackKind names one event's handler chain, for ResetCallback.

A defined type rather than a bare string so a call site cannot pass an arbitrary name, and one shared enum rather than fourteen ResetOnX methods so clearing a handler does not double the callback API surface.

const (
	CallbackStart            CallbackKind = "start"
	CallbackReasoning        CallbackKind = "reasoning"
	CallbackText             CallbackKind = "text"
	CallbackToolCallFragment CallbackKind = "tool_call_fragment"
	CallbackDelta            CallbackKind = "delta"
	CallbackRoundStart       CallbackKind = "round_start"
	CallbackRoundEnd         CallbackKind = "round_end"
	CallbackAssistantMessage CallbackKind = "assistant_message"
	CallbackToolCallStart    CallbackKind = "tool_call_start"
	CallbackToolResult       CallbackKind = "tool_result"
	CallbackMessageInjection CallbackKind = "message_injection"
	CallbackRetry            CallbackKind = "retry"
	CallbackFinish           CallbackKind = "finish"
	CallbackError            CallbackKind = "error"
)

type Capabilities

type Capabilities struct {
	SupportsResponseFormatJSONSchema bool
	SupportsResponseFormatJSONObject bool
	SupportsStrictToolArguments      bool
	SupportsToolChoice               bool
	SupportsParallelToolCalls        bool
	SupportsSeed                     bool
	SupportsSamplingPenalties        bool
	SupportsSamplingParams           bool
	SupportsReasoningEffort          bool
	SupportsDisablingReasoning       bool
	SupportsPromptCaching            bool

	// StreamingUnsupported says this provider cannot stream AT ALL, so every
	// call takes Driver.Complete no matter what the caller asked for.
	//
	// Phrased negatively, unlike every other flag here, so the ZERO VALUE is
	// the right answer. Streaming is what elelem has always done and what
	// every mainstream provider does; a SupportsStreaming bool would mean a
	// driver that simply forgot the field silently moved all its traffic onto
	// the non-streaming path — working, but a transport change nobody asked
	// for. The flags above are safe defaulted off because off merely declines
	// a feature; this one would change how every request is made.
	//
	// It is a property of the PROVIDER, which is all a driver can see, and NOT
	// the answer to "should this call stream". A path that streams perfectly
	// well may still be unable to DELIVER a stream — an async queue proxy in
	// front of the endpoint returns a job id and replays the buffered body
	// later, so the stream dissolves before the caller sees it. That choice
	// belongs to whoever configured the endpoint: elelem.WithStreaming.
	StreamingUnsupported bool

	// Content-part support. Text needs no flag — every provider takes it, and
	// a model that could not would not be a chat model.
	//
	// These are NECESSARY, not sufficient, exactly like MaxReasoningEffort.
	// SupportsImageInput says the provider has an image block at all; it says
	// nothing about which media types, and Anthropic accepts only four. The
	// driver makes the final per-value call and returns its own
	// ErrUnsupportedParameter. Claiming a capability the driver does not
	// enforce is worse than not claiming it: the engine lets the request
	// through on the strength of the flag and the provider rejects it.
	SupportsImageInput bool
	SupportsAudioInput bool
	SupportsFileInput  bool

	// MaxReasoningEffort is a CEILING, not a whitelist. A model's supported
	// effort set can be non-contiguous — a model may accept `max` while
	// rejecting `xhigh` — and a single ceiling cannot express that. So passing
	// the rank check here is necessary but not sufficient; the driver makes
	// the final call and returns ErrUnsupportedParameter for a level inside
	// the ceiling that the model does not actually take. That rejection is
	// still local, so a non-contiguous gap costs a clear error, never a
	// provider round-trip.
	MaxReasoningEffort ReasoningEffort
}

Capabilities declares what a provider supports FOR ONE MODEL, so the builder can reject an unsupported parameter locally instead of shipping it and eating a confusing 400.

Every flag reads as an assertion about the model. Support is deliberately NOT a provider-wide constant: Anthropic rejects a non-default temperature on newer models while accepting it on older ones, and reasoning-effort levels are gated per model family. A single struct per provider cannot say that.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a Driver plus process-level defaults. It carries no per-conversation state, so ONE client serves every request and is safe for concurrent use; Request is where an individual call is shaped.

func New

func New(driver Driver, opts ...Option) *Client

New builds a Client over the given Driver. A nil Option is skipped, so conditional wiring needs no branch at the call site.

func (*Client) Capabilities added in v0.2.0

func (c *Client) Capabilities(model Model) Capabilities

Capabilities reports what the given model supports, with any override from WithCapabilityOverride applied. Everything that gates on capabilities reads through here, so an override cannot be honoured in one place and missed in another.

func (*Client) Driver

func (c *Client) Driver() Driver

Driver returns the underlying driver so callers can compose their own decorators around it. Returns nil for a nil Client rather than panicking — this is a read-only accessor and a nil check at every call site is noise.

type Content added in v0.2.0

type Content []Part

Content is a message's content: an ordered list of parts.

Order is meaningful — providers show the model the parts as given, so an image before its question reads differently from after it.

func Text added in v0.2.0

func Text(text string) Content

Text builds single-part text content. This is the common case by a wide margin, so it returns Content rather than Part.

func (Content) Clone added in v0.2.0

func (c Content) Clone() Content

Clone returns a deep copy. Byte slices are copied too: content crosses into engine-owned transcripts that outlive the caller's buffer.

func (Content) IsTextOnly added in v0.2.0

func (c Content) IsTextOnly() bool

IsTextOnly reports whether every part is text, so a text-only path can stay exactly as it was.

func (Content) String added in v0.2.0

func (c Content) String() string

String returns the text parts joined by newlines, ignoring the rest.

Deliberately lossy: an image has no text, and substituting a placeholder would put words in the model's mouth. Token counting, logging and text-only provider fields all read through here.

func (Content) Types added in v0.2.0

func (c Content) Types() []PartType

Types returns the distinct part types present, in first-seen order. The capability gate reports on these so a refusal names the kind of content it refused rather than an offset.

func (Content) Validate added in v0.2.0

func (c Content) Validate() error

Validate reports the first structural problem in the content.

Structure is checked before capabilities: an image part with neither a URL nor bytes is malformed for EVERY provider, while audio is merely unsupported by one. Conflating the two would report "Anthropic cannot do this" for something no provider could.

type Delta

type Delta struct {
	Text              string
	Reasoning         string
	ProviderReasoning json.RawMessage
	ToolCall          *ToolCallDelta
	FinishReason      FinishReason
}

Delta is one streamed chunk. Exactly one field is populated per delta; FinishReason appears only on the final one.

type Driver

type Driver interface {
	// Stream issues the call with the provider's streaming mode on, invoking
	// the callback per delta as they arrive.
	Stream(context.Context, DriverRequest, func(Delta) error) (Usage, error)

	// Complete issues the SAME call with streaming OFF, then feeds the whole
	// response through the SAME callback as one delta per piece of content.
	//
	// The identical signature is the point: everything downstream of a driver
	// — every callback, the tool-call assembler, essessey's content-block
	// streamers — is delta-shaped, so a non-streaming turn must arrive as
	// deltas or every consumer needs a second code path. The driver does the
	// translation because only it knows the provider's non-streaming response
	// shape. A non-streaming turn therefore renders as one big chunk rather
	// than token-by-token, and nothing else changes.
	//
	// Implement it even when the provider always streams: buffer and emit
	// once. A driver that quietly streams anyway defeats the caller's reason
	// for asking — an endpoint that cannot deliver a stream at all (an async
	// queue proxy, a compat gateway) does not get better by ignoring them.
	Complete(context.Context, DriverRequest, func(Delta) error) (Usage, error)

	ListModels(context.Context) ([]string, error)
	Capabilities(Model) Capabilities
	TokenCounter() TokenCounter
}

Driver is the ONLY provider-aware surface in the library — everything above it speaks elelem's own vocabulary types. Implementations translate a DriverRequest into their vendor SDK and normalize the response back, including mapping the provider's finish reasons onto the FinishReason constants so no provider string ever escapes upward.

A Driver must be safe for concurrent use; one instance serves every request.

func WithRetry

func WithRetry(driver Driver, config RetryConfig) Driver

type DriverRequest

type DriverRequest struct {
	Model    Model
	Messages []Message
	Tools    []Tool
	Params   GenerationParams
}

DriverRequest is the fully-resolved, provider-agnostic call. The system message is pinned at Messages[0]; drivers whose provider takes system as a top-level parameter lift it out themselves.

type FileSource added in v0.2.0

type FileSource struct {
	Data      []byte `json:"data,omitempty"`
	MediaType string `json:"mediaType,omitempty"`
	Filename  string `json:"filename,omitempty"`

	// FileID references a file already uploaded to the provider. It is
	// provider-scoped: an id from one means nothing to another.
	FileID string `json:"fileId,omitempty"`
}

FileSource is a document: either the bytes or a provider-side FileID.

The providers cover different ground. OpenAI takes any bytes with a filename; Anthropic's document block takes PDF bytes, plain text, or a list of content blocks — so a .docx that works on one is rejected locally by the other rather than at the API.

type FinishReason

type FinishReason string

FinishReason is why a completion stopped, normalized across providers. It is a DEFINED type, not an alias, so it can carry methods.

Providers each use their own vocabulary; drivers map onto this set, and a value a driver does not recognize becomes Unset, never Stop — so an unmapped refusal or context overflow can never masquerade as a clean finish. Prefer IsTruncated/IsTerminal over comparing constants directly.

const (
	FinishReasonUnset           FinishReason = ""
	FinishReasonStop            FinishReason = "stop"
	FinishReasonLength          FinishReason = "length"
	FinishReasonToolCalls       FinishReason = "tool_calls"
	FinishReasonContentFilter   FinishReason = "content_filter"
	FinishReasonStopSequence    FinishReason = "stop_sequence"
	FinishReasonPaused          FinishReason = "paused"
	FinishReasonContextExceeded FinishReason = "context_exceeded"
	FinishReasonFunctionCall    FinishReason = "function_call"
)

func (FinishReason) IsRefusal

func (f FinishReason) IsRefusal() bool

IsRefusal reports whether the model DECLINED to answer, as opposed to failing to finish. The distinction is what stops a structured-output repair round: re-asking a model that refused buys a second billed round-trip and the same refusal, surfaced to the operator as a schema mismatch that never existed.

A predicate rather than a constant comparison, like IsTruncated: a provider gaining its own refusal value should land here once, not at every caller.

func (FinishReason) IsTerminal

func (f FinishReason) IsTerminal() bool

IsTerminal reports whether the turn is genuinely over. False for ToolCalls (the model is waiting on results) and for Paused (a long-running turn that is resumable).

func (FinishReason) IsTruncated

func (f FinishReason) IsTruncated() bool

IsTruncated reports whether the answer was cut off mid-generation and is therefore incomplete — the caller decides whether to raise the output cap or continue. The engine never auto-continues, since stitching a second completion hides cost and can corrupt structured output.

type GenerationParams

type GenerationParams struct {
	Temperature       *float64
	TopP              *float64
	ReasoningEffort   ReasoningEffort
	MaxOutputTokens   *int64
	FrequencyPenalty  *float64
	PresencePenalty   *float64
	Seed              *int64
	Stop              []string
	ToolChoice        ToolChoice
	ParallelToolCalls *bool
	ResponseFormat    *ResponseFormat
	Extra             map[string]any
}

GenerationParams are the per-call model knobs. A nil pointer or zero-value enum means "omit the field entirely so the provider default applies" — that omit-when-unset contract is what keeps one struct usable across providers that support different subsets.

type HTTPStatusError

type HTTPStatusError interface {
	error
	HTTPStatus() int
	RetryAfter() time.Duration
}

HTTPStatusError is implemented by provider errors that carry an HTTP status and an optional Retry-After. Callers match with errors.As rather than string-sniffing the message.

type ImageDetail added in v0.2.0

type ImageDetail = string

ImageDetail is how much fidelity the model should spend on an image.

OpenAI honours it; Anthropic has no equivalent and drops it. It is a hint about cost, never about meaning, so dropping it cannot change an answer — which is why this is a silent difference rather than a capability gate.

const (
	ImageDetailAuto ImageDetail = "auto"
	ImageDetailLow  ImageDetail = "low"
	ImageDetailHigh ImageDetail = "high"
)

type ImageSource added in v0.2.0

type ImageSource struct {
	// URL is an http(s) link or a `data:<media-type>;base64,<data>` URI.
	URL string `json:"url,omitempty"`

	// Data is the raw image, NOT base64. Drivers encode on the way out, so a
	// caller never encodes by hand and never double-encodes.
	Data []byte `json:"data,omitempty"`

	// MediaType is required alongside Data, optional alongside URL. Anthropic
	// accepts only the four image/* constants above and its driver rejects
	// anything else locally.
	MediaType string `json:"mediaType,omitempty"`

	// Detail is an OpenAI-only fidelity hint.
	Detail ImageDetail `json:"detail,omitempty"`
}

ImageSource is where an image comes from. Exactly one of URL or Data is set, which the constructors guarantee and Validate enforces for a hand-built one.

The providers disagree about shape: OpenAI takes a single `url` that is EITHER a link or a `data:` URI, while Anthropic takes a tagged union with the media type as its own field. Modelling it Anthropic's way is the direction that does not lose information — collapsing a tagged source into a data URI is mechanical, recovering a media type from an arbitrary URL is not.

func (ImageSource) DataURI added in v0.2.0

func (s ImageSource) DataURI() string

DataURI renders the source as `data:<media-type>;base64,<data>`, the only shape OpenAI accepts for inline image bytes. A source that already carries a URL is returned untouched.

func (ImageSource) DecodeDataURI added in v0.2.0

func (s ImageSource) DecodeDataURI() (string, []byte, error)

DecodeDataURI splits a `data:` URL into its media type and raw bytes.

Anthropic has no equivalent of OpenAI's packed form, so a caller who built content for one provider and sent it to the other still works — the driver unpacks here rather than refusing something the model could have read.

func (ImageSource) IsDataURI added in v0.2.0

func (s ImageSource) IsDataURI() bool

IsDataURI reports whether the URL carries inline bytes rather than a link. Anthropic needs the distinction: a link becomes URLImageSource, a data URI has to be split back into media type and payload.

type LogReason

type LogReason = string

LogReason is the stable, greppable value of the `reason` log field. The whole point of that field is that future-you greps for it, which makes it a finite named domain rather than free prose — so the values are constants, shared by the emit sites, the drivers, and the tests that assert on them.

const (
	// Engine — tool lifecycle.
	LogReasonToolCallDenied       LogReason = "tool_call_denied"
	LogReasonToolCallNotPending   LogReason = "tool_call_not_pending"
	LogReasonToolNotInToolSet     LogReason = "tool_not_in_toolset"
	LogReasonToolArgumentsInvalid LogReason = "tool_arguments_invalid_json"
	LogReasonToolHasNoHandler     LogReason = "tool_has_no_handler"
	LogReasonToolExecutionPanic   LogReason = "tool_execution_panicked"
	LogReasonToolHandlerPanic     LogReason = "tool_handler_panicked"
	LogReasonToolResultRemoved    LogReason = "tool_result_removed_by_hook"
	LogReasonInjectionRoleInvalid LogReason = "injection_role_invalid"
	LogReasonEmptyAssistantTurn   LogReason = "empty_assistant_turn"
	LogReasonToolArgumentsCapped  LogReason = "tool_arguments_size_capped"
	LogReasonToolCallIndexReused  LogReason = "tool_call_index_reused"
	LogReasonToolCallIDMissing    LogReason = "tool_call_id_missing"
	LogReasonToolCallIDDuplicate  LogReason = "tool_call_id_duplicate"
	LogReasonOnToolResultFailed   LogReason = "on_tool_result_hook_failed"
	LogReasonToolCallsCapped      LogReason = "tool_calls_per_round_capped"

	// Engine — transcript and budget.
	//
	//nolint:gosec // G101 false positive: "token" here is an LLM token count,
	// not a credential.
	LogReasonTokenBudgetExceeded LogReason = "token_budget_exceeded"
	//nolint:lll // Reason values are wire-stable strings; wrapping changes them.
	LogReasonBudgetUnreachable  LogReason = "budget_unreachable_after_compaction"
	LogReasonContextCeilingNear LogReason = "context_ceiling_near"
	LogReasonUnpairedToolCalls  LogReason = "unpaired_tool_calls"
	LogReasonOnErrorHookFailed  LogReason = "on_error_hook_failed"
	LogReasonNoDroppableUnit    LogReason = "no_droppable_unit"

	// Retry decorator — why the loop stopped.
	LogReasonErrorNotRetryable    LogReason = "error_not_retryable"
	LogReasonAlreadyStreamed      LogReason = "already_streamed"
	LogReasonMaxAttemptsExhausted LogReason = "max_attempts_exhausted"

	// Drivers.
	LogReasonStreamReadFailed     LogReason = "stream_read_failed"
	LogReasonFinishReasonUnmapped LogReason = "finish_reason_unmapped"
	//nolint:lll // Reason values are wire-stable; wrapping would change them.
	LogReasonProviderReasoningUndecodable LogReason = "provider_reasoning_undecodable"

	LogReasonProviderReasoningMismatch LogReason = "provider_reasoning_mismatch"
)

type Message

type Message struct {
	Role Role

	// Content is an ordered list of parts. Build the text-only case with
	// Text("..."); read it back with Message.Text() when a string is what you
	// need. Only user messages carry non-text parts on either provider — see
	// Capabilities for what each one accepts.
	Content Content

	ToolCalls         []ToolCall
	ToolCallID        string
	ToolResultIsError bool
	Reasoning         string
	ProviderReasoning json.RawMessage
	Origin            MessageOrigin
	Injection         *MessageInjection
	CacheHint         CacheHint
}

Message is one entry in the transcript. The wire fields are OpenAI-flat; drivers whose provider disagrees translate on the way out.

Origin, Injection and CacheHint are NON-WIRE — stripped before the provider request and used only by the engine and the caller.

func (Message) Text added in v0.2.0

func (m Message) Text() string

Text returns the message's text content, ignoring any non-text parts.

Shorthand for Content.String(), which is what nearly every caller wants: the engine's own text handling, logging, and any provider field that takes a bare string all read through here.

type MessageInjection

type MessageInjection struct {
	// Type is the role the injected message takes. Only RoleUser,
	// RoleAssistant and RoleSystem are usable — an injection is a NEW message,
	// so it can carry no tool_call_id, and a RoleTool injection would be an
	// orphan the provider rejects. Anything else (including the zero value) is
	// dropped with an ERROR log rather than written to the transcript.
	Type    Role
	Content string
	Phase   ToolPhase
	Tool    string
	CallID  string
	Round   int
}

type MessageInjector

type MessageInjector func(context.Context, *ToolEvent) (*MessageInjection, error)

type MessageOrigin

type MessageOrigin = string

MessageOrigin says who produced a message, so a caller can persist correctly even after compaction rewrote the transcript. Persist the Turn ones; Seed is already stored and Injection is ephemeral steering.

const (
	MessageOriginUnknown   MessageOrigin = ""
	MessageOriginSeed      MessageOrigin = "seed"
	MessageOriginTurn      MessageOrigin = "turn"
	MessageOriginInjection MessageOrigin = "injection"
)

type Model

type Model struct {
	ID                string
	ContextSize       int
	SupportsReasoning bool
	ReasoningLevels   ReasoningLevels
	Pricing           ModelPricing
}

func (Model) Cost

func (m Model) Cost(u Usage) float64

Cost prices ONE call's usage against this model's Pricing.

Hand it a single Usage, never a running total: the long-context threshold is evaluated against u.Prompt, so a summed total crosses it on volume alone and prices every round at the long-context rate. Sum per-round costs instead.

Returns 0 when the model has no Pricing — 0 means unknown, not free. Retry waste is not included; see Usage.BilledTotalTokens.

func (Model) IsReasoning

func (m Model) IsReasoning() bool

func (Model) ReasoningLevelHigh

func (m Model) ReasoningLevelHigh() ReasoningEffort

func (Model) ReasoningLevelLow

func (m Model) ReasoningLevelLow() ReasoningEffort

func (Model) ReasoningLevelMax

func (m Model) ReasoningLevelMax() ReasoningEffort

func (Model) ReasoningLevelMedium

func (m Model) ReasoningLevelMedium() ReasoningEffort

func (Model) ReasoningLevelMin

func (m Model) ReasoningLevelMin() ReasoningEffort

type ModelPricing

type ModelPricing struct {
	InputPerToken             float64
	OutputPerToken            float64
	CacheReadPerToken         float64
	CacheWritePerToken        float64
	CacheWriteLongTTLPerToken float64
	LongContextThreshold      int
	LongContextInputPerToken  float64
	LongContextOutputPerToken float64
}

type Option

type Option func(*clientConfig)

Option configures process-level defaults on a Client. Anything that varies per call belongs on Request instead.

func WithCapabilityOverride added in v0.2.0

func WithCapabilityOverride(
	override func(Model, Capabilities) Capabilities,
) Option

WithCapabilityOverride adjusts what the driver reports it can do.

A driver's Capabilities describe the PROVIDER'S API, because that is all a driver can know. Point one at a different backend — `WithBaseURL` aimed at an Anthropic-compatible or OpenAI-compatible gateway — and those answers stop being true: the wire format still matches, the model behind it may not read images at all. Without this the engine would pass content the gateway cannot serve and the failure would surface as a confusing upstream error.

A function of the model rather than a fixed struct, because capabilities are per-model: a single struct would flatten a gateway that fronts a vision model and a text-only one behind the same endpoint.

elelem.New(driver, elelem.WithCapabilityOverride(
    func(_ elelem.Model, caps elelem.Capabilities) elelem.Capabilities {
        // this gateway serves vision through MCP, not inline
        caps.SupportsImageInput = false
        return caps
    },
))

It can only be trusted to RESTRICT. Turning a flag on does not teach the driver a translation it does not have: the driver's own per-value gates still run — Anthropic's four-media-type image whitelist, its absent audio block — and still refuse. Widening a capability the driver cannot express moves the error later, it does not remove it.

func WithClientTokenCounter

func WithClientTokenCounter(counter TokenCounter) Option

WithClientTokenCounter sets the counter for every request off this client. It sits between the per-request override and the driver's own estimator in the resolution order: request → client → driver → package default → built-in.

func WithDefaultModel

func WithDefaultModel(model Model) Option

WithDefaultModel supplies the Model used when a Request sets none. Without it a model-less request falls back to a bare Model with no metadata, which silently disables the context-size checks.

func WithStreaming added in v0.4.0

func WithStreaming(enabled bool) Option

WithStreaming turns the provider's streaming mode on or off for every request this client makes. Default on.

This is deliberately NOT a capability override. Capabilities describe what the provider CAN do and WithCapabilityOverride may only ever restrict them; this is a choice between two things the provider supports equally, made by whoever knows what sits between you and it.

Turn it off when the PATH cannot deliver a stream even though the model can. The case that motivated it: an async queue proxy in front of the endpoint accepts the request, returns a job id immediately, and stores the upstream response to be fetched later — so a streamed body is buffered and replayed whole, and asking for one bought nothing but a stored SSE blob where a JSON object was expected. Same for a compat gateway that terminates the connection its own way.

// everything through this endpoint is queued, so never stream
elelem.New(driver, elelem.WithStreaming(false))

When the model reports SupportsStreaming false this is moot — the field is omitted entirely and the non-streaming path is the only one available.

Turning it off does NOT change what your callbacks receive. The driver feeds the completed response through the same delta callback, so OnDelta, OnText and every downstream accumulator still fire; the content simply arrives in one chunk instead of many.

type Part added in v0.2.0

type Part struct {
	Type PartType `json:"type"`

	// Text is set when Type is PartTypeText.
	Text string `json:"text,omitempty"`

	Image *ImageSource `json:"image,omitempty"`
	Audio *AudioSource `json:"audio,omitempty"`
	File  *FileSource  `json:"file,omitempty"`
}

Part is one piece of a message's content.

A tagged struct rather than an interface because Message round-trips through callers' storage as JSON, and an interface needs custom marshalling on both sides to survive that. ProviderReasoning already made the same trade.

func AudioBytes added in v0.2.0

func AudioBytes(data []byte, format AudioFormat) Part

AudioBytes builds an audio part. OpenAI only — see AudioSource.

func FileBytes added in v0.2.0

func FileBytes(data []byte, mediaType, filename string) Part

FileBytes builds a file part from raw bytes.

func FileRef added in v0.2.0

func FileRef(fileID string) Part

FileRef builds a file part referencing a file already uploaded to the provider. The id is provider-scoped.

func ImageBytes added in v0.2.0

func ImageBytes(data []byte, mediaType string) Part

ImageBytes builds an image part from raw bytes. The driver base64-encodes on the way out — do not pre-encode.

func ImageURL added in v0.2.0

func ImageURL(url string) Part

ImageURL builds an image part from a link, or from a `data:` URI when the caller already holds one in that form.

func TextOf added in v0.2.0

func TextOf(text string) Part

TextOf builds a text part, for composing multi-part content.

type PartType added in v0.2.0

type PartType = string

PartType identifies what a Part carries.

A string alias rather than a defined type so a driver can switch on it without a conversion, matching Role and FinishReason.

const (
	PartTypeText  PartType = "text"
	PartTypeImage PartType = "image"
	PartTypeAudio PartType = "audio"
	PartTypeFile  PartType = "file"
)

type Prompt added in v0.2.0

type Prompt struct {
	// contains filtered or unexported fields
}

Prompt is a whole conversation: a system message plus an ordered list of messages. It is what actually gets sent — the term names the entire thing rather than the last user turn, which is only one message in it.

IMMUTABLE. Every method returns a new Prompt, so one can be built once and run repeatedly, against several models, from several goroutines, with no chance of a later call mutating an earlier run's transcript.

The system message is a FIELD rather than a message at index 0. Two unrelated places used to depend on that position — the Anthropic driver hoisting it into the API's top-level `system` parameter, and history limiting pinning it against eviction — so "system is special" was a convention two files had to agree on by hand. Holding it as a field makes it true by construction and leaves exactly one place that decides where it goes.

func NewPrompt added in v0.2.0

func NewPrompt() Prompt

NewPrompt returns an empty Prompt. The zero value is equally usable; this exists to make the chain read as a builder.

func (Prompt) Add added in v0.2.0

func (p Prompt) Add(messages ...Message) Prompt

Add appends messages verbatim, for anything the typed helpers do not cover.

A message with no Origin is treated as this run's own output; an injection is dropped for the reason WithHistory gives.

func (Prompt) AppendSystem added in v0.2.0

func (p Prompt) AppendSystem(message string) Prompt

AppendSystem adds a fragment after the base system message.

This exists so composed code can add its own instructions without knowing, or clobbering, what the base prompt said — a library that needs one rule appended cannot safely call WithSystem, because it would erase the caller's. Fragments accumulate in call order and join with a blank line.

func (Prompt) AppendSystemf added in v0.2.0

func (p Prompt) AppendSystemf(format string, args ...any) Prompt

AppendSystemf is AppendSystem with fmt.Sprintf formatting.

func (Prompt) Assistant added in v0.2.0

func (p Prompt) Assistant(parts ...Part) Prompt

Assistant appends an assistant message, for replaying a turn the caller holds rather than one the engine produced.

func (Prompt) AssistantText added in v0.2.0

func (p Prompt) AssistantText(text string) Prompt

AssistantText appends a text-only assistant message.

func (Prompt) Len added in v0.2.0

func (p Prompt) Len() int

Len reports how many messages the prompt carries, system message included.

func (Prompt) Messages added in v0.2.0

func (p Prompt) Messages() []Message

Messages returns the full transcript the provider will see: the system message first when there is one, then everything else in order.

This is the ONLY place that decides the system message's position, which is what lets the driver and the limiter stop each maintaining their own copy of that rule.

func (Prompt) ResetSystemAppends added in v0.2.0

func (p Prompt) ResetSystemAppends() Prompt

ResetSystemAppends drops every appended fragment. The base message set by WithSystem survives.

func (Prompt) SystemMessage added in v0.2.0

func (p Prompt) SystemMessage() string

SystemMessage returns the assembled system message: the base followed by every appended fragment, blank-line separated, with empties dropped.

func (Prompt) ToolResult added in v0.2.0

func (p Prompt) ToolResult(callID, result string, isError bool) Prompt

ToolResult appends the answer to one tool call. The id must match a call the preceding assistant message made, or the provider rejects the transcript.

func (Prompt) User added in v0.2.0

func (p Prompt) User(parts ...Part) Prompt

User appends a user message built from content parts.

Variadic parts rather than a string because a user turn is the only place either provider accepts an image, audio or a document, and forcing those through a second method would make the multimodal case the awkward one.

func (Prompt) UserText added in v0.2.0

func (p Prompt) UserText(text string) Prompt

UserText appends a text-only user message. The common case.

func (Prompt) WithHistory added in v0.2.0

func (p Prompt) WithHistory(messages []Message) Prompt

WithHistory appends stored conversation history.

History is not a separate concept from the rest of the prompt — it is the same messages differing only in lifecycle, which Origin already records. So this is an append that stamps MessageOriginSeed, nothing more.

Messages a tool injected during an earlier run are dropped. An injection is scoped to the run that produced it and its injector re-creates it when the situation recurs; replaying a stored one instructs the model about a tool result that is no longer the subject, and every later turn inherits it.

func (Prompt) WithHistoryFrom added in v0.2.0

func (p Prompt) WithHistoryFrom(sequence iter.Seq[Message]) Prompt

WithHistoryFrom is WithHistory over a sequence, for a database cursor that would rather not materialise the whole transcript.

func (Prompt) WithSystem added in v0.2.0

func (p Prompt) WithSystem(message string) Prompt

WithSystem replaces the base system message.

func (Prompt) WithSystemf added in v0.2.0

func (p Prompt) WithSystemf(format string, args ...any) Prompt

WithSystemf is WithSystem with fmt.Sprintf formatting.

type ProviderError

type ProviderError struct {
	Cause           error
	StatusCode      int
	RetryAfterDelay time.Duration
	Code            string
}

ProviderError is a normalized upstream failure: the provider's own error code plus the HTTP status and any Retry-After, wrapped over a commonerrors sentinel so errors.Is works without knowing the provider.

func (*ProviderError) Error

func (e *ProviderError) Error() string

func (*ProviderError) ErrorCode

func (e *ProviderError) ErrorCode() string

ErrorCode returns the provider's own machine-readable code (for example "rate_limit_error"), empty when the provider sent none.

func (*ProviderError) HTTPStatus

func (e *ProviderError) HTTPStatus() int

HTTPStatus returns the upstream status, or 0 when the failure was not an HTTP error (a transport drop, for example).

func (*ProviderError) RetryAfter

func (e *ProviderError) RetryAfter() time.Duration

RetryAfter returns the provider-requested backoff parsed from the response header, or 0 when the provider did not ask for one.

func (*ProviderError) Unwrap

func (e *ProviderError) Unwrap() error

Unwrap exposes the underlying sentinel so errors.Is(err, commonerrors.ErrRateLimited) holds across the wrap layers.

type ReasoningDelta

type ReasoningDelta struct{ Text string }

ReasoningDelta is one streamed chunk of reasoning text. Only models with SupportsReasoning emit these, and only when the provider streams reasoning in the clear.

type ReasoningEffort

type ReasoningEffort = string

ReasoningEffort is a normalized reasoning level.

A constant existing here does NOT mean a given model accepts it — levels are gated per model on both providers. Build against Model.ReasoningLevel*() and Capabilities.MaxReasoningEffort, not against this list.

const (
	ReasoningEffortUnset   ReasoningEffort = ""
	ReasoningEffortNone    ReasoningEffort = "none"
	ReasoningEffortMinimal ReasoningEffort = "minimal"
	ReasoningEffortLow     ReasoningEffort = "low"
	ReasoningEffortMedium  ReasoningEffort = "medium"
	ReasoningEffortHigh    ReasoningEffort = "high"
	ReasoningEffortXHigh   ReasoningEffort = "xhigh"
	ReasoningEffortMax     ReasoningEffort = "max"
)

type ReasoningLevels

type ReasoningLevels struct{ Min, Low, Medium, High, Max ReasoningEffort }

type Request

type Request struct {
	// contains filtered or unexported fields
}

Request is one configured call, built with chained With* setters and then executed by Run, Complete, Stream, or CompleteInto.

Concurrency differs between the two halves of its life:

  • BUILDING is not safe. The With* setters write unsynchronized, so configure from one goroutine.
  • EXECUTING is safe. Run and friends never write back; each call snapshots the transcript into private run state, so a fully-built Request can run from many goroutines and be re-executed — it is not consumed.

func NewRequest

func NewRequest(client *Client) *Request

func (*Request) IsTokenLimitReached

func (r *Request) IsTokenLimitReached() (bool, error)

func (*Request) OnAssistantMessage

func (r *Request) OnAssistantMessage(
	fn func(context.Context, Message) error,
) *Request

func (*Request) OnDelta

func (r *Request) OnDelta(fn func(context.Context, Delta) error) *Request

func (*Request) OnError

func (r *Request) OnError(fn func(context.Context, error) error) *Request

func (*Request) OnFinish

func (r *Request) OnFinish(fn func(context.Context, *Response) error) *Request

func (*Request) OnMessageInjection

func (r *Request) OnMessageInjection(
	fn func(context.Context, MessageInjection) error,
) *Request

func (*Request) OnReasoning

func (r *Request) OnReasoning(
	fn func(context.Context, ReasoningDelta) error,
) *Request

func (*Request) OnRetry

func (r *Request) OnRetry(
	fn func(context.Context, RetryAttempt) error,
) *Request

func (*Request) OnRoundEnd

func (r *Request) OnRoundEnd(
	fn func(context.Context, *RoundEvent) error,
) *Request

func (*Request) OnRoundStart

func (r *Request) OnRoundStart(
	fn func(context.Context, *RoundEvent) error,
) *Request

func (*Request) OnStart

func (r *Request) OnStart(fn func(context.Context, *RunEvent) error) *Request

func (*Request) OnText

func (r *Request) OnText(fn func(context.Context, TextDelta) error) *Request

func (*Request) OnToolCallFragment

func (r *Request) OnToolCallFragment(
	fn func(context.Context, ToolCallDelta) error,
) *Request

func (*Request) OnToolCallStart

func (r *Request) OnToolCallStart(
	fn func(context.Context, ToolCallEvent) error,
) *Request

func (*Request) OnToolResult

func (r *Request) OnToolResult(
	fn func(context.Context, ToolCallEvent) error,
) *Request

func (*Request) PostMaxTokensReached

func (r *Request) PostMaxTokensReached(handler TokenLimitHandler) *Request

func (*Request) PreMaxTokensReached

func (r *Request) PreMaxTokensReached(handler TokenLimitHandler) *Request

func (*Request) ResetCallback added in v0.3.0

func (r *Request) ResetCallback(kinds ...CallbackKind) *Request

ResetCallback clears the handler chain for each named event, so the next On* call for it starts fresh instead of extending what is there.

This is how you replace ONE handler while leaving the others alone — swapping the text handler on a shared base request without disturbing its tool or error handling. ResetCallbacks does the same for all of them at once.

An unrecognized kind is ignored rather than silently clearing the wrong chain; the typed constants above are the whole valid set.

The switch below is one flat arm per kind with no nesting. Cyclomatic complexity counts all fourteen, but the only shape that scores lower is a map of clearing closures — which allocates on every call and hides the exhaustiveness this switch makes obvious.

func (*Request) ResetCallbacks added in v0.3.0

func (r *Request) ResetCallbacks() *Request

ResetCallbacks drops every registered handler, so the next On* call starts a fresh chain instead of extending the existing one.

This is how you REPLACE rather than add. It exists because a Request is re-executable and safe to run from several goroutines once built, which makes "configure a base request once, then derive variants" a real pattern — and chaining alone gives no way back out of it.

One method rather than fourteen ResetOnX: swapping a single handler on a shared template is not a thing worth a per-event API, while starting over is. Mirrors Prompt.ResetSystemAppends, which solves the same append-by-default problem the same way.

func (*Request) Run

func (r *Request) Run(ctx context.Context) (*Response, error)

Run executes the request: tools if any were configured, the agent loop if WithAutoToolCalls is on, streaming per WithStreaming.

Along with RunInto it is the whole launcher surface. It replaces Run/Complete/Stream, which were one private run() behind three names differing by two flags — neither of which the caller should have had to restate, because both were already implied by how the request was built.

func (*Request) RunInto added in v0.4.0

func (r *Request) RunInto(
	ctx context.Context,
	value any,
) (*Response, error)

RunInto executes the request and decodes the model's JSON reply into value, which must be a non-nil pointer. On any error value is left untouched — it never holds a half-decoded object. Tools are not sent, whatever the request carries; see cloneForStructuredResponse.

The target is a CALL argument rather than a builder option on purpose: a fully-built Request is safe to run from several goroutines, and a decode target living on the Request would be shared mutable state that every concurrent run wrote into.

func (*Request) WithAutoToolCalls

func (r *Request) WithAutoToolCalls() *Request

func (*Request) WithForceFinalAnswer

func (r *Request) WithForceFinalAnswer(value bool) *Request

func (*Request) WithFrequencyPenalty

func (r *Request) WithFrequencyPenalty(value float64) *Request

func (*Request) WithGenerationParams

func (r *Request) WithGenerationParams(params GenerationParams) *Request

func (*Request) WithJSONMode

func (r *Request) WithJSONMode() *Request

func (*Request) WithJSONSchema

func (r *Request) WithJSONSchema(
	name string,
	schema json.RawMessage,
	strict bool,
) *Request

func (*Request) WithMaxConcurrentTools

func (r *Request) WithMaxConcurrentTools(value int) *Request

func (*Request) WithMaxContextTokens

func (r *Request) WithMaxContextTokens(value int) *Request

func (*Request) WithMaxOutputTokens

func (r *Request) WithMaxOutputTokens(value int64) *Request

func (*Request) WithMaxRounds

func (r *Request) WithMaxRounds(value int) *Request

func (*Request) WithMaxToolResultTokens

func (r *Request) WithMaxToolResultTokens(value int) *Request

func (*Request) WithModel

func (r *Request) WithModel(model Model) *Request

func (*Request) WithOutputReserveTokens

func (r *Request) WithOutputReserveTokens(value int) *Request

func (*Request) WithParallelToolCalls

func (r *Request) WithParallelToolCalls(value bool) *Request

func (*Request) WithParam

func (r *Request) WithParam(name string, value any) *Request

func (*Request) WithParams

func (r *Request) WithParams(values map[string]any) *Request

func (*Request) WithPresencePenalty

func (r *Request) WithPresencePenalty(value float64) *Request

func (*Request) WithPrompt

func (r *Request) WithPrompt(prompt Prompt) *Request

WithPrompt sets the conversation to send: system message and every message, built with Prompt.

This replaces the previous WithSystemMessage / WithHistory / WithPrompt / WithMessages family. Those presented three concepts — a system message, a history, and "the prompt" — over a data model that was already one ordered list, and every one of them appended to the same slice. Naming the whole thing Prompt says what actually gets sent, and it is where multimodal content belongs, since a user turn is the only place a provider takes an image.

func (*Request) WithReasoningEffort

func (r *Request) WithReasoningEffort(value ReasoningEffort) *Request

func (*Request) WithResponseRepair

func (r *Request) WithResponseRepair() *Request

func (*Request) WithSeed

func (r *Request) WithSeed(value int64) *Request

func (*Request) WithStop

func (r *Request) WithStop(values ...string) *Request

func (*Request) WithStreaming added in v0.4.0

func (r *Request) WithStreaming(enabled bool) *Request

WithStreaming turns the provider's streaming mode on or off for THIS request, overriding whatever the client was built with. See elelem.WithStreaming for what the setting means and when to reach for it.

Prefer the client option when the reason is a property of the endpoint — "everything through this gateway is queued" is true of every request, not one of them.

func (*Request) WithStrictResponseValidation

func (r *Request) WithStrictResponseValidation() *Request

func (*Request) WithTemperature

func (r *Request) WithTemperature(value float64) *Request

func (*Request) WithTimeout

func (r *Request) WithTimeout(value time.Duration) *Request

func (*Request) WithTokenCounter

func (r *Request) WithTokenCounter(counter TokenCounter) *Request

func (*Request) WithTool

func (r *Request) WithTool(tool Tool) *Request

func (*Request) WithToolChoice

func (r *Request) WithToolChoice(choice ToolChoice) *Request

func (*Request) WithToolChoiceMode

func (r *Request) WithToolChoiceMode(mode ToolChoiceMode) *Request

func (*Request) WithToolProvider

func (r *Request) WithToolProvider(
	provider func(context.Context) (*ToolSet, error),
) *Request

func (*Request) WithToolTimeout

func (r *Request) WithToolTimeout(value time.Duration) *Request

func (*Request) WithTools

func (r *Request) WithTools(tools *ToolSet) *Request

func (*Request) WithTopP

func (r *Request) WithTopP(value float64) *Request

func (*Request) WithTranscriptRepair

func (r *Request) WithTranscriptRepair() *Request

WithTranscriptRepair DELETES messages before each round to make an illegal transcript legal: an assistant tool-call message missing any of its results (the whole unit goes), and any result answering no call. Providers reject both outright, so the choice is losing that exchange or failing the request.

Opt-in because it is the only option here that discards conversation. Reach for it when transcripts come from storage, where a run that died mid-tool leaves exactly this damage; leave it off in-process if you would rather see ErrInvalidTranscript. Every repair is logged at Warn with a count.

type Response

type Response struct {
	Text      string
	Reasoning string
	ToolCalls []ToolCall

	// Usage is the whole run's total, already summed across every round and
	// every tool loop — not just the final call.
	Usage Usage

	// Messages is the full transcript INCLUDING this turn's output, and is an
	// independent deep copy: ToolCalls, ProviderReasoning, and Injection are
	// each cloned, so retaining or mutating it cannot disturb the run that
	// produced it, nor the Request it came from. Feed it straight back into
	// the next Request to continue the conversation.
	Messages []Message

	// Injections lists the messages tool injectors added, in firing order.
	// They are already present in Messages; this is the audit trail.
	Injections []MessageInjection

	// Cost is the run's total in currency units, or 0 when the Model carries
	// no Pricing — 0 means unknown, not free. See Model.Cost.
	Cost float64

	Model            string
	FinishReason     FinishReason
	ExecuteToolCalls func(
		context.Context,
		...ToolCallDecision,
	) (*Response, error)
}

Response is the result of one completion. Usage and Messages are RUNNING TOTALS for the whole run, not just this round, so the final Response carries the grand total in both manual and auto tool-loop modes.

A nil ExecuteToolCalls IS the loop's terminating condition — it means the model gave its final answer rather than asking for tools.

type ResponseFormat

type ResponseFormat struct {
	Type         ResponseFormatType
	Name         string
	Schema       json.RawMessage
	StrictSchema bool
}

ResponseFormat constrains the model's own reply. This is one of only two places a schema legitimately applies — the other is tool arguments. Tool RESULTS are produced by our own handlers and are never schema-checked.

type ResponseFormatType

type ResponseFormatType = string

ResponseFormatType selects the structured-output mode for the ANSWER. JSONObject guarantees valid JSON but NOT schema conformance, and the model emits none at all unless the prompt also asks for JSON; JSONSchema is the constrained-decoding path.

const (
	ResponseFormatTypeUnset      ResponseFormatType = ""
	ResponseFormatTypeText       ResponseFormatType = "text"
	ResponseFormatTypeJSONObject ResponseFormatType = "json_object"
	ResponseFormatTypeJSONSchema ResponseFormatType = "json_schema"
)

type RetryAttempt

type RetryAttempt struct {
	Attempt  int
	Reason   RetryReason
	Err      error
	Status   int
	Delay    time.Duration
	Streamed bool
	Tokens   TokenCounts
}

RetryAttempt records one failed provider attempt and its billable tokens.

type RetryConfig

type RetryConfig struct {
	MaxAttempts       int
	InitialDelay      time.Duration
	MaxDelay          time.Duration
	Jitter            *bool
	RespectRetryAfter *bool
}

RetryConfig bounds the retry decorator. Retries are transient-only: 429, 5xx and connection failures. A 4xx config/prompt problem is never retried (it would just burn quota), and neither is a cancelled context.

type RetryInfo

type RetryInfo struct {
	TotalAttempts          int
	FailedAttempts         []RetryAttempt
	WastedPromptTokens     int64
	WastedCompletionTokens int64
	WastedTotalTokens      int64
}

RetryInfo summarizes the failed attempts for one provider call.

type RetryReason

type RetryReason = string
const (
	// RetryReasonUnset is the zero value: the attempt was not classified.
	RetryReasonUnset       RetryReason = ""
	RetryReasonRateLimited RetryReason = "rate_limited"
	RetryReasonServerError RetryReason = "server_error"
	RetryReasonTimeout     RetryReason = "timeout"
	RetryReasonTransport   RetryReason = "transport"
)

type Role

type Role = string

Role is the author of a message.

const (
	RoleUnknown   Role = ""
	RoleSystem    Role = "system"
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

type RoundEvent

type RoundEvent struct {
	Round     int
	MaxRounds int

	// Usage is THIS round alone; TotalUsage is the running sum across every
	// round so far. Summing Usage yourself double-counts.
	Usage      Usage
	TotalUsage Usage

	ToolCalls int
	Messages  []Message
	Tools     []Tool
}

RoundEvent is delivered once per completed provider call.

type RunEvent

type RunEvent struct {
	Model    Model
	Messages []Message
	Tools    []Tool
}

RunEvent is delivered once, before the first provider call, carrying the transcript and tool set the run starts with.

type Temperature

type Temperature = float64

Temperature is a sampling temperature. NOT universally accepted: newer models reject any non-default value, so gate on Capabilities.SupportsSamplingParams before setting it.

const (
	TemperaturePrecise  Temperature = 0
	TemperatureBalanced Temperature = 0.7
	TemperatureCreative Temperature = 1
)

type TextDelta

type TextDelta struct{ Text string }

TextDelta is one streamed chunk of answer text — a fragment, not a line or a token. Concatenate in arrival order.

type TokenCounter

type TokenCounter interface {
	Count([]Message, []Tool) (int, error)
}

TokenCounter estimates the prompt tokens a transcript plus tool schemas will cost. Estimates only: they gate elelem's own budget and compaction decisions, and never claim to match provider billing. Drivers whose provider exposes a real tokenizer return it from Driver.TokenCounter.

func DefaultTokenCounter

func DefaultTokenCounter() TokenCounter

DefaultTokenCounter returns the current process-wide fallback counter.

type TokenCounts

type TokenCounts struct {
	Prompt     int64
	Completion int64
	Total      int64
	Reasoning  int64
	CacheRead  int64

	// CacheWrite is ALL cache-write tokens, including CacheWriteLongTTL.
	CacheWrite int64

	// CacheWriteLongTTL is the ⊆ CacheWrite portion written at the longer TTL,
	// which providers bill at a higher multiple than the default TTL. Split out
	// because pricing the whole of CacheWrite at the short rate under-reports
	// materially the moment a caller opts into CacheHintLong.
	CacheWriteLongTTL int64
}

TokenCounts is one call's token breakdown. Reasoning is a subset of Completion; CacheRead and CacheWrite are subsets of Prompt — drivers whose provider reports cache tokens additively must fold them in.

type TokenLimitEvent

type TokenLimitEvent struct {
	// Messages is a COPY of the transcript, rewritten in place by the
	// handler; the engine adopts it only if the handler returns nil.
	//
	// An assistant message carrying ToolCalls must be kept with ALL of its
	// RoleTool results. Orphaning either side leaves a tool_call_id
	// unanswered, which the provider rejects on the NEXT request — a round
	// later, at an unrelated call site. DropOldestUnits honors this; a
	// custom handler must too.
	Messages []Message

	// Tools is the round's tool set, for counting only. Unlike Messages it
	// is NOT copied and is not read back by the engine — treat it as
	// read-only; mutating it reaches the live set.
	Tools []Tool

	// EstimatedTokens is refreshed by each IsOverBudget call.
	EstimatedTokens int

	BudgetTokens int
	Round        int
	// contains filtered or unexported fields
}

TokenLimitEvent carries the transcript that is over budget.

func (*TokenLimitEvent) IsOverBudget

func (e *TokenLimitEvent) IsOverBudget() (bool, error)

IsOverBudget recounts the CURRENT Messages/Tools and reports whether they still exceed the budget, refreshing EstimatedTokens as a side effect. Call it after every edit — a handler that trusts the initial EstimatedTokens is deciding against a stale count.

type TokenLimitHandler

type TokenLimitHandler func(context.Context, *TokenLimitEvent) error

TokenLimitHandler is called when a round's transcript is projected to exceed the budget. It reshapes event.Messages in place and returns nil; returning an error aborts the run.

func DropOldestUnits

func DropOldestUnits(counter TokenCounter) TokenLimitHandler

DropOldestUnits is the default TokenLimitHandler: it evicts the oldest droppable messages until the transcript fits.

It drops whole UNITS, never single messages — an assistant message with ToolCalls leaves together with all of its results, so no tool_call_id is ever orphaned. Deliberately preserved regardless of age: the leading system message, the most recent user message, and the in-flight tool exchange. Passing a nil counter keeps the one already on the event.

type Tool

type Tool struct {
	Name            string
	Description     string
	ArgumentsSchema json.RawMessage

	// StrictArguments makes the PROVIDER guarantee arguments match
	// ArgumentsSchema, instead of the model merely being asked to comply.
	//
	// Opt-in rather than default: not every model supports it, and a request
	// carrying it against one that does not is rejected outright
	// (ErrInvalidRequest) rather than degrading. Leaving it false is the
	// portable choice; setting it trades portability for the guarantee.
	StrictArguments bool

	// Timeout bounds this tool's WHOLE run — the PreRun and PostRun hooks and
	// any message injector, not only the Handler. Zero means no per-tool bound,
	// so the only limit is the caller's context.
	//
	// The hooks are inside the bound deliberately: they are caller code that
	// can block on a network call or a lock just as a handler can, and a
	// deadline starting after them would leave a hanging PreRun with nothing
	// able to interrupt it. Budget accordingly — a slow hook spends the
	// handler's share.
	Timeout time.Duration

	Handler ToolHandler

	// Firing order: PreRun → Handler → OnSuccess|OnError → the matching
	// injector → PostRun → PostRunMessageInjector.
	//
	// An error from any HOOK aborts the run (hooks are caller code, so their
	// failure is a caller failure); PreRun additionally skips the Handler. An
	// error from the HANDLER does the opposite — it becomes a tool error the
	// model can react to, and the loop continues. A panic in either never
	// aborts: it is recovered into a tool error, with the panic value logged
	// rather than written to the transcript.
	PreRun    ToolHook
	OnSuccess ToolHook
	OnError   ToolHook
	PostRun   ToolHook

	// Injectors add a message to the transcript after their phase's hook. A
	// nil return injects nothing.
	OnSuccessMessageInjector MessageInjector
	OnErrorMessageInjector   MessageInjector
	PostRunMessageInjector   MessageInjector
}

type ToolCall

type ToolCall struct {
	ID        string
	Name      string
	Arguments json.RawMessage
}

ToolCall is one tool invocation the model requested. Every ID must be answered by exactly one RoleTool message or the transcript is illegal and the provider rejects the whole request.

type ToolCallDecision

type ToolCallDecision struct {
	CallID     string
	Deny       bool
	DenyResult string
}

ToolCallDecision denies one pending call by id. There is deliberately no "skip": omitting a tool message for a tool_call_id makes the transcript protocol-illegal, so a denied call STILL emits a RoleTool message carrying DenyResult (or a default refusal) flagged as an error. The model sees the denial and can react, which is what you want from a gate anyway.

type ToolCallDelta

type ToolCallDelta struct {
	Index     int
	ID        string
	Name      string
	Arguments string
}

ToolCallDelta is a partial tool call: providers stream Arguments in pieces, so Index identifies which call in the round the fragment belongs to. Index is the ordinal among TOOL CALLS, not the provider's raw content-block index.

type ToolCallEvent

type ToolCallEvent struct {
	CallID    string
	Name      string
	Arguments json.RawMessage
	Index     int
	Result    *ToolResult
}

ToolCallEvent is the payload for BOTH tool callbacks, which is why Result is a pointer: OnToolCallStart receives it with Result nil (the call has been parsed, nothing has run), OnToolResult receives it with Result set. Each callback fires once per call.

The Result pointed at is a COPY of the outcome, so writing through it does not alter the transcript — use a PostRun hook for that.

type ToolChoice

type ToolChoice struct {
	Mode ToolChoiceMode
	Name string
}

ToolChoice is a struct rather than a bare string because mode and tool-name are two different things that map to two structurally different wire shapes. Flattening them would make a tool literally named "auto" or "required" unreachable. The zero value means unset, so an untouched request omits the field entirely.

func ToolChoiceTool

func ToolChoiceTool(name string) ToolChoice

ToolChoiceTool forces the model to call exactly the named tool. Naming a tool that is not in the ToolSet is rejected at build time, not by the provider.

type ToolChoiceMode

type ToolChoiceMode = string

ToolChoiceMode selects HOW the model may use tools this round. The zero value is unset, which leaves the decision to the provider's own default.

const (
	ToolChoiceModeUnset    ToolChoiceMode = ""
	ToolChoiceModeAuto     ToolChoiceMode = "auto"
	ToolChoiceModeNone     ToolChoiceMode = "none"
	ToolChoiceModeRequired ToolChoiceMode = "required"
	ToolChoiceModeTool     ToolChoiceMode = "tool"
)

type ToolEvent

type ToolEvent struct {
	// Phase is the hook currently running; the same event is threaded through
	// every phase of one call, so a hook can see what earlier ones did.
	Phase ToolPhase

	Tool         Tool
	CallID       string
	Round        int
	RawArguments json.RawMessage
	Messages     []Message

	// Result is mutable on purpose and authoritative after PostRun, so a hook
	// can rewrite or redact the handler's output. Setting it to nil does NOT
	// remove the tool message — an unanswered tool_call_id is protocol-illegal,
	// so the engine substitutes an error result; write empty Content instead.
	//
	// Each in-flight call owns its event: hooks for DIFFERENT calls run
	// concurrently and must synchronize shared state. Hooks for the SAME call
	// are sequential.
	Result *ToolResult

	// Err is the handler's error on the OnError path, nil otherwise.
	Err error
}

type ToolHandler

type ToolHandler func(context.Context, ToolInput) (ToolResult, error)

type ToolHook

type ToolHook func(context.Context, *ToolEvent) error

type ToolInput

type ToolInput struct {
	Name      string
	CallID    string
	Arguments json.RawMessage
}

type ToolPhase

type ToolPhase = string
const (
	ToolPhasePreRun    ToolPhase = "pre_run"
	ToolPhaseOnSuccess ToolPhase = "on_success"
	ToolPhaseOnError   ToolPhase = "on_error"
	ToolPhasePostRun   ToolPhase = "post_run"
)

type ToolResult

type ToolResult struct {
	Content  string
	IsError  bool
	Metadata map[string]any
}

func NewToolDeniedResult

func NewToolDeniedResult() ToolResult

NewToolDeniedResult creates the standard result for a caller-denied call.

func NewToolErrorResult

func NewToolErrorResult(content string) ToolResult

NewToolErrorResult marks model-visible tool output as an error.

type ToolSet

type ToolSet struct {
	// contains filtered or unexported fields
}

func NewToolSet

func NewToolSet(tools ...Tool) *ToolSet

func (*ToolSet) Add

func (s *ToolSet) Add(tool Tool) *ToolSet

func (*ToolSet) Definitions

func (s *ToolSet) Definitions() []Tool

func (*ToolSet) Get

func (s *ToolSet) Get(name string) (Tool, bool)

type Usage

type Usage struct {
	TokenCounts
	Model        string
	FinishReason FinishReason
	Retry        RetryInfo
}

Usage is one call's accounting: tokens, the model that served it, why it stopped, and what retrying cost.

func (Usage) BilledTotalTokens

func (u Usage) BilledTotalTokens() int64

BilledTotalTokens is Total plus the tokens burned by failed retry attempts — what the provider actually charges, as opposed to Total, which counts only the attempt that succeeded. Use this for cost, Total for context.

Directories

Path Synopsis
drivers
openai
Package openai adapts the official OpenAI Go SDK to elelem.Driver.
Package openai adapts the official OpenAI Go SDK to elelem.Driver.
Package elelemtest holds elelem's test doubles.
Package elelemtest holds elelem's test doubles.
conformance
Package conformance is the contract suite for people WRITING an elelem.Driver.
Package conformance is the contract suite for people WRITING an elelem.Driver.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL