llm

package module
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 25 Imported by: 0

README

go-llm-sdk

CI Go Reference Go

Multi-provider Go SDK for LLM inference endpoints — OpenAI, Google Gemini, DeepSeek, Z.ai, Kimi (Moonshot) and Anthropic, plus any OpenAI-compatible gateway. Stdlib only; zero external dependencies.

  • Multiple authenticated endpoints at once — auto-discovered from <PROVIDER>_API_KEY environment variables (aliases supported).
  • Dynamic model discovery — ListModels returns what the account can actually access. No static model tables.
  • One canonical API — OpenAI-shaped requests and responses; Anthropic and Gemini wire formats are translated for you.
  • Portable generation controls — token limits, temperature, top-p, stop sequences, thinking, and tools map to each provider's native fields.
  • Production streaming — SSE with an idle watchdog and a hard wall-clock deadline, abort-with-partial-result, retries that never duplicate partial output, premature-close detection, and learn-once fallbacks for providers that reject stream_options, streaming, or reasoning_effort+tools (GPT-5.6+ retries on /v1/responses so reasoning stays on).
  • Predictable under load — goroutine-leak-free streaming, race-clean shared state, and a canonical-only error vocabulary (API keys never leak into error text).

Install

go get github.com/BackendStack21/go-llm-sdk@v1.1.0

Requires Go 1.25+. No dependencies beyond the standard library.

Quickstart

package main

import (
	"context"
	"fmt"
	"log"

	llm "github.com/BackendStack21/go-llm-sdk"
)

func main() {
	ctx := context.Background()
	sdk := llm.New(llm.FromEnv()) // OPENAI_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY,
	                             // ZAI_API_KEY, KIMI_API_KEY, ANTHROPIC_API_KEY (+ aliases)

	for _, p := range sdk.Providers() { // authenticated endpoints, registry order
		models, err := p.ListModels(ctx)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%s: %d models\n", p.ID(), len(models))
		// models[i] → ID, DisplayName, CreatedAt, ContextWindow, MaxOutputTokens, Capabilities
	}

	chat, err := sdk.Chat("deepseek", "deepseek-chat")
	if err != nil {
		log.Fatal(err)
	}

	res, err := chat.Call(ctx, &llm.ChatRequest{
		System:   []llm.SystemBlock{{Text: "Be terse."}},
		Messages: []llm.Message{{Role: llm.RoleUser, Content: "Hello"}},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(res.Content)
}

Streaming:

res, err = chat.CallStream(ctx, req, func(d llm.Delta) error {
	switch d.Kind {
	case llm.DeltaReasoning: // thinking fragment
	case llm.DeltaContent:   // text fragment
	case llm.DeltaRefusal:    // refusal-text fragment (OpenAI, Responses; Anthropic: one delta with the explanation)
	case llm.DeltaToolArgs:  // tool-call argument fragment (d.ToolID, d.ToolName; d.ToolIndex = position in res.ToolCalls)
	}
	return nil // or an error to abort — partial result comes back with *StreamAbortedError
})

Providers

ID Format Default base URL Env var (alias) Base-URL override
openai openai https://api.openai.com/v1 OPENAI_API_KEY OPENAI_BASE_URL
gemini gemini https://generativelanguage.googleapis.com GEMINI_API_KEY (GOOGLE_API_KEY) GEMINI_BASE_URL
deepseek openai https://api.deepseek.com DEEPSEEK_API_KEY DEEPSEEK_BASE_URL
zai openai https://api.z.ai/api/paas/v4 ZAI_API_KEY ZAI_BASE_URL (e.g. coding-plan endpoint)
kimi openai https://api.moonshot.ai/v1 KIMI_API_KEY (MOONSHOT_API_KEY) KIMI_BASE_URL
anthropic anthropic https://api.anthropic.com ANTHROPIC_API_KEY ANTHROPIC_BASE_URL

Primary env var beats its alias. Explicit keys (WithAPIKey) beat env. Base-URL overrides accept any gateway speaking the provider's format. A bad URL passed to WithBaseURL (wrong scheme or malformed) is rejected at wiring time: the provider is marked invalid, Providers() omits it, and Chat returns a *ConfigError — no request is sent.

Custom gateways:

sdk := llm.New(llm.WithProvider("my-gateway",
	llm.WithFormat(llm.FormatOpenAI),
	llm.WithBaseURL("http://localhost:11434/v1"),
	llm.WithAPIKey("local"),
))

Extra HTTP headers ride every request a provider makes — chat, streaming, model listing, speech, transcription, embeddings. They are applied last, so they can override the SDK's own; an empty value removes a header. Values are treated like keys: never logged, never in String() or errors.

llm.WithProvider("openrouter", llm.WithFormat(llm.FormatOpenAI),
	llm.WithBaseURL("https://openrouter.ai/api/v1"), llm.WithEnvKeys("OPENROUTER_API_KEY"),
	llm.WithHeaders(map[string]string{"HTTP-Referer": "https://myapp.example", "X-Title": "myapp"}))

llm.WithProvider("anthropic", llm.WithHeaders(map[string]string{"anthropic-beta": "interleaved-thinking-2025-05-14"}))

// Anthropic-format gateway without output_config: keep the forced-tool JSON emulation.
llm.WithProvider("gw", llm.WithFormat(llm.FormatAnthropic), llm.WithBaseURL(gwURL), llm.WithAPIKey(key),
	llm.WithQuirks(llm.Quirks{AnthropicToolJSON: true}))

// api-key gateways (e.g. Azure OpenAI): drop the Bearer header, send api-key.
llm.WithProvider("azure", llm.WithFormat(llm.FormatOpenAI), llm.WithBaseURL(azureURL), llm.WithAPIKey(key),
	llm.WithHeaders(map[string]string{"Authorization": "", "api-key": key}))

Redirects to another host, and https-to-http scheme downgrades on the same host, are never followed (the redirect response surfaces as an *APIError): Go strips only Authorization across hosts, so x-api-key, x-goog-api-key and custom headers would otherwise leak to the target. Provider and ProviderConfig redact the key and header values under every fmt verb (%v, %+v, %#v).

For request/response observability (logging, metrics, tracing), wrap the transport: llm.WithTransport(myRoundTripper) sees every HTTP exchange the SDK makes.

Canonical API

Requests and results are provider-neutral. Unknown message roles are rejected at the SDK boundary (never silently dropped or reinterpreted).

User messages and tool results may contain ordered text and inline image parts. Content remains the backwards-compatible plain-text form; use TextPart and ImagePart when an image is needed. Parts are user- and tool-role only and cannot be combined with Content on the same message; empty text parts are rejected. Accepted image types are png, jpeg, gif, and webp (the informal image/jpg alias is accepted and normalized to image/jpeg on the wire). Per-image (MaxImageBytes, 10 MiB) and per-request aggregate (MaxRequestImageBytes, 32 MiB) caps are enforced before any network I/O. All validation failures are typed *ConfigError.

Empty text never reaches Anthropic or Gemini as an empty block: empty system text (SystemBlock, in-band system messages) is skipped; a user message with no Content and no Parts is a *ConfigError; an assistant turn with no text, thinking or tool calls is dropped on Anthropic and the surrounding user turns are merged so roles still alternate. Gemini tool Parameters are sent as parametersJsonSchema (full JSON Schema: $ref/$defs, const, type arrays). Gemini model ids may carry a models/ prefix on every endpoint (chat, stream, embeddings, speech); ids are path-escaped.

req := &llm.ChatRequest{Messages: []llm.Message{{
	Role: llm.RoleUser,
	Parts: []llm.ContentPart{
		llm.TextPart("Describe this image:"),
		llm.ImagePart("image/png", pngBytes),
	},
}}}
Documents and image URLs

Besides TextPart and ImagePart, user and tool messages accept:

  • DocumentPart(mime, data, name): an inline document, application/pdf or text/plain (lower-case, no parameters). Up to MaxDocumentBytes (20 MiB) each; document bytes count toward the same MaxRequestImageBytes aggregate cap as inline images.
  • ImageURLPart(url) / ImageURLPartMIME(url, mime) / part.WithMIME(mime): a remote http/https image the provider fetches (the SDK never fetches it; data:, file: and every other scheme are rejected). The optional MIME must be an image type.
Format Document Image URL
OpenAI chat file part, PDF only image_url
Responses input_file, PDF only input_image
Anthropic document (PDF base64, text/plain text source) image with url source (MIME hint ignored)
Gemini inlineData fileData; a MIME type is required (*ConfigError otherwise)

In tool results, documents are accepted on Anthropic and Gemini and are a *ConfigError on OpenAI chat and the Responses API; image URLs ride the post-tool user message (OpenAI chat) or function_call_output (Responses).

type ChatRequest struct {
	Model          string         // optional; ChatClient's model wins when both set
	Messages       []Message      // RoleUser | RoleAssistant | RoleSystem | RoleTool; Message.Cache → Anthropic user-block cache_control
	System         []SystemBlock  // {Text, Cache} — Cache marks Anthropic prompt-cache blocks
	Tools          []ToolDef      // {Name, Description, Parameters json.RawMessage}
	Thinking       string         // "", "enabled", "disabled", "low", "medium", "high", "max"
	ThinkingBudget int            // explicit token budget where the provider supports it
	MaxTokens      int            // routed to max_completion_tokens on o-series/gpt-5
	Temperature    float64        // 0 = provider default; negative = explicit 0
	TopP           float64        // 0 = provider default; negative = explicit 0
	Stop           []string       // provider-native stop / stop_sequences / stopSequences (not sent on /responses)
	ToolChoice        *ToolChoice     // nil = provider default; see "Request controls"
	ParallelToolCalls *bool           // with Tools: allow/forbid several calls per turn
	ResponseFormat    *ResponseFormat // JSON object / JSON Schema structured output
	Seed              *int            // OpenAI chat completions, Gemini
	Extra             map[string]any  // provider-specific top-level body fields (override the SDK's; Gemini generationConfig merges key by key)
}

type ChatResult struct {
	Content           string
	ReasoningContent  string          // provider thinking text; replay rules per provider in "Extended thinking"
	ThinkingSignature string          // last signature (Anthropic / Responses / Gemini text part)
	ThinkingBlocks    []ThinkingBlock // every signed reasoning segment, in order (Anthropic, Responses)
	ToolCalls         []ToolCall      // {ID, Name, Arguments, Signature (Gemini thoughtSignature)}
	FinishReason      string          // stop | length | tool_calls | content_filter | ""
	Refusal           string          // provider refusal text ("" when none); Content is never altered
	Meta              llm.ResponseMeta // provider-reported id/model/request id/rate-limit headroom (zero when unreported)
	Usage             Usage           // see "Usage" below
}

Refusal carries the provider's refusal text (OpenAI message.refusal, Responses refusal parts, Anthropic stop_details.explanation; Gemini has none) and is streamed as DeltaRefusal. A refusal on a stop/unmapped finish reports content_filter; length/tool_calls are kept. AssistantMessage() does not replay it.

Meta (ResponseMeta, also on EmbedResult, SpeakResult, TranscribeResult) is provider-reported or zero, never guessed: ID (OpenAI/Responses id, Anthropic id, Gemini responseId), Model (model, Gemini modelVersion), RequestID (first of x-request-id, request-id, x-goog-request-id, x-amzn-requestid) and RateLimit (Reported, RemainingRequests, RemainingTokens, ResetRequests, ResetTokens, from x-ratelimit-* with anthropic-ratelimit-* as per-field fallback; unparseable values stay zero). Headers come from the successful attempt only, buffered and streaming. ID/Model are empty on Embed/Speak/Transcribe.

Finish reasons are canonical: anything a provider reports outside that vocabulary maps to "" (unknown) rather than leaking provider-specific strings. A tool-call turn that ends normally (provider stop/STOP/end_turn, or the format's tool signal) finishes as tool_calls on every format (Gemini and OpenAI-format providers with a named tool_choice report STOP/stop for it; the SDK maps it). A truncation (length), filter (content_filter) or unmapped reason keeps that value (or "") even when calls are present, because the calls may be incomplete. A tool call with no arguments carries {} in Arguments on every format, buffered and streaming.

Signatures (ThinkingSignature, ThinkingBlocks, ToolCall.Signature) are provider-specific: replay them only to the provider that produced them — never carry a conversation's signed turns across providers.

res.AssistantMessage() returns the assistant turn to append before the next request, carrying every replay field (content, reasoning, signatures, thinking blocks, tool calls) — use it instead of copying fields by hand.

Usage

One meaning on every format:

  • PromptTokens — uncached input only. Inclusive provider counts (OpenAI cached_tokens, DeepSeek prompt_cache_hit_tokens, Gemini cachedContentTokenCount) are moved to CacheReadTokens.
  • CacheReadTokens / CacheCreationTokens — cache reads and writes. A DeepSeek cache miss is ordinary input, not a write, and stays in PromptTokens.
  • CompletionTokens — every generated token, reasoning included (Gemini thoughts are added); ReasoningTokens is that subset.
  • CachedTokens — the raw provider-reported cached count, for diagnostics; already inside CacheReadTokens, never add it.
  • InputTokens() = prompt + cache reads + cache writes; TotalTokens() = input + completion. Use these instead of summing by hand.

Request controls

req := &llm.ChatRequest{
	Messages: msgs,
	Tools:    tools,
	ToolChoice:        llm.ToolChoiceNamed("lookup"),         // or &llm.ToolChoice{Mode: llm.ToolChoiceRequired}
	ParallelToolCalls: &no,
	ResponseFormat:    &llm.ResponseFormat{Type: llm.ResponseJSONSchema, Name: "answer", Schema: schema, Strict: true},
	Seed:              &seed,
	Extra:             map[string]any{"service_tier": "flex"},
}
Control OpenAI chat / Responses Anthropic Gemini
ToolChoice auto / none / required / tool tool_choice tool_choice auto / none / any / tool toolConfig.functionCallingConfig AUTO / NONE / ANY (+ allowedFunctionNames)
ParallelToolCalls parallel_tool_calls (only with tools) disable_parallel_tool_use — (ignored)
ResponseFormat json_object / json_schema response_format / text.format native output_config.format (json_schema; json_object sends {"type":"object"}), merged with any thinking effort into one output_config; the reply text is returned as-is in Content responseMimeType + responseJsonSchema
Seed seed (chat completions only) — (ignored) generationConfig.seed
Extra merged into the body merged into the body merged into the body

Caveats: DeepSeek accepts json_object only (Quirks.NoJSONSchema; json_schema fails fast with a ConfigError — set the flag via WithQuirks for other gateways with the same limit). On Anthropic, JSON mode is native and combines with Tools, ToolChoice and thinking; the API decides which schemas it accepts. Gateways lacking output_config can set Quirks.AnthropicToolJSON, which keeps the older forced-synthetic-tool emulation folded back into Content (it cannot combine with Tools, ToolChoice or thinking, and needs an object schema). Gemini 2.x models reject JSON mode together with function declarations; Gemini 3 accepts it. Extra is a shallow, top-level override: an object value replaces the SDK's whole object, except Gemini generationConfig, whose keys are set one by one over the SDK's (so Extra["generationConfig"] = {"candidateCount": 2} keeps maxOutputTokens, topK and the rest; an Extra key wins; nested objects like thinkingConfig are replaced whole). Keys go to whichever endpoint the request lands on (including /responses for diverted OpenAI calls). It may not replace what the SDK validates (model, messages/contents/input, system/systemInstruction/instructions, tools) or stream. Gemini seeds must fit in 32 bits. TopK (0 = provider default, negative is a ConfigError) maps to Anthropic top_k (omitted when thinking is on) and Gemini generationConfig.topK; OpenAI chat and Responses ignore it. EmbedRequest.TaskType (e.g. RETRIEVAL_QUERY) is sent as Gemini taskType on every batch item and ignored by OpenAI-format providers.

Controls are validated before any network I/O (*ConfigError): ToolChoice needs Tools and a known tool name; json_schema needs a JSON object schema; names match [A-Za-z0-9_-]{1,64}; Extra may not set the reserved keys above. Anthropic rejects forced tool use under budget-based extended thinking, so ToolChoice required/tool fails fast when ThinkingBudget > 0 (adaptive thinking passes a forced ToolChoice through; native JSON mode adds no tool, so it is unaffected).

Per-request Headers

ChatRequest, EmbedRequest, SpeakRequest and TranscribeRequest carry Headers map[string]string, merged last (after auth and the provider's WithHeaders) for that call only, on every retry attempt. An empty value removes a non-reserved header for the call. Reserved names (case-insensitive) are always rejected with a *ConfigError: Authorization, Proxy-Authorization, X-Api-Key, X-Goog-Api-Key, Host, Content-Length, Content-Type, Transfer-Encoding. anthropic-version and anthropic-beta are not reserved. Names must be RFC 7230 tokens and names are case-insensitive, so two keys that differ only in case are rejected; values may contain no ASCII control byte except HTAB (CR, LF, NUL and DEL are rejected); violations fail before any network I/O and the error never contains the value. WithHeaders is unchanged and not validated.

Observability

llm.WithAttemptObserver(func(llm.AttemptEvent)) reports every HTTP attempt on chat (buffered and streaming), embeddings, speech, transcription and model listing, retries and learn-once immediate retries included. AttemptEvent carries Provider, Operation (chat|stream|embed|speak|transcribe|models), Model (empty for models), Attempt (1-based per call), Status (0 on transport error), Err (nil on success; the API key is masked), Latency (request start to response headers), RetryDelay (the wait before the next attempt, 0 on the last) and RequestID. A stream that fails after headers reports that failure on its attempt's event. The observer runs on the request goroutine: keep it fast and never call back into the SDK. A panic in it is recovered and discarded. Per-response data lives in ChatResult.Meta (ResponseMeta with RateLimit), see above.

Streaming semantics

CallStream enforces four guarantees, each covered by regression tests:

  1. Idle watchdog — a stream silent longer than the idle timeout (120s default; per SDK via WithStreamIdleTimeout, process-wide default via the race-safe SetStreamIdleTimeout; positive values only) fails with ErrIdleTimeout. Keepalive comments reset it; time spent in your onDelta callback never counts, only provider silence. A provider {"error":...} chunk (OpenAI-compat, Gemini, plus Anthropic/Responses error events) fails the stream with the provider message, and a bare [DONE] with nothing emitted is a premature-close error. The parser tolerates a leading UTF-8 BOM and \n, \r\n or bare \r line endings.
  2. Hard wall-clock deadline — the whole stream is bounded by the per-request timeout (WithRequestTimeout, default 120s; per-client via SetRequestTimeout).
  3. Retries never duplicate output — retries happen only before the first emitted delta. A failure after partial output returns the partial *ChatResult plus a wrapped error and is never retried.
  4. No silent truncation — a provider that closes the stream before any completion signal ([DONE], a finish_reason, message_stop, a Gemini finishReason, response.completed) yields an error: retryable before the first delta, the partial result plus a wrapped error after it. Gemini is no longer exempt. An unmapped provider finish reason still counts as completion.

Aborting from the delta handler returns the partial result alongside *StreamAbortedError — the parser goroutine is always released, so aborted streams leak nothing.

Tool-call loop
for {
	res, err := chat.CallStream(ctx, req, func(d llm.Delta) error { return nil })
	if err != nil {
		return err
	}
	if len(res.ToolCalls) == 0 {
		return nil
	}
	req.Messages = append(req.Messages, res.AssistantMessage()) // every replay field
	for _, tc := range res.ToolCalls {
		out, err := execute(tc)
		req.Messages = append(req.Messages,
			llm.Message{Role: llm.RoleTool, ToolCallID: tc.ID, ToolName: tc.Name, Content: out, IsError: err != nil})
	}
}

IsError marks a failed tool execution (Anthropic is_error, Gemini {"error": …}; OpenAI formats have no flag, so describe the failure in Content). Tool results may carry image Parts: Anthropic and the Responses API take them natively, Gemini sends them as inlineData in the same turn, and Chat Completions (whose tool messages are text-only) receives them in a user message right after the tool results.

RunTools runs this loop for you (buffered Call only):

res, transcript, err := llm.RunTools(ctx, chat, req, map[string]llm.ToolHandler{
	"get_weather": func(ctx context.Context, c llm.ToolCall) (string, error) { return lookup(c.Arguments) },
}, llm.ToolLoopOptions{MaxTurns: 8, Parallel: true})

MaxTurns bounds chat calls (default 8); Parallel runs one turn's handlers concurrently while results are appended in call order; OnTurn observes each turn. A handler error, panic or unknown tool becomes an IsError result and the loop continues. Assistant turns are replayed verbatim (thinking blocks, signatures). If the model still wants tools after MaxTurns, you get the last result, the transcript without the unanswered assistant turn, and llm.ErrMaxToolTurns. req.Messages is never mutated; the returned transcript is the full conversation.

The context is checked before every chat call and before each handler. On cancellation RunTools stops starting handlers (those not yet started record an IsError result carrying the context error) and returns the last completed *ChatResult, the transcript so far, and the context error (errors.Is(err, context.Canceled) / context.DeadlineExceeded).

On Gemini, a tool result's ToolName may be omitted — the SDK recovers the function name from the assistant ToolCall it answers, and errors loudly if it cannot.

Extended thinking

  • OpenAI GPT-5.6+ — function tools plus reasoning cannot ride Chat Completions (reasoning_effort 400s). Those calls go to POST /v1/responses with reasoning.effort and reasoning.summary=auto; summaries land in ReasoningContent, every reasoning item lands in ThinkingBlocks (encrypted content as Signature) and is replayed in order; the legacy ThinkingSignature carries the last one. Thinking: disabled stays on Chat Completions with reasoning_effort: none. Other OpenAI models keep reasoning_effort on Chat Completions.
  • Anthropic — every thinking and redacted_thinking block is parsed in both buffered and streaming modes into ChatResult.ThinkingBlocks, each with its own signature (redacted blocks keep their opaque data). For tool loops, replay them via Message.ThinkingBlocks (or res.AssistantMessage()): the SDK re-serializes them first, in order, as Anthropic's API requires. The legacy single pair (ReasoningContent + ThinkingSignature) still works for one-block turns. Unsigned thinking replay is rejected locally with ConfigError. Thinking presets use adaptive thinking: thinking: {type: adaptive} plus output_config.effort set to low/medium/high/max (enabled sends adaptive with no effort), never budget_tokens. Unset MaxTokens defaults to 16384 under adaptive thinking (an explicit value is sent as-is). A positive ThinkingBudget is the escape hatch for models that need the legacy form (e.g. Haiku 4.5 and older): thinking: {type: enabled, budget_tokens: N} (N >= 1024), max_tokens above N (unset → N + 8192; an impossible budget is a ConfigError), and forced tool use is rejected. With thinking on, temperature / top_p are omitted. Extra["output_config"] replaces the SDK-generated object, effort and JSON format included (shallow merge).
  • DeepSeek — reasoning streams as DeltaReasoning fragments and lands in ReasoningContent. Assistant-turn replay echoes it as reasoning_content, which DeepSeek requires for tool loops. On a chat-completions request that carries tools, the key is echoed for every assistant turn, even where the provider returned no reasoning for that turn (empty value instead of a dropped key) — including turns that made no tool call themselves: DeepSeek documents that a tool-bearing request whose assistant turns omit reasoning_content returns 400 for every later request in the loop, so one elided turn would otherwise poison the rest of the session. Whether DeepSeek accepts a present-but-empty value is measured against the live API by TestE2EDeepSeekEmptyReasoningEcho (tag-gated), not assumed by the suite.
  • zai / GLM — same reasoning field shape and DeltaReasoning streaming; GLM maps thinking medium → reasoning_effort high (no medium level) and max → max. GLM does not get the empty echo: no z.ai documentation confirms the same replay requirement, so Quirks.EchoReasoningWithTools is off and zai bodies are byte-identical to the pre-fix shape. A custom provider registered under its own id inherits no quirks — set WithQuirks explicitly if it needs the echo.
  • Gemini — a prompt blocked by safety (promptFeedback.blockReason, no candidates) completes with content_filter instead of erroring. thought: true parts map to reasoning deltas; thinkingConfig is derived from Thinking / ThinkingBudget. thoughtSignatures are captured per function call (ToolCall.Signature) and on text parts (ThinkingSignature) and replayed on the same parts — Gemini 3 rejects a function-call turn whose signature is missing.
  • OpenAI-format gateways (OpenRouter, LiteLLM, vLLM) — responses are accepted in every documented reasoning shape: reasoning_content (LiteLLM standardized), the reasoning string alias (OpenRouter), and the typed reasoning_details array (OpenRouter; reasoning.text/reasoning.summary fold in order, reasoning.encrypted entries are skipped). Precedence: reasoning_content, then reasoning, then the reasoning_details fold. Gateways that require an explicit opt-in get it via Quirks.IncludeReasoning, which sends the OpenRouter-documented legacy include_reasoning: true (equivalent to the canonical reasoning: {}) next to reasoning_effort. The flag is off by default — strict OpenAI-format endpoints reject unknown parameters.

ThinkingBudget, when positive, overrides the selected non-disabled thinking preset (Anthropic switches to the legacy budget_tokens form, 1024-token minimum). Canonical max selects the highest portable preset: OpenAI high, Gemini 24576, Anthropic effort max; GLM retains its native max.

Learn-once fallbacks

When a provider rejects a request pattern, the SDK learns the constraint once per provider (shared across every ChatClient you mint) and never re-pays the failed round-trip:

Trigger (provider 400) Learned fallback
Rejects stream_options omit stream_options from streaming requests
Names /v1/responses as the tools+reasoning path retry on POST /responses (keeps reasoning on)
Rejects reasoning_effort + tools (legacy) pin reasoning_effort: "none"
Rejects streaming itself downgrade to buffered calls permanently
Answers a streamed request with a non-SSE body consume that JSON response directly, then use buffered calls permanently (no duplicate generation)

Every engagement is observable: WithLearnObserver(func(LearnEvent)) registers a per-SDK callback (taking precedence over the process-wide SetLearnObserver) fired once per engaged fallback, with the kind (buffered, responses, none_effort, drop_stream_options), provider id, triggering HTTP status, and the provider's error message. Nil (default) disables observation. The callback runs on the request goroutine — keep it fast and never call back into the SDK from it.

Retry policy

8 attempts, exponential backoff capped at 30s with ±20% jitter (both configurable per SDK with WithRetryPolicy(llm.RetryPolicy{MaxAttempts, MaxBackoff}); MaxAttempts: 1 disables retries), Retry-After (seconds or HTTP-date) honored and capped at 120s, context cancellation honored between and during attempts. Retryable statuses include 408/429/5xx plus Cloudflare 520–524 and Anthropic 529. Persistent 429s surface as *llm.RateLimitError{Attempts, RetryAfter} — including when the retry sleep is cut short by a deadline, so the caller never loses the retry signal. When a backoff or Retry-After wait would outlive the context deadline, the SDK does not sleep: it returns the last *APIError / transport error immediately, also matching context.DeadlineExceeded under errors.Is. Oversized response bodies (50MB; 8MB for model lists) are terminal and never retried. A 429 whose body is billing exhaustion (insufficient_quota, exceeded your current quota, insufficient balance, no resource package) is not retryable and fails on the first attempt. RateLimitError unwraps to *APIError for errors.As access to Status/Retryable.

Timeouts & cancellation

  • The pooled transport honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY via http.ProxyFromEnvironment.
  • The request timeout (WithRequestTimeout, per-client SetRequestTimeout — race-safe, swap is atomic) is the wall-clock budget of the whole call, retries included, for buffered chat, streaming, speech, transcription and embeddings alike — a failing provider can never hold a caller for timeout × attempts.
  • Streaming: per-attempt SSE reads are additionally bounded by the idle watchdog.
  • Every wait (backoff, Retry-After, stream reads) selects on the caller's context — cancellation propagates everywhere, and a cancelled call never misreports as "retry exhausted".
  • Response bodies are capped (50 MB chat, 8 MB listings, 1 MiB SSE lines, 4 MiB SSE events) as an OOM bound.

Error handling

*ConfigError (unknown/unauthenticated provider, invalid wiring, unknown role, no model), *APIError{Provider, Status, Code, Message, Retryable}, *RateLimitError{Attempts, RetryAfter} (unwraps to *APIError), *StreamAbortedError (returned together with the partial *ChatResult). A stream failure after partial output returns the partial *ChatResult plus a wrapped error and is never retried; the idle watchdog surfaces as ErrIdleTimeout (retried only before the first delta); wall-clock deadlines surface as context deadline errors. Recommended classification:

var abort *llm.StreamAbortedError
var rl *llm.RateLimitError
var ae *llm.APIError
switch {
case errors.As(err, &abort):                 // consumer abort (partial result returned)
case errors.As(err, &rl):                    // back off rl.RetryAfter
case errors.As(err, &ae):                    // provider said no (ae.Status)
case errors.Is(err, llm.ErrIdleTimeout):     // stream went silent
case errors.Is(err, context.DeadlineExceeded): // wall-clock budget spent
}

API keys never appear in any error text. Provider error bodies are parsed per format (nested OpenAI envelope, Anthropic error.type/message, Gemini error.status/message) with a 512-byte raw-body fallback.

Text-to-speech

Speak synthesizes speech via a provider's TTS endpoint. Supported wire formats: OpenAI-compatible (POST {base}/audio/speech, binary audio) and Gemini (generateContent with AUDIO response modality, base64 inlineData). Other formats return a ConfigError.

res, err := sdk.Speak(ctx, "openai", "tts-1", llm.SpeakRequest{
    Text:  "Hello from go-llm-sdk",
    Voice: "alloy",           // required — no local default (never guessed)
    Format: "mp3",            // OpenAI-compat response_format (default "mp3")
    Speed: 1.0,               // optional, OpenAI-compat only
})
// res.Audio    — raw audio bytes exactly as the provider returned them
// res.Model    — the model that produced the audio
// res.MIMEType — the provider's Content-Type, or audio/mpeg when omitted

The SDK never transcodes: it returns the provider's bytes plus the MIME type the provider declared, falling back to the wire format's well-known default (audio/mpeg on the OpenAI path; Gemini's inlineData.mimeType is always present). A 2xx JSON error envelope from a gateway surfaces as a typed *APIError — JSON is never returned as audio. Requests carry the same retry ladder and error taxonomy as chat; empty Text or Voice fail fast with a ConfigError.

Speech-to-text

Transcribe converts audio bytes to text via the provider's transcription endpoint. v1 supports the OpenAI-compatible wire format (POST {base}/audio/transcriptions, multipart/form-data); other formats return a ConfigError. The SDK never touches the filesystem — callers own the audio bytes.

res, err := sdk.Transcribe(ctx, "openai", "whisper-1", llm.TranscribeRequest{
    Audio:    audio,            // required, non-empty, ≤25MB
    Filename: "probe.mp3",      // file part name (default "audio.wav")
    MIMEType: "audio/mpeg",     // audio part content type (default application/octet-stream)
    Language: "en",             // optional ISO-639-1 hint
    Prompt:   "context words",  // optional conditioning text
})
// res.Text        — recognized text
// res.Model       — model that produced the transcription
// res.Language, res.DurationSec — provider-reported, zero when omitted

Requests carry the same retry ladder and error taxonomy as chat. Oversized audio (>25MB), empty Audio/Model, and control characters in Filename/MIMEType (header injection) fail fast with a ConfigError before any network I/O. Format "text", "srt" and "vtt" are not JSON: the raw response body is returned as Text (other fields zero). For the JSON formats (default, json, verbose_json) a 2xx body that is not JSON surfaces as a typed *APIError. Fields the provider does not report stay zero — the SDK never guesses.

Counting tokens

ChatClient.CountTokens(ctx, req) returns the input token count of a request without generating: Anthropic POST /v1/messages/count_tokens and Gemini :countTokens. The request is validated like Call (Extra and sampling parameters are ignored). OpenAI-format providers, including the Responses API, have no such endpoint: the call returns *UnsupportedError (errors.Is(err, llm.ErrUnsupported)) without any network I/O, and the SDK never estimates. Same retry ladder, whole-call timeout, per-request Headers and attempt observer (operation count_tokens) as chat; a 2xx response without a valid count is a terminal *APIError.

n, err := chat.CountTokens(ctx, &llm.ChatRequest{Messages: msgs, Tools: tools})
if errors.Is(err, llm.ErrUnsupported) { /* fall back to your own estimate */ }

Embeddings

Embed turns texts into vectors via OpenAI-compatible POST {base}/embeddings or Gemini batchEmbedContents; Anthropic (no endpoint) returns a ConfigError. OpenAI-compatible requests ask for encoding_format: base64 (little-endian float32, decoded by the SDK) so a 2048-input batch fits the response size cap; providers that ignore it and return float arrays are still accepted.

res, err := sdk.Embed(ctx, "openai", "text-embedding-3-small", llm.EmbedRequest{
    Inputs:     []string{"first", "second"}, // required, each non-empty
    Dimensions: 256,                         // optional (0 = model default)
})
// res.Embeddings[i] is the vector for Inputs[i]; res.Usage.PromptTokens when reported

Vectors come back in input order (the SDK sorts by the provider's index — or trusts response order when a gateway omits it — and rejects a count or index mismatch with a typed *APIError). Large inputs are split into provider-sized batches (Gemini 100, OpenAI 2048) under one whole-call budget; a models/ prefix on Gemini model ids is accepted. Same retry ladder, budget, headers and error taxonomy as chat.

Thread safety

SDK and Provider are safe for concurrent use; SetStreamIdleTimeout and SetLearnObserver may be called while requests run. ChatClient is safe for concurrent Call/CallStream; SetRequestTimeout is race-safe (atomic swap) but should still be called before the first request so in-flight calls use one timeout. Learn-once state is shared per provider via atomics — monotonic, converging, race-free.

Model discovery

ListModels hits each provider's models endpoint (Anthropic paginates with after_id, Gemini with pageToken), caches per SDK for 5 minutes (WithModelCacheTTL(0) disables, ForceRefresh() bypasses), and retries transient failures through the shared ladder: 3 attempts unless WithRetryPolicy sets MaxAttempts, honoring Retry-After, under a whole-call budget of 30s unless WithRequestTimeout is set (a malformed body is not retried). OpenAI-format listings accept {data:[…]}, {models:[…]} or a bare array (Together AI). Anthropic has_more without a last_id is an error, never a partial list. Results are deep-copied on every read (mutating Capabilities cannot corrupt the cache) and an empty successful listing is cached like any other. Concurrent cache misses share one upstream fetch. A listing longer than 100 pages returns ErrModelListTruncated — never a silently truncated list. Fields the provider does not report stay zero — the SDK never guesses.

Testing

make quality    # fmt + vet + tests
make test-race  # race detector
make lint       # golangci-lint (v2 config)

Live end-to-end tests against real APIs (tag-gated, never run in CI). The suite covers every provider you have credentials for — currently DeepSeek, Z.ai, and a custom OpenRouter gateway. Each target skips when its key is absent.

go test -tags e2e -run 'TestE2E' -timeout 15m -v .

Credentials come from the environment or a repo-root .env file (KEY=VALUE); the file is gitignored and its contents are never logged. Override a target's model with <ID>_E2E_MODEL (e.g. DEEPSEEK_E2E_MODEL). Adding a provider is one e2eTarget entry in e2e_test.go.

Coverage sits at 99.0% of statements (unit suite, no e2e), including the streaming failure-orchestration paths (deadline, 429, premature close, partial-output) that are usually the blind spot of SDK test suites. The residual ~1% is unreachable defensive code (multipart writes into an in-memory buffer, json.Marshal of plain values, SSE shutdown races).

Repo guidance

See AGENTS.md for the architecture map, invariants, testing conventions, and the odek migration path.

Upgrading to v1.1 (from v1.0)

v1.1 adds API only (Refusal, ResponseMeta, per-request Headers, WithAttemptObserver, DocumentPart, ImageURLPart, RunTools, CountTokens, TopK, EmbedRequest.TaskType, Quirks.AnthropicToolJSON); nothing exported is removed or renamed. Positional (unkeyed) literals of structs that gained fields need field names. Behaviors that changed on purpose:

Area v1.0 v1.1 What to do
Anthropic thinking Presets sent budget_tokens (5000/1024/8192/16384); unset MaxTokens → budget + 8192; presets clamped to half of MaxTokens Presets send adaptive thinking + output_config.effort; unset MaxTokens → 16384; MaxTokens as-is; temperature/top_p omitted; forced ToolChoice allowed Set ThinkingBudget > 0 for models needing the legacy budget_tokens form (Haiku 4.5 and older)
Anthropic JSON mode Forced synthetic tool folded into Content; errors with Tools, ToolChoice, thinking Native output_config.format (reply text as-is); those combinations are allowed Gateways lacking output_config set Quirks.AnthropicToolJSON for the old behaviour (byte-identical, errors included)
OpenAI-compatible embeddings Float arrays requested encoding_format:"base64" requested (float arrays still accepted) —
Streaming A mid-stream {"error":…} chunk or a bare [DONE] with no output ended as an empty success Provider error / premature-close error None; this surfaces real failures
Finish reasons OpenAI stop with tool calls stayed stop; unknown Responses incomplete reasons leaked as length tool_calls; "" Loops checking len(res.ToolCalls) > 0 are unaffected
Empty content Empty text blocks sent (provider 400) Never sent; an empty Anthropic assistant turn is dropped and adjacent user turns merged —
Gemini tools parameters (OpenAPI subset) parametersJsonSchema (full JSON Schema) —
STT text/srt/vtt JSON-decoded (failed after billing) Raw body returned as Text —
Redirects Same-host https-to-http followed Refused (credential leak), surfaces as *APIError Point WithBaseURL at the final scheme and host
Oversized bodies / long backoff Retried up to 8×; a wait past the deadline returned bare DeadlineExceeded Terminal; the last provider error is returned immediately —
ListModels Ignored WithRetryPolicy, request timeout, Retry-After Honours all three —

Upgrading to v1.0 (from v0.7)

v1.0 removes or renames nothing exported. Existing code compiles unchanged unless it builds ToolCall, Message, ChatRequest, ChatResult, ProviderConfig or Quirks with positional (unkeyed) struct literals; those structs gained fields, so switch to field names. A few behaviors changed on purpose. Check these before upgrading:

Area v0.7 v1.0 What to do
Buffered timeout Each of up to 8 attempts got the full request timeout The request timeout (default 120s) covers the whole call, retries included, on Call, Speak, Transcribe and Embed, as streaming already did Raise WithRequestTimeout if long calls relied on retries outliving one timeout; tune retries with WithRetryPolicy
DeepSeek usage A cache miss counted as CacheCreationTokens A miss is ordinary input in PromptTokens; only hits go to CacheReadTokens Re-check cost math that priced misses as cache writes
Gemini usage CompletionTokens excluded thinking; cached tokens stayed in PromptTokens CompletionTokens includes thinking (as on every format); cached tokens move to CacheReadTokens Use Usage.InputTokens() / TotalTokens() for totals
Gemini finish reason A function-call turn returned stop Returns tool_calls, like every other format Loops checking len(res.ToolCalls) > 0 are unaffected
Gemini truncated streams A stream ending without finishReason was a silent success It is a premature-close error (retried before the first delta, partial result + error after) None; this surfaces real truncation
Delta.ToolIndex Anthropic content-block / Responses output index The call's position in ChatResult.ToolCalls on every format Streaming UIs that grouped fragments by index on Anthropic or Responses
Anthropic thinking Unset MaxTokens sent 8192 (rejected with budgets ≥ 8192); sampling fields sent Unset MaxTokens → budget + 8192; temperature/top_p omitted; impossible budgets are a ConfigError Requests that used to 400 now succeed
Thinking replay One ReasoningContent + ThinkingSignature pair Every block in ThinkingBlocks (incl. redacted), Gemini signatures per ToolCall Append res.AssistantMessage() instead of copying fields by hand
Redirects Followed to any host Cross-host redirects are not followed (credential leak); they surface as *APIError Point WithBaseURL at the final host
Validation Sent to the provider DeepSeek json_schema, malformed base URLs, control characters in TranscribeRequest.Filename/MIMEType are ConfigErrors —
ListModels Silently truncated at 10 pages Up to 100 pages, then ErrModelListTruncated —

Status

v1.1.0 — stable. The exported API follows semantic versioning from here: breaking changes only in a new major version.

License

MIT

Documentation

Overview

Package llm is a multi-provider Go SDK for LLM inference endpoints: OpenAI, Google Gemini, DeepSeek, Z.ai, Kimi (Moonshot) and Anthropic, plus any custom OpenAI-compatible gateway.

Design highlights:

  • Multiple authenticated endpoints simultaneously, discovered from the environment via <PROVIDER>_API_KEY (aliases supported).
  • Dynamic model discovery (ListModels) — no static model profile tables.
  • One canonical request/response shape (OpenAI-compatible); Anthropic and Gemini formats are translated by per-format serializers.
  • Zero external dependencies: stdlib only.
  • Streaming with idle watchdog, hard wall-clock deadline, abort-with- partial-result, and retries that never duplicate partial output.

Index

Constants

View Source
const (
	FinishStop          = "stop"
	FinishLength        = "length"
	FinishToolCalls     = "tool_calls"
	FinishContentFilter = "content_filter"
)

Canonical finish reasons.

View Source
const (
	DefaultTimeout = 120 * time.Second
)

Transport tuning defaults, shared by every provider client an SDK builds.

View Source
const MaxDocumentBytes = 20 << 20

MaxDocumentBytes bounds one inline document part.

View Source
const MaxImageBytes = 10 << 20
View Source
const MaxRequestImageBytes = 32 << 20

MaxRequestImageBytes bounds the aggregate inline image payload in one request, preventing many individually valid parts from causing an unexpectedly large allocation or HTTP body.

Variables

View Source
var ErrIdleTimeout = errors.New("llm: stream idle timeout")

ErrIdleTimeout is returned (with the partial result) when a stream goes silent longer than the idle watchdog after the first delta has been emitted. Before the first delta, idle timeouts are retried instead.

View Source
var ErrMaxToolTurns = errors.New("llm: max tool turns exceeded")

ErrMaxToolTurns reports that the model still wanted tools after MaxTurns.

View Source
var ErrModelListTruncated = errors.New("llm: model listing exceeds the page cap")

ErrModelListTruncated reports a model listing longer than the SDK's page cap; the SDK never returns a silently truncated listing.

View Source
var ErrUnsupported = errors.New("llm: operation not supported")

ErrUnsupported marks an operation the provider format cannot perform.

Functions

func RunTools added in v1.1.0

func RunTools(ctx context.Context, cc *ChatClient, req ChatRequest, handlers map[string]ToolHandler, opts ToolLoopOptions) (*ChatResult, []Message, error)

RunTools drives a buffered tool loop: it calls the model, runs the requested tools through handlers, appends the results and repeats until the model stops asking for tools. It returns the final result and the complete transcript (req.Messages plus everything appended). req.Messages is copied; the caller's slice is never mutated.

A handler error (or panic, or a missing handler) becomes an IsError tool result and the loop continues. If MaxTurns chat calls all end in tool calls, the last result is returned with ErrMaxToolTurns and a transcript without the final (unanswered) assistant turn.

func SetLearnObserver added in v0.7.0

func SetLearnObserver(fn func(LearnEvent))

SetLearnObserver registers a process-wide callback fired once per engaged learn flag, at the moment the fallback decision is made. Nil (the default) disables observation entirely. Delivery is at-most-once: a callback registered after a flag engaged (or swapped during the engaging call) does not retroactively receive that engagement. The callback runs on the request goroutine: keep it fast and non-blocking; it must never call back into the SDK and must not panic.

func SetStreamIdleTimeout added in v0.2.1

func SetStreamIdleTimeout(d time.Duration)

SetStreamIdleTimeout overrides the process-wide default SSE idle watchdog; non-positive values are ignored. Prefer the per-SDK WithStreamIdleTimeout option, which takes precedence.

func StreamIdleTimeout added in v0.2.1

func StreamIdleTimeout() time.Duration

StreamIdleTimeout reports the process-wide default idle watchdog.

Types

type APIError

type APIError struct {
	Provider  string
	Status    int
	Code      string
	Message   string
	Retryable bool
}

APIError is a non-2xx provider response. Status 0 with a non-nil underlying error form is not used; network failures surface as plain wrapped errors. Message is the provider's own error text when parseable. API keys are never included in any error text.

func (*APIError) Error

func (e *APIError) Error() string

type AttemptEvent added in v1.1.0

type AttemptEvent struct {
	Provider   string
	Operation  string        // "chat" | "stream" | "embed" | "speak" | "transcribe" | "models" | "count_tokens"
	Model      string        // "" for models
	Attempt    int           // 1-based, per entry-point call
	Status     int           // HTTP status; 0 on transport error
	Err        error         // nil on success; API keys are never included
	Latency    time.Duration // request start -> response headers received
	RetryDelay time.Duration // delay before the next attempt; 0 when final
	RequestID  string        // provider request id header, when reported
}

AttemptEvent describes one HTTP attempt of an SDK call. See WithAttemptObserver.

type ChatClient

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

ChatClient runs chat completions against one provider+model pair. Learn-once fallbacks are shared by every client from the same Provider; request timeouts are per client. It is safe for concurrent use.

func (*ChatClient) Call

func (c *ChatClient) Call(ctx context.Context, req *ChatRequest) (*ChatResult, error)

Call runs a buffered chat completion.

func (*ChatClient) CallStream

func (c *ChatClient) CallStream(ctx context.Context, req *ChatRequest, onDelta func(Delta) error) (*ChatResult, error)

CallStream runs a streaming chat completion. onDelta receives canonical fragments; returning an error from it aborts generation — CallStream then returns the partial result alongside *StreamAbortedError.

func (*ChatClient) CountTokens added in v1.1.0

func (c *ChatClient) CountTokens(ctx context.Context, req *ChatRequest) (int, error)

CountTokens reports the input token count of req using the provider's counting endpoint (Anthropic /v1/messages/count_tokens, Gemini :countTokens). OpenAI-format providers, including the Responses API, have none: *UnsupportedError (errors.Is ErrUnsupported), with no network call and never an estimate. Request validation matches Call; Extra and sampling parameters are ignored. Retries, the whole-call timeout, headers and the attempt observer (operation "count_tokens") behave as for chat.

func (*ChatClient) Model

func (c *ChatClient) Model() string

Model returns the bound model.

func (*ChatClient) ProviderID

func (c *ChatClient) ProviderID() string

ProviderID returns the bound provider's id.

func (*ChatClient) RequestTimeout

func (c *ChatClient) RequestTimeout() time.Duration

RequestTimeout reports the per-request timeout (streaming wall-clock deadline).

func (*ChatClient) SetRequestTimeout

func (c *ChatClient) SetRequestTimeout(d time.Duration)

SetRequestTimeout adjusts the per-request timeout. Call before the first request.

type ChatRequest

type ChatRequest struct {
	// Headers are per-request HTTP headers (stub; behaviour lands with SL03).
	Headers        map[string]string
	Model          string
	Messages       []Message
	System         []SystemBlock
	Tools          []ToolDef
	Thinking       string
	ThinkingBudget int
	MaxTokens      int
	Temperature    float64
	TopP           float64
	// TopK limits sampling to the K most likely tokens (0 = provider
	// default; negative is a ConfigError). Anthropic top_k (omitted when
	// thinking is on) and Gemini generationConfig.topK; OpenAI chat and
	// Responses ignore it.
	TopK int
	Stop []string
	// ToolChoice constrains tool use (nil = provider default). It requires
	// Tools.
	ToolChoice *ToolChoice
	// ParallelToolCalls, when set with Tools, allows or forbids several
	// tool calls in one turn (OpenAI parallel_tool_calls, Anthropic
	// disable_parallel_tool_use). Gemini has no equivalent and ignores it.
	ParallelToolCalls *bool
	// ResponseFormat requests JSON output (nil = free text).
	ResponseFormat *ResponseFormat
	// Seed asks for deterministic sampling where supported (OpenAI chat
	// completions, Gemini). Anthropic and the Responses API ignore it.
	Seed *int
	// Extra holds provider-specific top-level body fields merged into the
	// request JSON after the SDK's own (so they override them): an escape
	// hatch for parameters the canonical shape does not model yet. The merge
	// is shallow: an object value replaces the SDK's whole object, except
	// Gemini generationConfig, whose keys are set one by one over the SDK's
	// (nested objects such as thinkingConfig are replaced whole). Keys go to whichever endpoint the request
	// is sent to, including /responses when an OpenAI request is diverted
	// there. Reserved (ConfigError): "stream" (use Call vs CallStream) and
	// the keys the SDK validates — model, messages/contents/input,
	// system/systemInstruction/instructions, tools.
	Extra map[string]any
}

ChatRequest is the canonical request. Model is filled from the ChatClient when empty. Thinking accepts "", "enabled", "disabled", "low", "medium", "high", "max" and is translated per provider format. Temperature and TopP: 0 means use the provider default (field omitted); use a negative value to explicitly send 0. Stop maps to each provider's stop-sequence field (the OpenAI Responses API has none, so it is not sent on requests diverted there).

type ChatResult

type ChatResult struct {
	Content          string
	ReasoningContent string
	// ThinkingSignature authenticates ReasoningContent (Anthropic extended
	// thinking). Consumers must carry it back on the next assistant Message
	// for tool loops to stay valid.
	ThinkingSignature string
	// ThinkingBlocks lists every signed reasoning segment in response order
	// (Anthropic thinking/redacted_thinking, Responses reasoning items).
	ThinkingBlocks []ThinkingBlock
	ToolCalls      []ToolCall
	FinishReason   string
	Usage          Usage
	// Refusal is the provider's refusal text when it signals one; empty
	// otherwise. Content is never altered.
	Refusal string
	// Meta is provider-reported response metadata (zero when unreported).
	Meta ResponseMeta
}

ChatResult is the canonical response for both buffered and streaming calls.

func (*ChatResult) AssistantMessage added in v1.0.0

func (r *ChatResult) AssistantMessage() Message

AssistantMessage returns the assistant turn to append to the conversation before the next request. It carries every replay field (reasoning, signatures, thinking blocks, tool calls) so tool loops stay valid on every format. Slices are copied.

type ConfigError

type ConfigError struct{ Msg string }

ConfigError reports SDK misuse: unknown provider id, provider without an API key, or malformed options. Never retryable.

func (*ConfigError) Error

func (e *ConfigError) Error() string

type ContentPart added in v0.4.0

type ContentPart struct {
	Type     ContentPartType
	Text     string
	Image    []byte
	MIMEType string
	// Data is the document payload (ContentPartDocument).
	Data []byte
	// Name is the optional document filename.
	Name string
	// URL is the remote image location (ContentPartImageURL).
	URL string
}

ContentPart is one ordered text or inline-image segment in a message. Image data is raw bytes and is base64-encoded only by provider serializers. A part must contain exactly one supported payload: Text, or Image with a valid MIMEType. Inline images are bounded by MaxImageBytes.

func DocumentPart added in v1.1.0

func DocumentPart(mimeType string, data []byte, name string) ContentPart

DocumentPart creates an inline document part (application/pdf or text/plain). Name is an optional filename.

func ImagePart added in v0.4.0

func ImagePart(mimeType string, data []byte) ContentPart

ImagePart creates an ordered inline-image content part.

func ImageURLPart added in v1.1.0

func ImageURLPart(url string) ContentPart

ImageURLPart creates a part referencing a remote http(s) image. The SDK never fetches it; the provider does. Gemini needs a MIME type: use ImageURLPartMIME or WithMIME.

func ImageURLPartMIME added in v1.1.0

func ImageURLPartMIME(url, mime string) ContentPart

ImageURLPartMIME creates a remote-image part with a MIME type hint.

func TextPart added in v0.4.0

func TextPart(text string) ContentPart

TextPart creates an ordered text content part.

func (ContentPart) WithMIME added in v1.1.0

func (p ContentPart) WithMIME(mime string) ContentPart

WithMIME returns a copy of p with MIMEType set.

type ContentPartType added in v0.4.0

type ContentPartType string

ContentPartType identifies the payload carried by ContentPart.

const (
	ContentPartText  ContentPartType = "text"
	ContentPartImage ContentPartType = "image"
	// ContentPartDocument is an inline document (PDF or plain text).
	ContentPartDocument ContentPartType = "document"
	// ContentPartImageURL is an image the provider fetches by URL.
	ContentPartImageURL ContentPartType = "image_url"
)

type Delta

type Delta struct {
	Kind      DeltaKind
	Text      string
	ToolIndex int
	ToolID    string
	ToolName  string
}

Delta is one streamed fragment. Text is the fragment for this event, not accumulated output.

type DeltaKind

type DeltaKind int

DeltaKind discriminates streamed fragments.

const (
	// DeltaReasoning is a thinking/reasoning fragment, usually before content.
	DeltaReasoning DeltaKind = iota
	// DeltaContent is an assistant text fragment.
	DeltaContent
	// DeltaToolArgs is a tool-call argument fragment (partial JSON). ToolIndex
	// and, on the first fragment of a call, ToolID/ToolName identify the call.
	DeltaToolArgs
	// DeltaRefusal is a refusal-text fragment carried in Delta.Text.
	DeltaRefusal
)

type EmbedRequest added in v1.0.0

type EmbedRequest struct {
	Headers    map[string]string // per-request headers (SL03)
	Inputs     []string          // required: one or more non-empty texts
	Dimensions int               // optional output dimensionality (0 = model default)
	TaskType   string            // Gemini taskType, e.g. RETRIEVAL_QUERY (OpenAI-format ignores it)
}

EmbedRequest describes one embeddings call.

type EmbedResult added in v1.0.0

type EmbedResult struct {
	Embeddings [][]float64
	Model      string
	Usage      Usage // PromptTokens when the provider reports it
	Meta       ResponseMeta
}

EmbedResult carries one vector per input, in input order.

type Format

type Format string

Format identifies a wire protocol family. The SDK translates the single canonical request shape into exactly one of these.

const (
	// FormatOpenAI is the OpenAI chat-completions protocol — also spoken by
	// DeepSeek, Z.ai (GLM), Kimi/Moonshot and most self-hosted gateways.
	FormatOpenAI Format = "openai"
	// FormatAnthropic is the Anthropic Messages API.
	FormatAnthropic Format = "anthropic"
	// FormatGemini is the Google Gemini generateContent API.
	FormatGemini Format = "gemini"
)

type LearnEvent added in v0.7.0

type LearnEvent struct {
	Kind     LearnKind
	Provider string
	Status   int
	Message  string
}

LearnEvent reports one learn-once constraint engaging. Status is the HTTP status that triggered the fallback (0 when none applied, e.g. a 2xx response that arrived without SSE framing); Message carries the provider error text when one exists.

type LearnKind added in v0.7.0

type LearnKind string

LearnKind identifies which learn-once fallback flag engaged.

const (
	// LearnBuffered: streaming was rejected by the provider (explicit
	// "streaming not supported" 400) or a 2xx response arrived without an
	// SSE content type. Every later generation on this provider is buffered.
	LearnBuffered LearnKind = "buffered"
	// LearnResponses: the provider requires /v1/responses for
	// reasoning_effort combined with function tools.
	LearnResponses LearnKind = "responses"
	// LearnNoneEffort: the provider rejected reasoning_effort with tools;
	// effort is pinned to "none" for later requests.
	LearnNoneEffort LearnKind = "none_effort"
	// LearnDropStreamOpts: the provider rejected stream_options; the field
	// is omitted from later requests.
	LearnDropStreamOpts LearnKind = "drop_stream_options"
)

type ListOption

type ListOption func(*listOpts)

ListOption tweaks ListModels.

func ForceRefresh

func ForceRefresh() ListOption

ForceRefresh bypasses the model cache for this call and refreshes it.

type Message

type Message struct {
	Role    Role
	Content string
	// Parts optionally carries ordered text and inline-image content. When
	// empty, Content retains the legacy plain-text representation.
	Parts            []ContentPart
	ReasoningContent string
	// ThinkingSignature authenticates ReasoningContent for providers that
	// require thinking to be replayed verbatim (Anthropic signature, OpenAI
	// Responses encrypted_content).
	ThinkingSignature string
	// ThinkingBlocks replays every signed reasoning segment of a previous
	// assistant turn, in order. When set it supersedes ReasoningContent and
	// ThinkingSignature on Anthropic and Responses requests.
	ThinkingBlocks []ThinkingBlock
	ToolCalls      []ToolCall
	ToolCallID     string
	ToolName       string
	// IsError marks a RoleTool result as a failed tool execution (Anthropic
	// tool_result is_error; Gemini functionResponse {"error": …}). OpenAI
	// formats have no flag: describe the failure in Content.
	IsError bool
	// Cache marks this user message for Anthropic prompt caching
	// (cache_control ephemeral on the text block). Ignored on other
	// formats and on non-user roles.
	Cache bool
}

Message is one canonical chat message. For RoleTool messages, ToolCallID and ToolName identify the call being answered and Content carries the tool result. ReasoningContent is provider-reported thinking text (deepseek-reasoner, anthropic thinking, gemini thoughts). The SDK replays it where a provider requires conversation continuity: OpenAI-format assistant messages echo it as reasoning_content (DeepSeek tool loops; with tools present the key is echoed even when empty, because DeepSeek rejects a missing key with 400), and Anthropic re-serializes a signed thinking block as the first content block when ThinkingSignature is also set.

type Model

type Model struct {
	ID              string
	DisplayName     string
	CreatedAt       time.Time
	ContextWindow   int // input token limit, 0 = unknown
	MaxOutputTokens int // 0 = unknown
	Capabilities    []string
}

Model is one accessible model, as reported by a provider's models endpoint. Fields the provider does not report stay zero — the SDK never guesses.

type Option

type Option func(*SDK)

Option configures an SDK at construction time.

func FromEnv

func FromEnv() Option

FromEnv resolves <PROVIDER>_API_KEY (aliases included, primary first) and optional <PROVIDER>_BASE_URL overrides via os.LookupEnv. Providers without a key stay registered but unauthenticated.

func WithAttemptObserver added in v1.1.0

func WithAttemptObserver(fn func(AttemptEvent)) Option

WithAttemptObserver registers fn to be called once per HTTP attempt on every entry point (chat, stream, embed, speak, transcribe, models), including retries and learn-once immediate retries. fn runs on the request goroutine: keep it fast and never call back into the SDK. fn must not panic, but a panic is recovered and discarded. A nil fn disables observation.

func WithEnv

func WithEnv(lookup func(string) (string, bool)) Option

WithEnv resolves API keys and base-URL overrides from the environment using lookup — the testable twin of FromEnv.

func WithLearnObserver added in v1.0.0

func WithLearnObserver(fn func(LearnEvent)) Option

WithLearnObserver registers a callback fired once per learn-once fallback engaged by this SDK's providers. When set it replaces the process-wide SetLearnObserver callback for this SDK. It runs on the request goroutine: keep it fast and never call back into the SDK from it.

func WithModelCacheTTL

func WithModelCacheTTL(d time.Duration) Option

WithModelCacheTTL sets the ListModels cache TTL. Zero disables caching — every call hits the provider's models endpoint.

func WithProvider

func WithProvider(id string, popts ...ProviderOption) Option

WithProvider configures one provider — a built-in id (override) or a new custom provider (requires WithFormat and WithBaseURL, plus auth).

func WithRequestTimeout

func WithRequestTimeout(d time.Duration) Option

WithRequestTimeout sets the default per-request timeout for all chat clients (default 120s). Streaming calls use it as the hard wall-clock deadline.

func WithRetryPolicy added in v1.0.0

func WithRetryPolicy(p RetryPolicy) Option

WithRetryPolicy replaces the default retry ladder (8 attempts, 30s backoff cap) for every chat, speech, transcription and embedding call made through this SDK. Zero fields keep their defaults.

func WithStreamIdleTimeout added in v1.0.0

func WithStreamIdleTimeout(d time.Duration) Option

WithStreamIdleTimeout sets the SSE idle watchdog for this SDK's streams, taking precedence over the process-wide SetStreamIdleTimeout default. Non-positive values are ignored.

func WithTransport

func WithTransport(rt http.RoundTripper) Option

WithTransport replaces the SDK's pooled HTTP transport (tests, proxies).

type Provider

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

Provider is one configured endpoint with dynamic model discovery.

func (*Provider) Authenticated

func (p *Provider) Authenticated() bool

Authenticated reports whether an API key resolved.

func (*Provider) Config

func (p *Provider) Config() ProviderConfig

Config returns the provider configuration. The copy carries the API key: treat it as a secret; it is never logged by the SDK itself.

func (*Provider) GoString added in v1.0.0

func (p *Provider) GoString() string

GoString implements fmt.GoStringer with the same redaction as String.

func (*Provider) ID

func (p *Provider) ID() string

ID returns the provider's registry id.

func (*Provider) ListModels

func (p *Provider) ListModels(ctx context.Context, opts ...ListOption) ([]Model, error)

ListModels discovers the models accessible to this provider's account, on the fly — no static tables. Results are cached per SDK instance for the cache TTL (default 5 min; WithModelCacheTTL(0) disables). Fields the provider does not report stay zero.

func (*Provider) String added in v1.0.0

func (p *Provider) String() string

String and GoString redact the provider for fmt verbs (%v, %+v, %#v): the API key and header values never print.

type ProviderConfig

type ProviderConfig struct {
	ID      string
	Format  Format
	BaseURL string
	APIKey  string
	// EnvKeys lists environment variable names to consult, primary first
	// (aliases after). Resolution stops at the first non-empty value.
	EnvKeys []string
	Quirks  Quirks
	// Headers are extra HTTP headers sent on every request to this
	// provider (OpenRouter attribution, OpenAI-Organization, anthropic-beta,
	// api-key gateways, …). They are applied last, so they can override the
	// SDK's own headers; an empty value removes that header. Like APIKey,
	// header values are never logged or included in String().
	Headers map[string]string
}

ProviderConfig fully describes one inference endpoint. APIKey is resolved from EnvKeys (primary first) or set explicitly; it is never included in error text, logs, or String() output.

func (ProviderConfig) GoString added in v1.0.0

func (c ProviderConfig) GoString() string

GoString keeps %#v from printing the API key and header values.

func (ProviderConfig) String

func (c ProviderConfig) String() string

type ProviderOption

type ProviderOption func(*ProviderConfig)

ProviderOption configures one provider entry.

func WithAPIKey

func WithAPIKey(key string) ProviderOption

WithAPIKey sets an explicit API key (overrides env).

func WithBaseURL

func WithBaseURL(url string) ProviderOption

WithBaseURL overrides the provider's default base URL.

func WithEnvKeys

func WithEnvKeys(keys ...string) ProviderOption

WithEnvKeys sets env var names consulted for this provider's key, primary first.

func WithFormat

func WithFormat(f Format) ProviderOption

WithFormat sets the wire format (custom providers).

func WithHeaders added in v1.0.0

func WithHeaders(h map[string]string) ProviderOption

WithHeaders adds extra HTTP headers to every request this provider makes (merged over earlier WithHeaders calls). An empty value removes that header, e.g. {"Authorization": ""} for gateways that authenticate with an api-key header instead. The map is copied.

func WithQuirks

func WithQuirks(q Quirks) ProviderOption

WithQuirks overrides protocol quirk flags.

type Quirks

type Quirks struct {
	// ThinkingObject: provider accepts the Anthropic-style top-level
	// "thinking" object (Anthropic, DeepSeek, Z.ai GLM).
	ThinkingObject bool
	// EchoReasoningWithTools: the provider requires the reasoning_content key
	// on every replayed assistant message while the request carries tools,
	// even for turns where it returned no reasoning itself. DeepSeek's
	// thinking mode rejects a bare omission with 400 "The reasoning_content
	// in the thinking mode must be passed back to the API" — which poisons
	// every later request in the tool loop, not just the offending one.
	EchoReasoningWithTools bool
	// AnthropicToolJSON: the Anthropic-format endpoint lacks
	// output_config.format, so JSON ResponseFormat is emulated with a forced
	// synthetic tool folded back into Content. Off for the built-in anthropic
	// provider (native structured outputs).
	AnthropicToolJSON bool
	// ReasoningEffort: provider accepts reasoning_effort (OpenAI, GLM-5.3+).
	ReasoningEffort bool
	// IncludeReasoning: the provider needs the explicit reasoning opt-in
	// flag on OpenAI-format requests. OpenRouter documents the legacy
	// `include_reasoning: true` as the equivalent of the canonical
	// `reasoning: {}` object; without some opt-in such gateways return no
	// reasoning at all.
	IncludeReasoning bool
	// ForceThinking lists model-name prefixes that reject
	// thinking.type=disabled outright (GLM-5.3 always reasons; the
	// documented migration is {type: enabled} + reasoning_effort "low").
	ForceThinking []string
	// NoJSONSchema: the provider accepts response_format json_object but
	// rejects json_schema (DeepSeek); a json_schema ResponseFormat fails fast
	// with a ConfigError instead of a provider 400.
	NoJSONSchema bool
	// AnthropicVersion is the anthropic-version header value required by
	// FormatAnthropic providers ("2023-06-01").
	AnthropicVersion string
}

Quirks carries per-provider protocol deviations, resolved at registration time. This replaces odek's URL-sniffing: a custom base URL gets its format's default quirks unless the caller overrides them explicitly.

type RateLimit added in v1.1.0

type RateLimit struct {
	Reported          bool // true when at least one field below was parsed
	RemainingRequests int
	RemainingTokens   int
	ResetRequests     time.Duration // time until the window resets; 0 = unknown
	ResetTokens       time.Duration
}

RateLimit is the provider-reported rate-limit headroom.

type RateLimitError

type RateLimitError struct {
	APIError
	Attempts   int
	RetryAfter time.Duration
}

RateLimitError is the final failure after persistent 429 responses. RetryAfter carries the last observed Retry-After hint (0 if none).

func (*RateLimitError) Error

func (e *RateLimitError) Error() string

func (*RateLimitError) Unwrap

func (e *RateLimitError) Unwrap() error

Unwrap exposes the embedded APIError so errors.As(err, *APIError) reaches Status/Retryable without a type switch.

type ResponseFormat added in v1.0.0

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

ResponseFormat requests structured output. Name (json_schema only; default "response", letters/digits/_/- up to 64) names the schema; Schema is a JSON Schema object; Strict asks OpenAI-format providers for strict schema adherence.

type ResponseFormatType added in v1.0.0

type ResponseFormatType string

ResponseFormatType selects the shape of the model's text output.

const (
	ResponseText       ResponseFormatType = "text"        // free text (default; nothing sent)
	ResponseJSONObject ResponseFormatType = "json_object" // any JSON object
	ResponseJSONSchema ResponseFormatType = "json_schema" // JSON matching Schema
)

type ResponseMeta added in v1.1.0

type ResponseMeta struct {
	ID        string // provider response/message id; "" when not reported
	Model     string // model reported by the provider; "" when not reported
	RequestID string // from the x-request-id family of response headers
	RateLimit RateLimit
}

ResponseMeta is provider-reported response metadata. Every field is provider-reported or zero — never guessed.

type RetryPolicy added in v1.0.0

type RetryPolicy struct {
	// MaxAttempts is the total number of attempts including the first
	// (default 8). 1 disables retries.
	MaxAttempts int
	// MaxBackoff caps one exponential backoff step (default 30s).
	// Retry-After from the server is honored independently (capped at
	// 120s).
	MaxBackoff time.Duration
}

RetryPolicy configures the retry ladder shared by chat (buffered and streaming), speech, transcription and embeddings. Zero fields keep the defaults.

type Role

type Role string

Role enumerates canonical message roles.

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

type SDK

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

SDK is a configured set of LLM endpoints. Build one with New and options:

sdk := llm.New(llm.FromEnv())

Providers are authenticated when an API key resolves from the environment or is set explicitly; every authenticated provider is usable concurrently through the SDK's shared connection pool.

func New

func New(opts ...Option) *SDK

New builds an SDK with the built-in provider registry and the given options. Options apply in order; later WithProvider calls for the same id override earlier ones.

func (*SDK) Chat

func (s *SDK) Chat(providerID, model string) (*ChatClient, error)

Chat returns a chat client bound to a provider and model.

func (*SDK) Embed added in v1.0.0

func (s *SDK) Embed(ctx context.Context, providerID, model string, req EmbedRequest) (*EmbedResult, error)

Embed computes embeddings for req.Inputs with the named provider and model.

func (*SDK) Provider

func (s *SDK) Provider(id string) (*Provider, error)

Provider looks a provider up by id. Unknown ids return a ConfigError; known-but-unauthenticated providers are returned with Authenticated() == false.

func (*SDK) Providers

func (s *SDK) Providers() []*Provider

Providers returns the authenticated providers in registry order.

func (*SDK) Speak added in v0.5.0

func (s *SDK) Speak(ctx context.Context, providerID, model string, req SpeakRequest) (*SpeakResult, error)

Speak synthesizes speech with the named provider and model. Buffered only — no streaming in v1 — with the same retry ladder as chat.

func (*SDK) Transcribe added in v0.6.0

func (s *SDK) Transcribe(ctx context.Context, providerID, model string, req TranscribeRequest) (*TranscribeResult, error)

Transcribe converts speech to text with the named provider and model. Buffered only, with the same retry ladder as chat.

type SpeakRequest added in v0.5.0

type SpeakRequest struct {
	Headers map[string]string // per-request headers (SL03)
	Text    string            // required, non-empty
	Voice   string            // provider voice id (no fallback tables — invariant #5)
	Format  string            // container for OpenAI-compat providers ("mp3" default)
	Speed   float64           // optional playback speed, OpenAI-compat only (0 = omit)
}

SpeakRequest describes one text-to-speech conversion.

type SpeakResult added in v0.5.0

type SpeakResult struct {
	Audio    []byte // raw audio bytes as the provider returned them
	Model    string // model that produced the audio
	MIMEType string // provider-declared (or well-known default) MIME type
	Meta     ResponseMeta
}

SpeakResult carries the synthesized audio.

type StreamAbortedError

type StreamAbortedError struct{ Reason error }

StreamAbortedError is returned by CallStream when the delta handler aborted generation. CallStream also returns the partial ChatResult assembled so far alongside this error.

func (*StreamAbortedError) Error

func (e *StreamAbortedError) Error() string

func (*StreamAbortedError) Unwrap

func (e *StreamAbortedError) Unwrap() error

type SystemBlock

type SystemBlock struct {
	Text  string
	Cache bool
}

SystemBlock is one system-prompt segment. On Anthropic each block maps to a system text block (Cache marks it for prompt caching); on OpenAI-format providers blocks concatenate into a leading system message; on Gemini they become systemInstruction parts.

type ThinkingBlock added in v1.0.0

type ThinkingBlock struct {
	Text      string // reasoning text (summary on Responses); empty when redacted
	Signature string // Anthropic signature / Responses encrypted_content
	Redacted  string // Anthropic redacted_thinking data; Text and Signature stay empty
}

ThinkingBlock is one provider-signed reasoning segment that must be replayed verbatim and in order: an Anthropic thinking or redacted_thinking block, or an OpenAI Responses reasoning item.

type ToolCall

type ToolCall struct {
	ID        string
	Name      string
	Arguments string // JSON object as a string
	// Signature is the provider's opaque per-call reasoning signature
	// (Gemini thoughtSignature). Replay it unchanged; Gemini 3 rejects a
	// function-call turn whose signature is missing.
	Signature string
}

ToolCall is a model-requested tool invocation.

type ToolChoice added in v1.0.0

type ToolChoice struct {
	Mode ToolChoiceMode
	Name string
}

ToolChoice constrains tool use. Name is set only with ToolChoiceTool and must match one of the request's Tools.

func ToolChoiceNamed added in v1.0.0

func ToolChoiceNamed(name string) *ToolChoice

ToolChoiceNamed forces a call to the named tool.

type ToolChoiceMode added in v1.0.0

type ToolChoiceMode string

ToolChoiceMode selects how the model may use the request's tools.

const (
	ToolChoiceAuto     ToolChoiceMode = "auto"     // the model decides (provider default)
	ToolChoiceNone     ToolChoiceMode = "none"     // never call a tool
	ToolChoiceRequired ToolChoiceMode = "required" // call at least one tool
	ToolChoiceTool     ToolChoiceMode = "tool"     // call the tool named by Name
)

type ToolDef

type ToolDef struct {
	Name        string
	Description string
	Parameters  json.RawMessage
	// Cache marks this tool for Anthropic prompt caching. Set it on the
	// last tool so the catalog is a stable prefix independent of later
	// system rows. OpenAI-format serializers ignore it.
	Cache bool
}

ToolDef declares a callable tool. Parameters is a JSON Schema object (json.RawMessage so callers can pass through marshaled schemas verbatim).

type ToolHandler added in v1.1.0

type ToolHandler func(ctx context.Context, call ToolCall) (string, error)

ToolHandler executes one model-requested tool call and returns its result.

type ToolLoopOptions added in v1.1.0

type ToolLoopOptions struct {
	// MaxTurns is the number of chat calls allowed; <=0 means 8.
	MaxTurns int
	// Parallel runs one turn's handlers concurrently. Results are still
	// appended in call order.
	Parallel bool
	// OnTurn is called after each chat call (1-based), before handlers run.
	// Panics in it are not recovered.
	OnTurn func(turn int, res *ChatResult)
}

ToolLoopOptions tunes RunTools.

type TranscribeRequest added in v0.6.0

type TranscribeRequest struct {
	Headers  map[string]string // per-request headers (SL03)
	Audio    []byte            // required, non-empty: raw audio bytes (caller-owned)
	Filename string            // file part name for the audio (default "audio.wav")
	MIMEType string            // optional content type for the audio part
	Language string            // optional ISO-639-1 hint, OpenAI-compat only
	Prompt   string            // optional conditioning text, OpenAI-compat only
	Format   string            // optional response_format (provider default when empty)
}

TranscribeRequest describes one speech-to-text conversion.

type TranscribeResult added in v0.6.0

type TranscribeResult struct {
	Text        string
	Model       string
	Language    string
	DurationSec float64
	Meta        ResponseMeta
}

TranscribeResult carries the recognized text. Fields the provider omits stay zero — unknown data stays unknown (invariant #5).

type UnsupportedError added in v1.1.0

type UnsupportedError struct{ Op, Provider, Reason string }

UnsupportedError reports an unsupported operation; it unwraps to ErrUnsupported.

func (*UnsupportedError) Error added in v1.1.0

func (e *UnsupportedError) Error() string

func (*UnsupportedError) Unwrap added in v1.1.0

func (e *UnsupportedError) Unwrap() error

type Usage

type Usage struct {
	PromptTokens        int
	CompletionTokens    int
	ReasoningTokens     int
	CacheReadTokens     int
	CacheCreationTokens int
	CachedTokens        int
	CacheReported       bool
}

Usage reports token accounting with one meaning on every format. Fields the provider does not report stay 0.

  • PromptTokens is uncached input only. Inclusive provider counts (OpenAI cached_tokens, DeepSeek prompt_cache_hit_tokens, Gemini cachedContentTokenCount) are subtracted and moved to CacheReadTokens; Anthropic reports cache volumes exclusively and is left alone.
  • CacheReadTokens / CacheCreationTokens are cache reads and cache writes. A DeepSeek cache miss is ordinary input (billed as such), not a write, so it stays in PromptTokens.
  • CompletionTokens counts every generated token, reasoning included (Gemini's candidatesTokenCount excludes thoughts, so they are added); ReasoningTokens is the reasoning subset.
  • CachedTokens is the raw provider-reported cached count, kept for diagnostics. It is already included in CacheReadTokens — never add it.

Sum with InputTokens and TotalTokens rather than by hand.

func (Usage) InputTokens added in v1.0.0

func (u Usage) InputTokens() int

InputTokens is the full input volume: uncached prompt plus cache reads and writes.

func (Usage) TotalTokens added in v1.0.0

func (u Usage) TotalTokens() int

TotalTokens is InputTokens plus CompletionTokens.

Jump to

Keyboard shortcuts

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