provider

package
v0.132.1 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 42 Imported by: 0

Documentation

Overview

Package provider defines the LLM client abstraction used by terva.

It supports several providers behind one Client interface — Anthropic (Messages API), OpenAI (Chat Completions and the Codex/Responses API), Google Gemini, Amazon Bedrock, Kimi, Ollama, and any OpenAI-compatible endpoint. Everything above this package operates on the types declared here and does not know about HTTP or SSE.

Index

Constants

View Source
const (
	ReasoningShapeAnthropicThinking       = "anthropic.thinking"
	ReasoningShapeAnthropicThinkingOpaque = "anthropic.thinking_opaque"
	ReasoningShapeAnthropicRedacted       = "anthropic.redacted_thinking"
	// ReasoningShapeOpenAIResponses is the Responses/Codex `reasoning` item:
	// an id plus an encrypted payload, replayed verbatim. Omitting it on
	// replay makes the backend reject the following tool call.
	ReasoningShapeOpenAIResponses = "openai.responses"
	// ReasoningShapeOpenAIChat is chat-completions `reasoning_content` —
	// prose, replayed as text beside a tool call rather than as a payload.
	ReasoningShapeOpenAIChat = "openai.chat"
	// ReasoningShapeGeminiThoughtSummary is a Gemini thought summary. It is
	// never replayed: Gemini's replay token is thoughtSignature, and that
	// rides on ToolCallBlock.Signature, not here.
	ReasoningShapeGeminiThoughtSummary = "google.thought_summary"
)

Reasoning shapes. Anthropic is the only wire terva replays reasoning to verbatim, so it is the only one that needs naming today.

🪤 None of the three is derivable from the block's contents, which is the whole reason they are tagged. All three can present as "no readable text plus an opaque string", and they are not interchangeable on the wire:

  • Thinking: readable text sealed BY the signature. Blanking the text invalidates the block; recording it means recording the model's unabridged chain-of-thought.
  • ThinkingOpaque: a thinking block whose text Anthropic withheld — adaptive-thinking models (Opus 4.7+, Sonnet 5) sign the reasoning and send `thinking:""`. Replayable, and it carries nothing readable to record, so it is NOT subject to the recording-off drop.
  • Redacted: thinking Anthropic's safety systems encrypted, replayed under a different block type entirely.

A stripped Thinking block and a native ThinkingOpaque one are byte-identical in Go; the first is unreplayable and the second is fine. Hence the tag rather than a Summary == "" test. Every capture site sets one of these, and every replay site accepts only the ones it issued. An empty Shape means one thing and one thing only: the block was written before terva tagged them (see NormalizeLegacyReasoningShape).

🪤 There is deliberately NO "unknown means OpenAI-compatible" default. That default is what let kimi be recorded as an OpenAI-wire provider when its client speaks Anthropic — the census in reasoning_wire_census_test.go exists to force the decision instead. A default here would reintroduce the same silent-plausible-answer failure one layer down, in the data.

View Source
const CacheTTL = 6 * time.Hour

CacheTTL is how long a discovered list is considered fresh.

View Source
const MaxDisplayNameRunes = 64

MaxDisplayNameRunes bounds a models.json `name`. Not a layout decision — the render sites do their own width clamping — just a sanity ceiling so a pasted essay can't become a model's name.

View Source
const ModelCacheVersion = 4

ModelCacheVersion is bumped whenever the discovery LOGIC changes — a new provider or endpoint is added to refreshModels. A cache written by an older binary carries a lower version and is treated as stale even within CacheTTL, so a newly-added source (e.g. opencode-go) is picked up on the next launch instead of waiting out the time-based TTL.

v2: added opencode / opencode-go /v1/models discovery.
v3: added user-defined endpoint (config.json "endpoints") discovery.
v4: openai-compatible discovery asserts image-input per model id
    (text-only by default), so cached entries re-resolve their caps.

Variables

View Source
var Catalog = []Model{

	{
		Provider: "anthropic", ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5 (latest)",
		ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
	},
	{
		Provider: "anthropic", ID: "claude-opus-4-1", DisplayName: "Claude Opus 4.1 (latest)",
		ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
		PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
	},
	{
		Provider: "anthropic", ID: "claude-opus-4-0", DisplayName: "Claude Opus 4 (latest)",
		ContextWindow: 200000, MaxOutput: 32000, Reasoning: true,
		PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
	},
	{
		Provider: "anthropic", ID: "claude-sonnet-4-0", DisplayName: "Claude Sonnet 4 (latest)",
		ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
	},
	{
		Provider: "anthropic", ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5 (latest)",
		ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 1, PriceOutput: 5, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
	},

	{
		Provider: "anthropic", ID: "claude-3-7-sonnet-20250219", DisplayName: "Claude Sonnet 3.7",
		ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
	},
	{
		Provider: "anthropic", ID: "claude-3-5-sonnet-20241022", DisplayName: "Claude Sonnet 3.5 v2",
		ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
	},
	{
		Provider: "anthropic", ID: "claude-3-5-haiku-latest", DisplayName: "Claude Haiku 3.5 (latest)",
		ContextWindow: 200000, MaxOutput: 8192, Reasoning: false,
		PriceInput: 0.8, PriceOutput: 4, PriceCacheRead: 0.08, PriceCacheWrite: 1,
	},
	{
		Provider: "anthropic", ID: "claude-3-opus-20240229", DisplayName: "Claude Opus 3",
		ContextWindow: 200000, MaxOutput: 4096, Reasoning: false,
		PriceInput: 15, PriceOutput: 75, PriceCacheRead: 1.5, PriceCacheWrite: 18.75,
	},

	{
		Provider: "deepseek", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro",
		ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
		PriceInput: 0.435, PriceOutput: 0.87, PriceCacheRead: 0.003625,
		BaseURL: "https://api.deepseek.com",
		Caps:    map[Capability]bool{CapImageInput: false},
	},
	{
		Provider: "deepseek", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash",
		ContextWindow: 1000000, MaxOutput: 384000, Reasoning: true,
		PriceInput: 0.14, PriceOutput: 0.28, PriceCacheRead: 0.0028,
		BaseURL: "https://api.deepseek.com",
		Caps:    map[Capability]bool{CapImageInput: false},
	},

	{
		Provider: "kimi", ID: "k3", DisplayName: "Kimi K3",
		ContextWindow: 1000000, MaxOutput: 32768, Reasoning: true,
		DefaultReasoning: "high",
		PriceInput:       3, PriceOutput: 15, PriceCacheRead: 0.3,
		BaseURL: "https://api.kimi.com/coding",
	},
	{
		Provider: "kimi", ID: "k3-256k", DisplayName: "Kimi K3 256k",
		ContextWindow: 262144, MaxOutput: 32768, Reasoning: true,
		DefaultReasoning: "high",
		PriceInput:       3, PriceOutput: 15, PriceCacheRead: 0.3,
		BaseURL: "https://api.kimi.com/coding",
	},
	{

		Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi For Coding",
		ContextWindow: 262144, MaxOutput: 32768, Reasoning: true,
		PriceInput: 0.6, PriceOutput: 2.5, PriceCacheRead: 0.15,
		BaseURL: "https://api.kimi.com/coding",
	},
	{

		Provider: "kimi", ID: "kimi-k2-thinking", DisplayName: "Kimi K2 Thinking",
		ContextWindow: 262144, MaxOutput: 32768, Reasoning: true,
		PriceInput: 0.6, PriceOutput: 2.5, PriceCacheRead: 0.15,
		BaseURL: "https://api.kimi.com/coding",
	},

	{
		Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5",
		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.125,
	},
	{
		Provider: "openai", ID: "gpt-5-mini", DisplayName: "GPT-5 Mini",
		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 0.25, PriceOutput: 2, PriceCacheRead: 0.025,
	},
	{
		Provider: "openai", ID: "gpt-5-nano", DisplayName: "GPT-5 Nano",
		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 0.05, PriceOutput: 0.4, PriceCacheRead: 0.005,
	},

	{
		Provider: "openai", ID: "gpt-4.1", DisplayName: "GPT-4.1",
		ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
		PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
	},
	{
		Provider: "openai", ID: "gpt-4.1-mini", DisplayName: "GPT-4.1 mini",
		ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
		PriceInput: 0.4, PriceOutput: 1.6, PriceCacheRead: 0.1,
	},
	{
		Provider: "openai", ID: "gpt-4.1-nano", DisplayName: "GPT-4.1 nano",
		ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false,
		PriceInput: 0.1, PriceOutput: 0.4, PriceCacheRead: 0.03,
	},

	{
		Provider: "openai", ID: "gpt-4o", DisplayName: "GPT-4o",
		ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
		PriceInput: 2.5, PriceOutput: 10, PriceCacheRead: 1.25,
	},
	{
		Provider: "openai", ID: "gpt-4o-mini", DisplayName: "GPT-4o mini",
		ContextWindow: 128000, MaxOutput: 16384, Reasoning: false,
		PriceInput: 0.15, PriceOutput: 0.6, PriceCacheRead: 0.08,
	},

	{
		Provider: "openai", ID: "o4-mini", DisplayName: "o4-mini",
		ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
		PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.28,
	},
	{
		Provider: "openai", ID: "o3", DisplayName: "o3",
		ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
		PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5,
	},
	{
		Provider: "openai", ID: "o3-mini", DisplayName: "o3-mini",
		ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
		PriceInput: 1.1, PriceOutput: 4.4, PriceCacheRead: 0.55,
	},
	{
		Provider: "openai", ID: "o1", DisplayName: "o1",
		ContextWindow: 200000, MaxOutput: 100000, Reasoning: true,
		PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5,
	},

	{
		Provider: "openrouter", ID: "anthropic/claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5 (OpenRouter)",
		ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
		BaseURL: openrouterDefaultBaseURL,
	},

	{
		Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
		ContextWindow: 200000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6",
		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7",
		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
		PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
		PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-opus-5", DisplayName: "Claude Opus 5",
		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
		PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6",
		ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
		ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true,
		PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, PriceCacheWrite: 3.75,
		Speculative: true,
	},
	{
		Provider: "anthropic", ID: "claude-fable-5", DisplayName: "Claude Fable 5",
		ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
		PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Speculative: true,
	},

	{
		Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1",
		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 1.25, PriceOutput: 10, PriceCacheRead: 0.13,
		Speculative: true,
	},
	{
		Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2",
		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
		Speculative: true,
	},
	{
		Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4",
		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
		Speculative: true,
	},
	{
		Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
		ContextWindow: 400000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
		Speculative: true,
	},
	{
		Provider: "openai", ID: "gpt-5.5", DisplayName: "GPT-5.5",
		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
		Speculative: true,
	},

	{

		Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
		ContextWindow: 128000, MaxOutput: 32000, Reasoning: true,
		PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
	},
	{
		Provider: "openai-codex", ID: "gpt-5.4", DisplayName: "GPT-5.4",
		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25,
		Caps: map[Capability]bool{CapImageOutput: true},
	},
	{
		Provider: "openai-codex", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini",
		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075,
		Caps: map[Capability]bool{CapImageOutput: true},
	},
	{
		Provider: "openai-codex", ID: "gpt-5.5", DisplayName: "GPT-5.5",
		ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,

		Caps: map[Capability]bool{CapImageOutput: true},
	},

	{
		Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
		ContextWindow: 1050000, DesiredContextWindow: 272000, ContextSurchargeAt: 272000,
		MaxOutput: 128000, Reasoning: true,
		PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
		Caps: map[Capability]bool{CapImageOutput: true},
	},
	{
		Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
		ContextWindow: 1050000, DesiredContextWindow: 272000, ContextSurchargeAt: 272000,
		MaxOutput: 128000, Reasoning: true,
		PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
		Caps: map[Capability]bool{CapImageOutput: true},
	},
	{
		Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
		ContextWindow: 1050000, DesiredContextWindow: 272000, ContextSurchargeAt: 272000,
		MaxOutput: 128000, Reasoning: true,
		PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
		Caps: map[Capability]bool{CapImageOutput: true},
	},
}

Catalog is the hardcoded, read-only list of supported models. Prices are USD per 1M tokens. The list is curated to what terva's clients (Anthropic Messages + OpenAI Chat Completions) can actually talk to; models that are only reachable through the OpenAI Responses API (o1-pro, o3-pro, gpt-5-pro) are omitted.

View Source
var DefaultModel = Catalog[0] // claude-sonnet-4-5

DefaultModel is used when the user does not specify one.

View Source
var ErrResetsUnsupported = errors.New("provider does not support usage resets")

ErrResetsUnsupported is returned by ClientConsumeReset when the target client exposes no reset capability — a routing bug, surfaced loudly because the call is meant to spend a credit.

View Source
var ErrStreamLimit = errors.New("event stream line exceeded its size limit")

ErrStreamLimit is the sentinel behind NewStreamLimitError, so callers can errors.Is a limit abort apart from a plain truncation.

View Source
var LegacyUserModelProviderAliases = map[string]string{
	"anthropic-messages": "anthropic",
	"moonshot-ai":        "kimi",
	"kimi-code":          "kimi",
	"deepseek-chat":      "deepseek",
	"deepseek-ai":        "deepseek",
}

LegacyUserModelProviderAliases maps historical models.json provider keys onto the provider they were renamed to. It exists only for files written before the rename; a key here must be DEAD — a name the registry no longer knows.

Two entries were not dead, and both silently repointed an operator's override onto a different provider than the one they named:

  • "openai-responses" mapped to "openai" while being a first-class registry id with its own client (NewOpenAIResponses), its own catalog rows, its own reasoning wire and its own label. A models.json block keyed "openai-responses" landed on plain "openai" with no warning — and because the WRITE side (UpsertUserModel/FindUserModel) does not normalize at all, /model showed the value as saved while the merged catalog carried it elsewhere. baseUrl and prices ride the same path, so editing a Responses model silently repointed and repriced the operator's chat provider.

  • "moonshot" mapped to "kimi" while the registry makes it an alias of "moonshotai" — and reasoning.go's own comment states that "moonshotai is the OpenAI-wire Kimi; they are different providers". Two hand-maintained tables, opposite answers for one string.

TestLegacyModelAliasesAreDead in packages/agent/build is what keeps this honest; the check has to live there because provider cannot import the registry without a cycle.

View Source
var ReasoningLevels = []string{"off", "minimum", "low", "medium", "high", "maximum", "max"}

ReasoningLevels is the ladder as a user types it, lowest to highest. It is the ONE source every surface that prints the ladder reads from.

It exists because the three places that spoke about the ladder drifted: the flag accepted "max" while both `--help` and the error a typo produced listed only up to "maximum", so the tier that unlocks gpt-5.6's native ceiling was enforced but never advertised. Printing a hand-written copy of this list is how that happens, so there is no hand-written copy in Go.

There IS one more, and it cannot be removed: the web client's REASONING_LEVELS (ui/ReasoningPick.tsx) is a different language and cannot import this. That copy is held to this one by reasoning-ladder-parity.test.ts, which reads this var out of this file. Until that guard existed the claim above was simply false for the web surface, in the exact way it describes having already happened once.

Aliases ("min", "minimal", "hi", "none", …) are deliberately absent: they are accepted by NormalizeReasoning but are not what a surface should teach.

Functions

func AnthropicAdaptiveEffort

func AnthropicAdaptiveEffort(level string) string

AnthropicAdaptiveEffort maps terva's user-facing thinking levels onto the effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+). These models reject explicit thinking budgets; thinking depth is controlled by output_config.effort instead. Returns "" when reasoning is disabled.

func ApplyCost added in v0.130.0

func ApplyCost(m Model, u *Usage)

ApplyCost stamps both money fields on a usage record from the model that produced it. The single place a decoder prices a response.

It exists so the two stay together. CacheSavedUSD can only be computed here — the model is in scope, and by the time the usage row is read back the price sheet that applied to it is gone (a session switches models; the row records no model). A decoder that set CostUSD and forgot the savings would silently report a session as having saved nothing, so TestEveryProviderPricesThroughApplyCost holds every decoder to this door — and finds a new one by what it assigns, not by a list someone has to remember to extend.

func CacheSavings added in v0.130.0

func CacheSavings(m Model, u Usage) float64

CacheSavings returns what the prompt cache was worth on this response: the prompt billed at full input price, minus the prompt as actually billed. Negative when cache writes outweigh the reads they enabled.

A model with no cache pricing (PriceCacheRead and PriceCacheWrite both zero) returns 0 rather than the full prompt price. Free reads would otherwise report the whole prompt as "saved" on every local ollama turn, where the honest answer is that nothing was billed and nothing was saved.

func CatalogRevision added in v0.109.0

func CatalogRevision() uint64

CatalogRevision returns a counter that increments whenever the active catalog is recomputed (a layer write — e.g. live discovery completing). A long-lived view can poll it to know when to re-read Active().

func ClientContinuesAssistantPrefill added in v0.125.1

func ClientContinuesAssistantPrefill(c Client) bool

ClientContinuesAssistantPrefill is a named convenience over ClientCaps for the Stage "continue" gate: whether this client's wire format extends a trailing assistant message (a prefill) rather than starting a fresh turn.

func ClientMirrorsToolImages

func ClientMirrorsToolImages(c Client) bool

ClientMirrorsToolImages is a named convenience over ClientCaps for the agent loop's tool-image-mirror decision.

func ClientNeedsUsageFetch added in v0.110.0

func ClientNeedsUsageFetch(c Client) bool

ClientNeedsUsageFetch reports whether c pulls its usage from an endpoint (a UsageRefresher) — the case where /usage should fetch in the background and show a loading state instead of rendering instantly from headers.

func ClientReasoningWire added in v0.132.1

func ClientReasoningWire(c Client) string

ClientReasoningWire names the reasoning wire the CLIENT actually speaks, as declared by the concrete client's Capabilities(). Looks through wrappers. Returns "unknown" for a client that has not declared one.

func ClientSupportsResets added in v0.120.0

func ClientSupportsResets(c Client) bool

ClientSupportsResets reports whether c (through any wrapper layer) exposes consumable usage resets — the gate for showing a /resets affordance at all.

func ComputeCost

func ComputeCost(m Model, u Usage) float64

ComputeCost returns the USD cost for the given usage on model m.

Output is billed at one rate unless the model sets PriceOutputImage, in which case the image tokens inside OutputTokens are split out and billed at their own rate. See Model.PriceOutputImage and Usage.ImageOutputTokens: the image models bill the two 10-20x apart, and both directions of getting it wrong are real money.

func ContextGauge added in v0.132.1

func ContextGauge(provider, id string) int

ContextGauge is the denominator EVERY user-facing context reading must use: the model's EFFECTIVE window, resolved from the active catalog. 0 when the model is unknown, which callers render as "no gauge" rather than as a division by zero.

There were two context-window semantics in the tree and they disagreed. Agent.ContextUsage, the auto-compaction keep-tail budget and ShouldAutoCompact all divide by EffectiveContextWindow; nine gauge sites read the raw ContextWindow instead — the TUI status bar, the script-mode payload, the chat-bridge /status line, the web session card, the usage surface and the context inspector. tools/status.go stated the contract out loud ("this percentage matches the status-bar gauge and the auto-compaction threshold") and it did not match.

On a model with a DesiredContextWindow the gap is not cosmetic. gpt-5.6-luna ships ContextWindow 1,050,000 against DesiredContextWindow 272,000, so auto-compaction fires at 217,600 tokens while every gauge read 21% full: the user watched their conversation compact at a fifth of a bar, with no surface anywhere showing the number that triggered it. Any operator who sets desiredContextWindow in models.json to dodge a context surcharge reproduces it on any model.

The hard ceiling keeps using Model.ContextWindow — the maxTok clamp and every surface that reports the model's SPEC (`--list-models`, models.list, the rpc and sdk model rows). Two meanings, two names, and the name says which.

func DumpRequestJSONL added in v0.131.5

func DumpRequestJSONL(providerName, authMethod string, req Request) ([]byte, error)

DumpRequestJSONL renders req exactly as providerName would put it on the wire, as newline-delimited JSON: one header line carrying every field except the input array, then one line per input item, verbatim.

The line-per-item shape is the entire point, and it is not cosmetic. A provider prompt cache matches on an exact byte PREFIX, so the only question worth asking of two requests is "which item is the first that differs" — and that is `diff a.jsonl b.jsonl`, reading the first changed line number. As one pretty-printed blob the same question is a manual scan of several hundred kilobytes, which is why the cache investigation could establish that terva's own message list was append-only and never that the SERIALIZED body was.

Deliberately no per-line index: a pure append leaves every earlier line byte-identical, so diff reports the minimal edit and the first differing line IS the first differing item. Numbering the lines would renumber the whole file whenever an item was inserted early, turning the one signal worth having into noise.

🪤 That append-only property is the OpenAI/Codex wire's, not a guarantee of this function. Anthropic marks its cache breakpoint on the last user message, so appending a turn moves the mark and rewrites the line that used to carry it — one line of expected churn on every diff, before any real change. See TestDumpRequestJSONLAnthropicRewritesOnlyTheCacheBreakpoint, which pins that to the breakpoint alone.

authMethod ("apikey" | "oauth" | "") selects the auth MODE to build for. It is not a credential and nothing is resolved from it — but on Anthropic the mode changes the body itself (see wireBody), so a dump that ignored it would show a subscription user a request they never send.

This builds the body only. It opens no connection, needs no credential, and is safe to run against a session file that is in use.

func EffectiveReasoning added in v0.126.7

func EffectiveReasoning(reqReasoning string, reasoningSet bool, m Model) string

EffectiveReasoning resolves the reasoning level for a turn against model m: an explicitly-set level (reasoningSet==true, incl. "" meaning the user chose off) wins; otherwise the model's DefaultReasoning applies; otherwise off. The result is NORMALIZED, so callers gate on `!= ""` and pass it to the budget/effort mappers unchanged. Shared by every backend's request builder.

This is the BOTTOM of the chain, not all of it. reqReasoning arrives already resolved across the layers that need to be told apart — the flag / session override, an operator's per-model models.json value, and the global config — because only the builder can distinguish them (see build.Resolve). What remains here is the last rung: a model's CATALOG default, which applies when the user chose nothing at all.

func FinalizeToolArguments added in v0.131.1

func FinalizeToolArguments(raw string) (args json.RawMessage, unparsed string)

FinalizeToolArguments turns a streamed argument buffer into the two things a ToolCallBlock needs: Arguments that are ALWAYS valid JSON, and — when the buffer could not be made parseable — the original text, kept verbatim.

Arguments being unconditionally valid is the point. An invalid json.RawMessage does not fail politely at the tool that reads it: it makes every enclosing json.Marshal fail, and a ToolCallBlock is marshalled by at least three independent paths (the session JSONL, each provider's request builder, and the ctrlproto wire behind the TUI and `terva attach`). Guarding each of those separately is whack-a-mole, and the one that was missed lost whole assistant turns off the transcript. Normalising here — at the single boundary where model-generated text becomes a typed block — makes the invariant hold everywhere downstream by construction.

The unparseable text is returned rather than dropped because it is the only evidence of what the model actually tried to do. A session that discarded it recorded a tool_result with no call in front of it, which is unreadable after the fact: the tool name, the arguments, and the fact a call happened at all were simply gone.

func ForeignCompactions added in v0.131.5

func ForeignCompactions(msgs []Message, providerName string) []int

ForeignCompactions reports the indices of messages carrying a compaction blob that provider cannot replay.

This lives above the serializers on purpose. Every provider's content switch is a type switch with no default arm, so an unrecognized block is dropped silently — and for a compaction that is not degradation but amnesia: the blob is the only encoding of the assistant turns it replaced, so the model receives a conversation that reads continuous and is missing half its history, with nothing raised anywhere. Leaving this to each provider to remember is how it stayed invisible; asking once, here, is what makes it a decision.

A blob with no recorded provider is treated as foreign to everyone: it predates provenance, and guessing that it belongs to whoever is asking is the answer that loses data.

func IsTransportError

func IsTransportError(err error) bool

IsTransportError reports whether err is a network-level failure worth retrying, classified by type rather than message prose: timeouts, abrupt EOFs, connection resets/refusals, broken pipes, and DNS blips. Context cancellation is never transient — it is the caller saying stop.

func LadderWireValue added in v0.131.5

func LadderWireValue(rung string) string

LadderWireValue turns a displayed rung into the value the mappers take. The ladder prints "off" where the wire means "no reasoning", which is the empty string everywhere else in this package.

func MaxIsNative added in v0.131.5

func MaxIsNative(m Model) bool

MaxIsNative reports whether the "max" rung reaches m as a native max effort rather than being clamped down to the "maximum" tier.

It lives beside the mappers that do the clamping (OpenAICodexReasoningEffort gates on the gpt-5.6 prefix; AnthropicAdaptiveEffort and OpenAICompatAnthropicEffort pass "max" through for adaptive models) so a surface that EXPLAINS the rung and the code that ENFORCES it cannot drift apart — which is the same failure the ladder itself just had.

func NewHTTPClient added in v0.108.3

func NewHTTPClient(insecureTLS bool) *http.Client

NewHTTPClient returns a provider HTTP client. When insecureTLS is true, ONLY this client skips TLS certificate verification — the process-wide http.DefaultTransport is left untouched, so auth, model discovery, and every other provider keep normal certificate validation. The insecure transport is a clone of the default, so timeouts/proxies are preserved.

func NormalizeReasoning

func NormalizeReasoning(level string) string

NormalizeReasoning canonicalizes terva's user-facing thinking levels. Empty string means reasoning/thinking is disabled. "maximum" is the long-standing top tier (mapped to xhigh effort); "max" is a separate opt-in tier above it, sent natively only to models that support it (GPT-5.6, adaptive Claude) and clamped to the "maximum" effort elsewhere.

func NormalizeReasoningSummary added in v0.126.13

func NormalizeReasoningSummary(mode string) string

NormalizeReasoningSummary canonicalizes the reasoning-summary setting onto the values the OpenAI Responses backend accepts, and returns "" (off) for anything it does not recognize.

Unknown values are dropped rather than forwarded on purpose: this field rides every request on the path, so a typo'd config would otherwise turn into a 400 on every single turn instead of a silently absent summary. Failing off degrades to today's behavior; failing open breaks the session.

func NormalizeUserModelProviderKey added in v0.132.1

func NormalizeUserModelProviderKey(key string) string

NormalizeUserModelProviderKey resolves a models.json provider key through the legacy-alias table, returning the key unchanged when it is not a legacy name.

func OpenAICodexReasoningEffort

func OpenAICodexReasoningEffort(level, model string) string

OpenAICodexReasoningEffort maps terva levels onto the ChatGPT/Codex Responses backend enum. That backend rejects "minimal" and uses "xhigh" for the top of the GPT-5.x tier. GPT-5.6 additionally supports a native "max" effort above xhigh; other models clamp "max" to xhigh.

func OpenAICompatAnthropicEffort

func OpenAICompatAnthropicEffort(level string) string

OpenAICompatAnthropicEffort maps terva's user-facing thinking levels onto reasoning_effort values when an adaptive-thinking Anthropic model (Opus 4.7+) is served over the OpenAI-compatible chat- completions wire (openrouter, opencode, ...). Differs from OpenAIReasoningEffort only at the top: terva's "maximum" maps to "xhigh" instead of being clamped to "high", so the model's full adaptive-thinking ceiling is preserved when reachable through a gateway that accepts the effort knob.

func OpenAIReasoningEffort

func OpenAIReasoningEffort(level string) string

OpenAIReasoningEffort maps terva's six-level setting onto the effort enum accepted by OpenAI-compatible chat-completions endpoints.

func ParseRetryAfter

func ParseRetryAfter(v string) time.Duration

ParseRetryAfter parses a Retry-After header value: either delay seconds ("17") or an HTTP-date. Returns 0 for absent or unparseable values, never a negative duration.

func ProviderLabel

func ProviderLabel(id string) string

ProviderLabel returns the user-facing label for a provider id.

func ProviderReasoningWire added in v0.132.1

func ProviderReasoningWire(provider string) string

ProviderReasoningWire names the wire the provider TABLE believes the given provider id speaks. The pair (ClientReasoningWire, ProviderReasoningWire) must agree for every registered provider; see the build-side guard.

func ReasoningBudget

func ReasoningBudget(level string) int

ReasoningBudget returns terva's approximate token budget for thinking-capable providers that accept explicit budgets.

func ReasoningLadder added in v0.131.5

func ReasoningLadder() string

ReasoningLadder renders the ladder for help text and error messages.

func RegisterExtraModel

func RegisterExtraModel(m Model)

RegisterExtraModel upserts a single model into the "extra" layer, replacing any layer entry with the same provider/id. Used for models that are neither in the baked-in catalog nor discovered via the standard refresh — currently the openai-compatible endpoint's models. Entries persist across SetLiveModels calls.

func RemoveUserModel added in v0.108.1

func RemoveUserModel(path, providerKey, id string) (bool, error)

RemoveUserModel deletes the entry for id under providerKey and writes the file atomically, reporting whether an entry was actually removed. The provider block is dropped when its last model goes (via WriteUserModelsFile's pruning), so a reset leaves no residue.

Every spelling of the provider is cleared, not just the canonical one. This is the Reset button: leaving a legacy-keyed entry behind would report success and leave the override in force, which is exactly what it used to do.

func RepairToolArguments added in v0.131.1

func RepairToolArguments(raw string) string

RepairToolArguments makes a streamed tool-argument buffer parseable when the only thing wrong with it is raw control bytes inside a JSON string.

Every provider streams tool arguments as opaque text fragments the model generated token by token — Anthropic's input_json_delta, OpenAI's function-call argument deltas, and the Bedrock/Gemini equivalents. None of them validate that text on the way out, so whatever the model typed is what arrives. A model writing Go into an `edit` call types real tabs to indent it, and a real tab inside a JSON string is a syntax error:

invalid args: invalid character '\t' in string literal

The call is not ambiguous, only mis-encoded: the model's intent survives intact in the bytes, and escaping the control characters recovers it exactly. Without that the tool rejects the call, and because the error names a character class rather than a location the model has nothing to act on and re-sends the identical bytes until the stall detector intervenes.

This is deliberately NOT a general JSON fixer. It repairs one defect, and it is safe precisely because that defect is impossible in valid JSON: RFC 8259 forbids unescaped bytes below 0x20 inside a string, so any buffer this function changes was already unparseable. Already-valid input is returned untouched, and a repair that does not produce valid JSON is discarded in favour of the original — so a caller can never be handed something that parses differently than what the model sent.

func ResetCatalogLayers

func ResetCatalogLayers()

ResetCatalogLayers clears every overlay layer (live, extra, user), returning Active() to the baked-in Catalog. Intended for tests that need a pristine catalog regardless of what earlier tests installed.

func SanitizeDisplayName added in v0.130.0

func SanitizeDisplayName(s string) string

SanitizeDisplayName makes an operator-supplied model name safe to print.

Unlike every other scalar override, this one is rendered raw into a terminal status bar and picker rows, so an ESC in it is not a cosmetic problem: it repaints the frame. Escape sequences, C0/C1 controls, and DEL are dropped outright; the remaining whitespace is collapsed to single spaces so a multi-line paste becomes one line rather than a torn status bar. Applied at BOTH doors — the editor's SetOverride and the loader — because models.json is hand-edited at least as often as it is written.

func SaveCache

func SaveCache(path string, c ModelCache) error

SaveCache writes the cache atomically.

func SeedClientUsage added in v0.120.0

func SeedClientUsage(c Client, snap UsageSnapshot)

SeedClientUsage primes c with a predecessor's snapshot, looking through wrapper layers for a UsageSeeder. A client that cannot be seeded (or a snapshot the seeder rejects) is a silent no-op — seeding is best-effort continuity, not state transfer.

func SetLiveModels

func SetLiveModels(live []Model)

SetLiveModels replaces the "live" layer. Typically called after a successful /v1/models discovery or on load from the on-disk cache.

func SetUserModels

func SetUserModels(models []Model)

SetUserModels is the []Model convenience form of SetUserOverrides for callers that build models programmatically (mostly tests): every field including Reasoning is treated as explicitly set. nil clears the user layer.

A non-empty DisplayName counts as explicitly set for the same reason, so a caller that spells one out gets it — the merge asks DisplayNameSet, which the JSON loader derives from the raw entry and a hand-built Model has no other way to assert.

func SetUserOverrides

func SetUserOverrides(overrides []UserOverride)

SetUserOverrides replaces the "user" layer with the given models.json overrides. User entries take precedence over every other layer; nil clears the layer.

func UpsertUserModel added in v0.108.1

func UpsertUserModel(path, providerKey string, um UserModel) error

UpsertUserModel inserts or replaces the entry for um.ID under providerKey, preserving every other entry, then writes the file atomically. Provider and id are required.

The entry always lands under the CANONICAL provider key, and the same id is dropped from every legacy-spelled block on the way. Without that fold, saving against a legacy-keyed file leaves two entries for one model, which the loader applies in map order — so any field set by both flips between runs.

func WriteUserModelsFile added in v0.108.1

func WriteUserModelsFile(path string, f UserModelsFile) error

WriteUserModelsFile writes f to path atomically (temp + rename), pretty-printed with a trailing newline. Provider blocks that hold no models are pruned first, so removing a provider's last override never leaves an empty husk behind.

Types

type Capability

type Capability string

Capability names one per-model feature flag. Typed string, not iota: the same names appear in models.json `capabilities` keys and in the on-disk model cache.

const (
	// CapImageInput marks vision models: ImageBlocks in user/tool
	// content serialize and the tool-image mirror runs. Models that
	// can't take images get image blocks dropped at serialization
	// instead of 400-bricking the session.
	CapImageInput Capability = "image-input"
	// CapImageOutput marks image-generation models. Reserved: no
	// consumer yet; tags without consumers are allowed (they're data).
	CapImageOutput Capability = "image-output"
	// CapReasoning is the query-surface alias for the legacy
	// Model.Reasoning field — Has falls back to it, so filters and
	// display treat reasoning like any other capability without
	// migrating the field's existing consumers.
	CapReasoning Capability = "reasoning"
)

func KnownCapabilities

func KnownCapabilities() []Capability

KnownCapabilities lists every capability terva understands, for models.json validation warnings.

type Client

type Client interface {
	// Name returns "anthropic" or "openai".
	Name() string
	// Stream starts a request. The returned channel delivers events
	// and is closed after EventDone. Errors during request setup are
	// returned directly; runtime errors arrive as EventDone{Err: ...}.
	Stream(ctx context.Context, req Request) (<-chan Event, error)
}

Client is an LLM streaming client.

func NewAnthropic

func NewAnthropic(apiKey, baseURL string) Client

NewAnthropic creates an Anthropic client using an API key. baseURL may be empty.

func NewAnthropicCompat

func NewAnthropicCompat(name, apiKey, baseURL string) Client

NewAnthropicCompat returns an anthropicClient pinned to a non-default base URL and identifying as `name` for cost / logging purposes. Auth is API key (x-api-key header). For OAuth-fronted compatibles (rare) use NewAnthropicOAuth and rename via NameClient.

func NewAnthropicOAuth

func NewAnthropicOAuth(accessToken, baseURL string) Client

NewAnthropicOAuth creates an Anthropic client using a subscription OAuth access token.

func NewAnthropicOAuthSource added in v0.119.0

func NewAnthropicOAuthSource(cred CredentialSource, baseURL string) Client

NewAnthropicOAuthSource is NewAnthropicOAuth with a CredentialSource instead of a fixed token, so the subscription access token can rotate (refresh) without rebuilding the client — resolved once per Stream.

func NewAzureOpenAI

func NewAzureOpenAI(apiKey, baseURL string) Client

NewAzureOpenAI returns an Azure OpenAI client.

baseURL examples (any is fine; we normalise trailing slashes):

https://my-resource.openai.azure.com
https://my-resource.openai.azure.com/openai/v1

apiKey is the Azure resource key. The api-version comes from AZURE_OPENAI_API_VERSION env var (default "2024-10-21").

func NewAzureOpenAIResponses

func NewAzureOpenAIResponses(apiKey, baseURL string) Client

NewAzureOpenAIResponses delegates to the real Azure OpenAI client. Despite the provider id mentioning "responses", terva uses Azure's Chat Completions endpoint (older but functionally complete for our agent loop) to avoid duplicating the full openai-responses wire client. Models register under provider id `azure-openai-responses` so user catalogs keep working unchanged.

func NewBedrock

func NewBedrock(apiKey, baseURL string) Client

NewBedrock returns an AWS Bedrock client. See amazon_bedrock.go for the hand-rolled Converse-Stream wire-format parser. Auth is via AWS_BEARER_TOKEN_BEDROCK (the modern Bedrock API key flow); SigV4 signing for IAM access-key + secret credentials is not yet wired.

func NewBedrockClient

func NewBedrockClient(apiKey, baseURL string) Client

NewBedrockClient returns a Bedrock client.

Auth resolution (first match wins):

  1. apiKey == real bearer-ish string (not "<aws>") -> bearer route.
  2. AWS_BEARER_TOKEN_BEDROCK env var -> bearer route.
  3. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN) -> SigV4 route.
  4. AWS_PROFILE -> read ~/.aws/credentials, take that profile's keys.

region defaults to us-east-1 unless AWS_REGION / AWS_DEFAULT_REGION is set or the baseURL embeds a region.

func NewCerebras

func NewCerebras(apiKey, baseURL string) Client

NewCerebras: ultra-fast inference (Llama/Qwen/GPT-OSS/GLM).

func NewCloudflareAIGateway

func NewCloudflareAIGateway(apiKey, baseURL string) Client

NewCloudflareAIGateway returns the AI Gateway client (OpenAI compat route). Sends `cf-aig-authorization` instead of `Authorization` so the gateway authenticates the caller (downstream-provider auth is configured per-gateway in the Cloudflare dashboard).

func NewCloudflareWorkersAI

func NewCloudflareWorkersAI(apiKey, baseURL string) Client

NewCloudflareWorkersAI returns an OpenAI-compatible client pinned to the Workers AI base URL with {CLOUDFLARE_ACCOUNT_ID} substituted. Returns an erroring client (deferred error on first Stream call) if the env var is missing, so the constructor signature stays the same.

func NewDeepSeek

func NewDeepSeek(apiKey, baseURL string) Client

NewDeepSeek creates a DeepSeek client. DeepSeek's chat API is OpenAI-compatible at https://api.deepseek.com/v1.

func NewFireworksAnthropic

func NewFireworksAnthropic(apiKey, baseURL string) Client

NewFireworksAnthropic is the main Fireworks route. The anthropic-messages-compatible endpoint at api.fireworks.ai/inference expects Anthropic-style request bodies; use this rather than the OpenAI flavor.

func NewGemini

func NewGemini(apiKey, baseURL string) Client

NewGemini creates a Gemini client using an AI Studio API key. baseURL may be empty; defaults to https://generativelanguage.googleapis.com.

func NewGithubCopilot

func NewGithubCopilot(apiKey, _ string) Client

NewGithubCopilot returns a GitHub Copilot client. The provided credential must be a GitHub Personal Access Token (PAT) with Copilot access enabled; terva trades it for a short-lived Copilot token on every inference request (cached in memory until ~5min before expiry).

Wire format: OpenAI Chat Completions. Copilot-specific headers (X-Initiator, Openai-Intent, Editor-Version, Editor-Plugin-Version, Copilot-Integration-Id, User-Agent) are added by the refresh transport. The model id passes through unchanged.

baseURL is ignored: the canonical host is read from `proxy-ep=...` in the short-lived token's value.

func NewGithubCopilotClient

func NewGithubCopilotClient(pat string) Client

NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client. The pat must be a GitHub Personal Access Token with Copilot access.

func NewGoogleVertex

func NewGoogleVertex(apiKey, baseURL string) Client

NewGoogleVertex returns a Vertex AI client. See google_vertex.go for the full auth + URL-rewrite implementation. Requires GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION, and either GOOGLE_CLOUD_API_KEY or a service- account JSON file pointed to by GOOGLE_APPLICATION_CREDENTIALS (or the default ADC location ~/.config/gcloud/application_default_credentials.json).

func NewGroq

func NewGroq(apiKey, baseURL string) Client

NewGroq: LPU inference (Llama/Kimi/Qwen/GPT-OSS).

func NewHuggingFace

func NewHuggingFace(apiKey, baseURL string) Client

NewHuggingFace: HF inference router.

func NewKimi

func NewKimi(apiKey, baseURL string) Client

NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible.

func NewKimiCodingSourceWithHeaders added in v0.119.0

func NewKimiCodingSourceWithHeaders(cred CredentialSource, baseURL string, headers map[string]string) Client

NewKimiCodingSourceWithHeaders is NewKimiCodingWithHeaders with a CredentialSource, so the subscription OAuth token can rotate without rebuilding the client. Kimi authenticates via x-api-key (not Bearer), so the client stays in non-oauth mode; only the credential value rotates.

func NewKimiCodingWithHeaders

func NewKimiCodingWithHeaders(apiKey, baseURL string, headers map[string]string) Client

NewKimiCodingWithHeaders is the Kimi Code client: Kimi behind the Anthropic Messages API at https://api.kimi.com/coding (replaces the older OpenAI-completions-on-/coding/v1 wiring).

func NewKimiWithHeaders

func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client

NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers.

func NewMinimaxAnthropic

func NewMinimaxAnthropic(apiKey, baseURL string) Client

NewMinimaxAnthropic is the anthropic-messages flavor on api.minimax.io/anthropic, catalogued under provider=minimax.

func NewMinimaxCNAnthropic

func NewMinimaxCNAnthropic(apiKey, baseURL string) Client

NewMinimaxCNAnthropic is the CN-region MiniMax (anthropic-messages).

func NewMistral

func NewMistral(apiKey, baseURL string) Client

NewMistral returns a Mistral client using their OpenAI-compatible Chat Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a bespoke "Conversations" API, but the OpenAI-compat endpoint supports the same models with tool calling and streaming, so we use that for simplicity (no extra wire format to maintain).

func NewMoonshot

func NewMoonshot(apiKey, baseURL string) Client

NewMoonshot is the global Moonshot AI endpoint (Kimi-K2 family by id). Provider id is `moonshotai`.

func NewMoonshotCN

func NewMoonshotCN(apiKey, baseURL string) Client

NewMoonshotCN is the China-region Moonshot endpoint. Same model ids as the global flavor, different base URL.

func NewOpenAI

func NewOpenAI(apiKey, baseURL string) Client

NewOpenAI creates an OpenAI client using an API key. baseURL may be empty.

func NewOpenAICodex

func NewOpenAICodex(token, accountID, baseURL string) Client

NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint using a subscription OAuth access token and the user's ChatGPT account id. baseURL may be empty to use the default.

func NewOpenAICodexSource added in v0.119.0

func NewOpenAICodexSource(cred CredentialSource, accountID, baseURL string) Client

NewOpenAICodexSource is NewOpenAICodex with a CredentialSource instead of a fixed token, so the OAuth access token can rotate (refresh) without rebuilding the client — the client resolves it once per Stream.

func NewOpenAIResponses

func NewOpenAIResponses(apiKey, baseURL string) Client

NewOpenAIResponses returns an OpenAI Responses-API client (API-key flow). Uses the same wire format as the ChatGPT Codex backend but with the public api.openai.com endpoint and standard Bearer auth.

baseURL may be empty; defaults to https://api.openai.com/v1/responses.

func NewOpenCode

func NewOpenCode(apiKey, baseURL string) Client

NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this constructor wires the openai-completions flavor only. Models that need the anthropic-messages flavor under the same provider should be built with NewAnthropicCompat against the same base URL.

func NewOpenCodeGo

func NewOpenCodeGo(apiKey, baseURL string) Client

NewOpenCodeGo is the opencode-go variant.

Usage windows (/usage): the OpenCode Go plan has no usage/balance endpoint yet, and the Zen gateway does not return subscription-window headers, so this client implements no UsageReporter and /usage shows "doesn't report usage limits" for it. When OpenCode ships the endpoint (anomalyco/opencode#16017 — rolling/weekly/monthly windows), light it up by wrapping this client in a UsageReporter that fetches it; the dialog and status hint then work with no further changes.

func NewOpenRouter

func NewOpenRouter(apiKey, baseURL string) Client

NewOpenRouter: OpenRouter aggregator. Unlocks dozens of upstream models with one key.

Usage (/usage): wrapped in a pollingUsageClient that lazily fetches GET /api/v1/key (works with the normal inference key) for the key's credit limit/remaining + lifetime spend. The dialog renders it as `Credits`; no subscription windows.

func NewTogether

func NewTogether(apiKey, baseURL string) Client

NewTogether: Together.ai aggregator.

func NewVercelGatewayAnthropic

func NewVercelGatewayAnthropic(apiKey, baseURL string) Client

NewVercelGatewayAnthropic — Vercel AI Gateway anthropic-messages route.

func NewVertex

func NewVertex(_ string, _ string) Client

NewVertex returns a Vertex AI client. The apiKey argument is ignored in favor of env-based config (GOOGLE_CLOUD_API_KEY or GOOGLE_APPLICATION_CREDENTIALS), since Vertex's auth model doesn't fit the "just paste a key" interface other providers use.

func NewXAI

func NewXAI(apiKey, baseURL string) Client

NewXAI: xAI Grok.

func NewXiaomi

func NewXiaomi(apiKey, baseURL string) Client

NewXiaomi: Xiaomi MiMo family (default endpoint).

func NewXiaomiTokenPlan

func NewXiaomiTokenPlan(region, apiKey, baseURL string) Client

NewXiaomiTokenPlan creates a regional Xiaomi token-plan client. region must be "ams", "cn", or "sgp", matching the three `xiaomi-token-plan-*` provider ids.

func NewZAI

func NewZAI(apiKey, baseURL string) Client

NewZAI: Z.AI GLM family.

func WithHTTPClient added in v0.108.3

func WithHTTPClient(c Client, httpClient *http.Client) Client

WithHTTPClient scopes an HTTP client to a concrete provider client. Only the OpenAI-compatible client (used by the openai-compatible and ollama providers) is handled, because --insecure is gated to exactly those providers in build.go — those are plain http.Client-based clients with no wrapped transport. Any other client type is returned unchanged so it keeps normal certificate verification (fail-safe: a naive swap of a wrapped-transport provider would otherwise silently bypass nothing or miss the inner transport).

type ClientCapabilities added in v0.105.0

type ClientCapabilities struct {
	// MirrorsToolImages is true for wire formats that can't carry
	// images inside a tool result (OpenAI chat-completions; the
	// Responses/codex function_call_output). The agent loop mirrors
	// such images into a following synthetic user message. This is a
	// WIRE-FORMAT fact; whether the current model can see images at
	// all is the separate per-model image-input capability the loop
	// checks alongside it.
	MirrorsToolImages bool

	// ContinuesAssistantPrefill is true for wire formats that treat a
	// TRAILING assistant message as a prefill to extend — the model
	// continues that message rather than starting a fresh turn. Anthropic
	// (Claude Messages) does; OpenAI treats it as history, Codex marks it
	// completed, and Gemini enforces strict alternation. The Stage
	// "continue" interaction (turn.continue) is gated on this: only a
	// prefill-continuing provider can extend the last response in place.
	ContinuesAssistantPrefill bool

	// ReasoningWire is the reasoning control this wire format actually
	// carries: an effort enum, a thinking budget, a Responses-route enum, a
	// thinkingBudget/thinkingLevel, or nothing. It is a WIRE-FORMAT fact and
	// so belongs to the client, not to the provider id.
	//
	// reasoningWireWiring restates the same decision keyed on provider id,
	// because /reasoning and the web picker hold only a Model and cannot reach
	// a Client. The two must agree; TestReasoningWireTableMatchesTheRealClient
	// in packages/agent/build is what makes them. That guard exists because
	// the table was wrong for two providers at once — vercel-ai-gateway
	// (anthropic client, read as OpenAI-compat) and azure-openai-responses
	// (openai client, read as Codex) — while the census that was supposed to
	// cover it only asserted a provider appeared SOMEWHERE, never that the
	// answer was right, and kept both offenders in its escape set.
	ReasoningWire reasoningWire
}

ClientCapabilities is the explicit, compiler-checked set of behaviors a Client can opt into. A new client capability is a field here, set in the concrete client's Capabilities() method — never a one-off optional interface (`interface{ Foo() bool }`) probed by a bespoke chain-walking helper. That older pattern silently returned the zero value whenever a wrapper sat in front of the concrete client (the MirrorsToolImages bug class): openai-responses and google-vertex ship wrapped in a renamedClient (and deepseek in a pollingUsageClient), so an inline assertion on the outer client missed the capability entirely.

func ClientCaps added in v0.105.0

func ClientCaps(c Client) ClientCapabilities

ClientCaps returns c's capabilities, looking through any wrapper layers (renamedClient, pollingUsageClient) via Unwrap(). Adding a capability is a struct field, not a new per-capability helper, and callers can never reintroduce the silent-false wrapper gap because there is no per-cap assertion to get wrong.

type CompactionBlock added in v0.131.5

type CompactionBlock struct {
	ID        string `json:"compaction_id,omitempty"`
	Encrypted string `json:"encrypted_content,omitempty"`
	// Provider names who issued the blob. Only that provider can decrypt it,
	// so this is what lets a later dispatch tell "replayable" from "so much
	// opaque text" BEFORE handing the transcript to a serializer.
	//
	// Provider, not provider+model: measured 2026-08-04, a blob compacted on
	// gpt-5.6-terra replays on sol and on gpt-5.5, all three recalling content
	// that existed only inside it. A /model switch within one provider is
	// safe; a provider switch is not.
	Provider string `json:"provider,omitempty"`
}

CompactionBlock carries a server-side compaction summary: an opaque blob in which the backend has encoded the conversation it compacted away, to be replayed on later requests in place of the turns it replaces.

It exists because the OpenAI Responses backend compacts through its own endpoint (POST <base>/compact) rather than leaving it to the client, and answers with items to send back as input — the last of which is this. Terva has always summarized client-side into a synthetic user message instead, which is a shape the backend never produced and, on the codex backend, is where every measured prompt-cache collapse begins. See docs/reviews/2026-08-04-gpt56-post-compaction-cache-collapse.md §9.1.

🪤 The wire type is `compaction_summary`, NOT `compaction` — the published write-ups say otherwise and a live probe says this. Encrypted is opaque: terva must round-trip it byte for byte and can never inspect or rebuild it, so any surface that drops this block silently discards the only record of what the compaction removed.

type Content

type Content interface {
	// contains filtered or unexported methods
}

Content is a block inside a Message. One of TextBlock, ImageBlock, ToolCallBlock, or ToolResultBlock.

type CredentialSource added in v0.119.0

type CredentialSource func(ctx context.Context) (string, error)

CredentialSource yields the auth credential (an API key or an OAuth access token) a client should present on a request. A client resolves it ONCE per Stream, so a long-lived client can rotate its credential — an OAuth refresh — without being reconstructed. This replaced an earlier model that rebuilt the whole client to swap a token, which discarded per-client state (usage snapshots, connection pools) on every rotation.

Implementations must be safe for concurrent use: one client can serve several sessions (bot mode mints an agent per chat), and their turns resolve the credential concurrently. A refreshing source should single-flight so concurrent callers coalesce onto one refresh rather than stampeding the token endpoint.

The ctx bounds a refresh that has to hit the network; a source that never refreshes (a static key) ignores it.

func StaticCredential added in v0.119.0

func StaticCredential(cred string) CredentialSource

StaticCredential is a CredentialSource for a fixed, non-rotating credential (every API-key client). It never errors and ignores the context.

type Credits added in v0.110.0

type Credits struct {
	Unlimited  bool
	HasCredits bool
	// Balance is in provider-defined units; only meaningful when the
	// provider reports it (Unlimited and HasCredits give the context).
	Balance float64
	// Used is the amount spent so far in provider-defined units, when the
	// provider reports it (e.g. OpenRouter's lifetime key usage). 0 when
	// unknown. This is the "usage" half of "usage and limits"; Balance is
	// the "remaining" half — some providers expose one, some the other.
	Used float64
}

Credits is the optional pay-as-you-go balance some plans expose alongside (or instead of) windowed limits.

type Event

type Event interface {
	// contains filtered or unexported methods
}

Event is one item from a provider stream.

type EventDone

type EventDone struct {
	Stop    StopReason
	Err     error
	Message Message // fully assembled assistant message
}

EventDone is always the final event on a stream.

type EventReasoningDelta added in v0.131.10

type EventReasoningDelta struct {
	Delta string
}

EventReasoningDelta carries a piece of the model's reasoning SUMMARY as it streams — the same text that lands in ReasoningBlock.Summary, delivered while the turn is still running instead of only once it finishes.

It exists because the summary was already arriving in pieces and terva was throwing the timing away: both openai clients accumulate these deltas into a buffer and emit one block at the end, so the stream was there and nothing could watch it. A summary read after the turn is of little use — the common shape is a progress headline ("**Inspecting commit before push**") whose whole value is being visible during the silence BEFORE the tool call it describes.

🪤 This is the provider's own précis, never raw chain-of-thought, and never the opaque reasoning payload (ReasoningBlock.Encrypted). A provider that streams no summary emits no events here, which is not an error: the openai backends return an empty summary unless Request.ReasoningSummary asked for one.

Consumers accumulate. Providers separate one summary section from the next with a blank line, so the CURRENT headline is the text after the last "\n\n" — the boundary rides the delta stream rather than a second event type, because a renderer that only wants the latest section should not have to track state that the text already carries.

type EventStart

type EventStart struct {
	Model    string
	Provider string
}

type EventTextDelta

type EventTextDelta struct {
	Delta string
}

type EventToolArgs

type EventToolArgs struct {
	ID    string
	Delta string // partial JSON
}

type EventToolEnd

type EventToolEnd struct {
	ID string
}

type EventToolStart

type EventToolStart struct {
	ID   string
	Name string
}

type EventTransport added in v0.131.1

type EventTransport struct {
	Info TransportInfo
}

EventTransport reports the transport picture of the dispatch whose events follow it. Emitted once per successful Stream, before any content event.

type EventUsage

type EventUsage struct {
	Usage Usage
}

type ImageBlock

type ImageBlock struct {
	MimeType string `json:"mime_type"`
	Data     []byte `json:"data"` // raw bytes; encoded to base64 on the wire
	// ID is the provider-issued generation id for an image the assistant
	// produced itself (OpenAI Responses "ig_…" image_generation_call id),
	// carried so a later turn can replay the image to edit it. Empty for
	// image *inputs* (the read tool, pasted images), which no provider
	// treats as editable and which never round-trip as a generation call.
	ID string `json:"id,omitempty"`
}

ImageBlock is an inline image (PNG/JPEG/GIF/WebP).

type ImageOutputConfig added in v0.125.1

type ImageOutputConfig struct {
	// Size and Quality configure the built-in image tool, e.g.
	// "1024x1024"/"1024x1536"/"1536x1024"/"auto" and
	// "low"/"medium"/"high"/"auto". Empty lets the provider default.
	Size    string
	Quality string
	// EditHistory bounds how many of the most recent assistant-generated
	// images are replayed to the model (with their bytes) so it can edit
	// them. Each replayed image re-uploads as image-input tokens every turn,
	// so this trades cost/latency for how far back editing reaches. 0 means
	// generation only (no image replayed); 1 (the default) keeps only the
	// most recent image editable.
	EditHistory int
}

ImageOutputConfig configures native image output (see Request.ImageOutput).

type Message

type Message struct {
	Role    Role              `json:"role"`
	Content []Content         `json:"content"`
	Time    time.Time         `json:"time"`
	Meta    map[string]string `json:"meta,omitempty"`
}

Message is a single turn in the conversation.

func EnsureLeadingUserTurn added in v0.113.0

func EnsureLeadingUserTurn(msgs []Message) []Message

EnsureLeadingUserTurn prepends a minimal, request-scoped user turn when the first message is an assistant turn. A character card seeds its opening greeting as an assistant message[0]; the Anthropic, Bedrock Converse, and Gemini APIs all reject a conversation that does not begin with a user turn, and the OpenAI-family builders apply it too as a safety net for strict OpenAI-compatible backends (Moonshot/Kimi, local alternation-enforcing templates) that share the wire format. Prepending keeps the greeting in the transcript while the request stays well-formed. It returns a new slice and never mutates the input, so the added turn is request-scoped and is never cached as part of the history prefix. Dormant for normal conversations, which always open with a user turn.

The placeholder is a bracketed stage cue, not an utterance: a greeting like "Hello." would recast a character-initiated opening (an ambush, a monologue) as a reply to the user, distorting the scene on every request for the whole session. It must carry visible text — these APIs also reject empty or whitespace-only blocks.

func MergeAdjacentSameRole added in v0.125.1

func MergeAdjacentSameRole(msgs []Message) []Message

MergeAdjacentSameRole coalesces consecutive messages that share a role into one by concatenating their content — a request-scoped repair for the strict alternation the Anthropic, Bedrock, and Gemini APIs enforce (and that some OpenAI-compatible backends, e.g. Moonshot/Kimi and local templates, do too).

Transcript revision can leave two same-role turns adjacent: deleting the assistant reply between two user messages, editing across a boundary, or a compaction summary landing next to a same-role turn. Those APIs reject the resulting sequence with a 400. Merging restores alternation while keeping every turn's content. It runs AFTER tool-pair repair, so tool_use/tool_result structure is already settled and merging same-role turns cannot split a pair.

It returns a new slice and copies any message it merges into, so the input and its messages are never mutated — the merge is request-scoped and never cached as part of the history prefix. Dormant for well-formed transcripts, which already alternate (a step's parallel tool results are one RoleTool message, so normal turns never trip it).

func RepairOrphanedToolResults

func RepairOrphanedToolResults(msgs []Message) []Message

RepairOrphanedToolResults removes tool_result content blocks (and entire messages that become empty) when the matching tool_use ID does not appear anywhere in the given messages. Resume tails, compaction repair, and provider request builders all need this so the upstream API never sees a tool_call_id with no corresponding assistant tool_call earlier in the same request.

type Model

type Model struct {
	Provider      string // "anthropic" | "openai"
	ID            string // API id
	DisplayName   string
	ContextWindow int // model max — the hard ceiling (maxTok clamp, display)

	// DesiredContextWindow is the working window that drives auto-compaction
	// (the warn/compact thresholds are fractions of it, via
	// EffectiveContextWindow). It lets a user keep a large-window model but
	// compact earlier — e.g. to stay under a long-context pricing surcharge —
	// without pretending the model is smaller. 0 means "use ContextWindow";
	// a value above ContextWindow is clamped down. User-settable per model
	// via models.json `desiredContextWindow`.
	DesiredContextWindow int
	// ContextSurchargeAt is the input-token count above which the provider
	// charges a higher rate (OpenAI bills 2x input / 1.5x output past 272K on
	// GPT-5.6). Informational: it names the natural cost-safe value for
	// DesiredContextWindow. 0 means no surcharge tier.
	ContextSurchargeAt int

	MaxOutput int
	Reasoning bool // supports reasoning/thinking

	// AdaptiveThinking marks Anthropic models that only support the
	// adaptive thinking mode (Opus 4.7+). These reject explicit
	// thinking budgets (thinking:{type:"enabled",budget_tokens:N} -> 400)
	// and also reject non-default sampling params (temperature/top_p/
	// top_k). The Anthropic client sends thinking:{type:"adaptive"} plus
	// output_config.effort and omits temperature for these models.
	AdaptiveThinking bool

	// Prices are USD per 1M tokens.
	PriceInput      float64
	PriceOutput     float64
	PriceCacheRead  float64
	PriceCacheWrite float64

	// PriceOutputImage is the output rate for IMAGE tokens, for the
	// image-generating models that bill their output at two different
	// rates depending on what they emitted.
	//
	// Gemini's nano-banana family is the case: gemini-3.1-flash-image
	// bills text and thinking at $3/1M but images at $60/1M, on the same
	// model, in the same response. One rate cannot describe that. Pricing
	// every output token at the text rate understates an image turn 20x;
	// pricing them all at the image rate overstates a text turn by the
	// same factor, and these models hold ordinary conversations between
	// pictures.
	//
	// 0 means "this model has one output rate", which is every other model
	// in the catalog, and ComputeCost then prices output exactly as it
	// always did. So a model that never sets this is untouched by it.
	PriceOutputImage float64

	// Speculative marks models whose ids are known from the upstream
	// vendor's CLI but not yet live on their public API. They'll 404
	// today but start working the moment the provider flips the switch.
	Speculative bool

	// BaseURL overrides the provider's default API endpoint for this
	// model. Optional; when empty the provider's default (or the
	// --base-url flag) is used. Useful for local models served by
	// ollama, vLLM, LM Studio, etc.
	BaseURL string

	// Source is where this model entry came from: "catalog" (baked in),
	// "live" (discovered via /v1/models), or "cache" (loaded from the
	// on-disk cache). Informational.
	Source string

	// Caps holds explicit capability assertions; an absent key means
	// unknown and resolves through capDefaults via Has. Treat as
	// immutable after construction — Model is copied by value
	// everywhere, so the map is shared between copies; the only writer
	// is the layer merge, which builds fresh maps (mergeCaps). All
	// reads go through Has, never the map directly. See
	// docs/plans/model-capabilities.md.
	Caps map[Capability]bool

	// Temperature is this model's default sampling temperature (0–2), set
	// via models.json. nil leaves it to the global config / provider
	// default. AdaptiveThinking models ignore it (they reject sampling
	// params). Launch resolve order: --temperature flag > per-model > global
	// config. One of the registry-driven scalar params (see ScalarParams).
	Temperature *float32

	// DefaultReasoning is the reasoning level this model uses when the user has
	// set NO global level (--reasoning / config unset). A raw level string
	// (off/minimum/low/medium/high/maximum/max); "" means no default (off), the
	// prior behavior. Ships in the catalog and is overridable per-model via
	// models.json `defaultReasoning`. NORMALIZED at the point of use, so a
	// literal "off" here forces thinking off for the model unless the user sets a
	// global level. Some endpoints (Kimi K3) silently downgrade to an older model
	// when no thinking is sent, so their catalog rows set this to "high".
	DefaultReasoning string

	// DefaultReasoningSet marks a DefaultReasoning the OPERATOR chose in
	// models.json, as opposed to one the catalog shipped. Set by
	// applyUserOverrides; never by the catalog. Same distinction, and for the
	// same reason, as DisplayNameSet below.
	//
	// 🪤 The two are not interchangeable and collapsing them breaks one of the
	// callers. A catalog DefaultReasoning is terva's FALLBACK — the k3 rows
	// carry "high" only so the endpoint stops silently downgrading to K2, and
	// the comment on those rows says in as many words that it applies "unless
	// the user sets a global level". So the catalog value must stay BELOW the
	// global setting. An operator's models.json value is the opposite: it is a
	// deliberate per-model choice, and a global default that overrode it would
	// make the field unreachable for anyone who has ever touched /settings.
	// Hence one field, two precedence slots, told apart by this flag.
	DefaultReasoningSet bool

	// DisplayNameSet marks a DisplayName the operator chose in models.json
	// (`name`), as opposed to one the catalog or live discovery supplied.
	// The distinction is what lets a surface that shows the raw id — the
	// status bar, the picker's id column — swap in the operator's name
	// WITHOUT also swapping in catalog names, which run longer than the ids
	// they'd replace ("Claude Sonnet 4.5 (latest)" vs claude-sonnet-4-5).
	// Set by applyUserOverrides; never by the catalog or live layers.
	DisplayNameSet bool
}

Model describes a single LLM we know about.

func Active

func Active() []Model

Active returns the current merged catalog.

When no layer has ever been set it returns the fully-assembled static Catalog. Reading Catalog at call time (rather than capturing it into a package-level var initializer) is load-bearing: the extended catalog in catalog_builtin.go / extra_models.go is appended from init() functions, which run AFTER package-level var initializers. Snapshotting Catalog at var-init time would freeze the picker to the curated seed list and drop every extra provider (openrouter, groq, xai, ...). The same applies to remergeLocked — it only ever runs from a setter call, well after init.

func DiscoverAnthropic

func DiscoverAnthropic(ctx context.Context, apiKey, baseURL string) ([]Model, error)

DiscoverAnthropic lists model ids visible to key on api.anthropic.com. The API returns a paginated list; we page through until has_more is false.

func DiscoverGoogle

func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error)

DiscoverGoogle lists Gemini model ids visible to key on generativelanguage.googleapis.com. The API paginates with nextPageToken; we follow it until exhausted.

func DiscoverOpenAI

func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error)

DiscoverOpenAI lists model ids visible to key on api.openai.com.

func DiscoverOpenAICompatible

func DiscoverOpenAICompatible(ctx context.Context, baseURL, key string, defaultCtx int) ([]Model, error)

DiscoverOpenAICompatible lists every model id a user-configured OpenAI-compatible endpoint reports from /v1/models. The standard response carries only ids, so context sizes are best-effort: we read the common non-standard hints (vLLM's max_model_len, others' context_length / context_window) when present and otherwise fall back to defaultCtx. The API key is optional — keyless local servers are common. Obvious non-chat artefacts (embeddings, rerankers, audio) are skipped to keep the picker focused on usable models.

func DiscoverOpenRouter

func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error)

DiscoverOpenRouter lists models from OpenRouter's public /models endpoint (no auth). Per-token USD prices are converted to USD per 1M tokens to match the rest of the catalog. baseURL defaults to the public endpoint.

func FindModel

func FindModel(provider, id string) (Model, error)

FindModel returns a Model by id, optionally constrained by provider. If provider is empty, the first matching id is returned. Looks up against the merged active catalog.

func MergeCatalog

func MergeCatalog(live []Model) []Model

MergeCatalog returns the baked-in catalog overlaid with live entries. Precedence per id: live > catalog; speculative entries are promoted to non-speculative when a matching live id appears.

Unknown live ids (not in the static catalog) are appended at the end with placeholder prices so they still render in the picker. Prices can be populated later from a richer catalog source. For discovery-authoritative providers (see above), baked entries the live endpoint didn't return are dropped as stale.

func ModelsForProvider

func ModelsForProvider(provider string) []Model

ModelsForProvider returns all models for the given provider, from the merged active catalog.

func (Model) EffectiveContextWindow added in v0.120.0

func (m Model) EffectiveContextWindow() int

EffectiveContextWindow is the window used for auto-compaction: the desired working window when set and sane, otherwise the model max. A desired window above the model max is clamped down (you cannot make the model hold more than it can); a desired window on a model whose max is unknown (0) is honored as-is. The hard ceiling — maxTok clamp and capability display — always uses ContextWindow; this only moves the warn/compact thresholds.

func (Model) Has

func (m Model) Has(c Capability) bool

Has reports whether the model has the capability: an explicit assertion when present, the legacy Reasoning field for CapReasoning, the per-capability default otherwise. This is the only sanctioned read path for Caps.

func (Model) Label added in v0.130.0

func (m Model) Label() string

Label is the model's name for a surface that would otherwise print the raw id: the operator's models.json `name` when they set one, the id otherwise. Catalog display names deliberately do NOT win here — see DisplayNameSet. Surfaces that show name and id side by side (the picker's second column, `terva models`) want DisplayName directly instead.

type ModelCache

type ModelCache struct {
	FetchedAt time.Time `json:"fetched_at"`
	Version   int       `json:"version,omitempty"`
	// Endpoints is an opaque signature of the user's configured
	// OpenAI-compatible endpoints at write time. The discoverer re-runs when
	// it changes, so adding/removing an endpoint refreshes without waiting out
	// CacheTTL. (Computed by the agent layer; the cache just carries it.)
	Endpoints string  `json:"endpoints,omitempty"`
	Models    []Model `json:"models"`
}

ModelCache is the on-disk shape for discovered models.

func LoadCache

func LoadCache(path string) (ModelCache, error)

LoadCache reads the model cache from path. Returns an empty ModelCache (no error) if the file does not exist.

func (ModelCache) IsCurrent added in v0.109.0

func (c ModelCache) IsCurrent() bool

IsCurrent reports whether the cache is fresh AND written by a binary with the same discovery set (ModelCacheVersion). A version mismatch forces re-discovery so newly-added providers appear without waiting out CacheTTL.

func (ModelCache) IsFresh

func (c ModelCache) IsFresh() bool

IsFresh reports whether the cache was fetched within CacheTTL.

type ProviderError

type ProviderError struct {
	// Provider is the client name as shown to users, e.g. "anthropic"
	// or "openai-compatible".
	Provider string
	// Status is the HTTP status code, or 0 when the failure was not
	// an HTTP response (stream death, in-stream error events).
	Status int
	// Transient marks failures worth retrying: rate limits, 5xx,
	// truncated streams, throttling. Set at construction by the code
	// that understands the wire protocol.
	Transient bool
	// RetryAfter is the server-requested wait parsed from a
	// Retry-After header; 0 when absent. Core's retry loop honors it
	// (capped) instead of its default backoff.
	RetryAfter time.Duration
	// Msg is the human-readable description: a body excerpt for HTTP
	// failures, the event message for in-stream errors.
	Msg string
	// contains filtered or unexported fields
}

ProviderError is the typed error every in-tree client returns for a provider-side failure: a non-2xx HTTP response, an in-stream error event, or a connection that died before the protocol's terminal frame. Downstream classification — core's retry loop, the rescue dialog, 413 auto-compact — switches on these fields instead of substring-matching rendered prose, so a provider rewording an error can no longer silently change retry behavior.

Clients construct it with their own protocol knowledge: only the anthropic client knows "overloaded_error" is transient, only the bedrock client knows "throttlingException" is. Policy lives where the information is; consumers just read Transient.

Custom Client implementations (SDK embedders) that want retry and rescue classification should return this type too; untyped errors get a conservative transport-level fallback (IsTransportError) and are otherwise treated as permanent.

func NewAPIError

func NewAPIError(provider, msg string, transient bool) *ProviderError

NewAPIError wraps an error event delivered inside an otherwise-OK stream. transient comes from the constructing client's knowledge of its protocol's error vocabulary.

func NewHTTPError

func NewHTTPError(provider string, status int, retryAfter, body string) *ProviderError

NewHTTPError classifies a non-2xx HTTP response. retryAfter is the raw Retry-After header value ("" when absent); body is the response body (callers may pre-truncate).

func NewStreamDeathError

func NewStreamDeathError(provider, terminal string) *ProviderError

NewStreamDeathError marks a connection that closed before the protocol's terminal frame (anthropic's message_stop, openai's [DONE], …) — a truncation, always worth retrying. Wraps io.ErrUnexpectedEOF so errors.Is keeps working.

func NewStreamLimitError added in v0.119.0

func NewStreamLimitError(provider string, max int) *ProviderError

NewStreamLimitError marks a stream aborted because a single wire line exceeded the reader's ceiling.

Unlike stream death this is *permanent*, and the distinction is the whole point. A truncated stream is a transport accident: retrying re-runs the request and probably succeeds. An over-limit line is deterministic — the server re-sends the identical oversized event on every attempt — so marking it transient burns the entire retry budget, paying full input tokens per attempt, to fail in exactly the same place and then blame the network. Transient stays false.

func NewStreamReadError added in v0.119.0

func NewStreamReadError(provider string, err error) *ProviderError

NewStreamReadError wraps a transport failure that killed an event stream mid-read. Transient: the read failed, not the payload.

func (*ProviderError) Error

func (e *ProviderError) Error() string

func (*ProviderError) Unwrap

func (e *ProviderError) Unwrap() error

type ReasoningBlock

type ReasoningBlock struct {
	ID        string `json:"reasoning_id,omitempty"`
	Summary   string `json:"summary,omitempty"`
	Encrypted string `json:"encrypted_content,omitempty"`

	// Shape names the provider block this reasoning was captured from, and
	// is empty for every provider whose reasoning terva does not replay
	// verbatim (Codex replays by ID + Encrypted, which needs no tag).
	//
	// 🪤 It exists because a transcript outlives a /model switch. Reasoning
	// blocks from one provider are meaningless to another — a Codex item id
	// replayed as an Anthropic thinking block is a 400 on every turn that
	// follows — and Summary+Encrypted alone cannot tell the two apart. Any
	// serializer that replays a ReasoningBlock verbatim MUST check Shape
	// first and drop what it does not recognize, which is also what keeps
	// today's behavior for the untagged case.
	//
	// This is CompactionBlock.Provider's reason, one type over: the block is
	// only replayable where it came from.
	Shape string `json:"shape,omitempty"`
}

ReasoningBlock carries the assistant's chain-of-thought metadata so providers that require it on follow-up requests (OpenAI Codex with thinking enabled) can replay the same payload they emitted earlier. Summary is the human-readable reasoning summary (may be empty); the encrypted blob is opaque to terva. ID is the provider-issued reasoning item id.

func NormalizeLegacyReasoningShape added in v0.131.10

func NormalizeLegacyReasoningShape(b ReasoningBlock) ReasoningBlock

NormalizeLegacyReasoningShape fills in Shape for a block written before terva tagged them, and leaves a tagged block alone.

The legacy population is FROZEN — every capture site tags at the source now — so this only has to be right about what already exists on disk, and it can be deleted once those sessions have aged out.

🔑 The discriminator is a payload, not a timeline. A session records only its OPENING provider (SessionMeta) and writes no row at all for a /model switch, so "which provider was active" is not recoverable from the file. But before tagging existed, the Responses/Codex path was the only one that set ID or Encrypted — the chat and Gemini paths are summary-only, and Anthropic blocks have carried a Shape since capture for them was added. So a payload on an untagged block identifies it exactly.

Getting this wrong in the other direction is the expensive one: an untagged Codex block that stops being replayed makes the backend reject the next tool call in a resumed session, while a misfiled summary costs only some prose.

type ReasoningEffect added in v0.131.5

type ReasoningEffect struct {
	// Budget is the thinking-token budget actually sent, or 0 when this
	// backend takes no budget.
	Budget int
	// Effort is the effort/level enum actually sent ("low", "HIGH", …), or ""
	// when this backend takes no effort knob.
	Effort string
	// Supported is false when the model accepts no reasoning control at all,
	// which is a different statement from "this rung turns reasoning off".
	Supported bool
}

ReasoningEffect is what one ladder rung actually becomes on the wire for a given model: a thinking-token budget, an effort/level enum, or nothing.

It exists because a rung's NAME is not its meaning. terva's ladder is one list for every backend, but the backends do not agree: Anthropic takes a token budget clamped by the model's output cap, Gemini 2.5 takes a budget with a per-model ceiling while Gemini 3 takes an enum, the Codex/Responses route takes an effort enum with no budget at all, and generic OpenAI-compatible endpoints collapse six rungs onto three efforts.

So a surface that prints "~32k tokens of thinking" for every model is wrong on most of them, and silently: the request carries something else entirely.

Comparable on purpose — a caller can spot two rungs that land on the same wire value and say so, rather than offering the user a choice that is not one.

func ReasoningEffectFor added in v0.131.5

func ReasoningEffectFor(m Model, level string) ReasoningEffect

ReasoningEffectFor resolves what level does to m.

Every arm DELEGATES to the function the request builder itself calls — geminiThinkingConfig, OpenAICodexReasoningEffort, anthropicThinkingBudget, and the effort mappers — so this cannot describe a rung differently from the way it is sent. The only thing added here is the routing from a provider to the wire it speaks, and reasoningWireFamily is census-guarded so a new provider cannot slip through unclassified.

func (ReasoningEffect) Off added in v0.131.5

func (e ReasoningEffect) Off() bool

Off reports whether this rung leaves reasoning disabled on this model.

type ReasoningRung added in v0.131.5

type ReasoningRung struct {
	// Level is the rung as a user types it ("off", "minimum", …).
	Level string
	// Effect is what this rung becomes on the wire for this model.
	Effect ReasoningEffect
	// SameAs names the rung this one collapses onto, or "" when this rung is
	// the canonical one for its wire value. It is what stops a picker from
	// offering four rungs that are, on this model, a single choice.
	SameAs string
}

ReasoningRung is one row of the ladder as it applies to ONE model: the rung a user picks, what it becomes on the wire, and — when several rungs land on the same wire value — which of them is the one worth naming.

func ReasoningLadderFor added in v0.131.5

func ReasoningLadderFor(m Model) []ReasoningRung

ReasoningLadderFor builds the whole ladder for m with the collapse annotations resolved — everything a surface needs to explain the ladder, computed once here rather than in each frontend.

It returns nil when m accepts no reasoning control at all. That is a DIFFERENT answer from a ladder whose rungs are all off: "this model takes no thinking setting" versus "you have chosen not to think", and a client that conflated them would tell a Bedrock user their model was merely switched off.

The canonical rung is not simply the first: when minimum and low both send effort "low", the rung a user recognizes is low, and annotating THAT one as "same as minimum" reads backwards. So the canonical rung is the one whose NAME matches the wire value, and only where no name matches does ladder order decide.

type ReasoningSource added in v0.132.1

type ReasoningSource int

ReasoningSource names the layer of the chain that decided a level.

It exists because "which one won" is a different question from "what is the level", and every surface that explains the setting to a user needs the first. Without it a surface can only re-derive the answer from the raw inputs, and the tree had five doing exactly that — all wrong the same way.

Deliberately NOT called a rung. In this package a ReasoningRung is already one row of the LEVEL ladder ("off" … "max") as it applies to one model, and the two ideas are perpendicular: a level says how hard to think, a source says who chose it. Reusing the word is how you end up with the drift this symbol exists to end. See ResolveReasoning.

const (
	// ReasoningFromSession is the --reasoning flag or a session override.
	ReasoningFromSession ReasoningSource = iota
	// ReasoningFromModelOperator is an operator's per-model models.json
	// `defaultReasoning`. It sits ABOVE the global setting: it is a choice
	// someone made about this model specifically.
	ReasoningFromModelOperator
	// ReasoningFromGlobal is the global config setting.
	ReasoningFromGlobal
	// ReasoningFromModelCatalog is the model's catalog DefaultReasoning. It
	// sits BELOW the global setting: it is a fallback shipped with the row,
	// meant to yield to anything the user actually chose.
	ReasoningFromModelCatalog
	// ReasoningFromNothing is nothing set anywhere — the chain runs out.
	ReasoningFromNothing
)

func ResolveReasoning added in v0.132.1

func ResolveReasoning(session string, m Model, global string) (level string, from ReasoningSource)

ResolveReasoning composes the WHOLE reasoning chain and reports which layer decided it:

--reasoning / session > models.json per-model > global config > CATALOG default > off

Until this existed the chain was composed nowhere in production. The turn path walked it in two halves that could not see each other — build.Resolve handled the first three rungs, EffectiveReasoning the last two — and the only thing that ever joined them was a helper inside a test. Every display surface therefore re-derived it by hand from (global, model.DefaultReasoning), and every one of them made the same mistake: with no way to tell an operator's per-model value from a catalog default, they collapsed the two model rungs into one and put it BELOW the global.

What that costs the operator: they set `defaultReasoning` for a model in models.json, and the dialog tells them the session will "follow the global setting" — naming a value that is not deciding anything. The turn then runs at their per-model level, so the surface and the behaviour disagree.

🪤 The two model rungs are the SAME FIELD (DefaultReasoning) on opposite sides of the global, told apart only by DefaultReasoningSet. Reading the raw field without the set-signal makes a global "low" unreachable on every k3 row (they carry a catalog default so the endpoint stops downgrading to K2).

Precedence is decided on the RAW strings, before normalizing: a non-empty raw level — including "off"/"none", which normalize to "" — is an explicit choice, and must beat the rungs below it. The returned level IS normalized.

type Request

type Request struct {
	Model       string
	System      string
	Messages    []Message
	Tools       []Tool
	MaxTokens   int
	Temperature *float32
	// Reasoning is "", "minimum", "low", "medium", "high", "maximum", or
	// "max". Empty disables reasoning. Budget-based providers map these to
	// roughly 1k/2k/8k/16k/32k thinking tokens; effort-based providers map
	// them onto their closest supported reasoning_effort values. "max" is
	// sent natively only to models that support it (GPT-5.6, adaptive
	// Claude) and clamped to the "maximum" effort elsewhere.
	Reasoning string

	// ReasoningSet reports the user explicitly chose the global reasoning level
	// (including "off"), so it wins over a model's DefaultReasoning. False means
	// unset — fall back to the model default.
	ReasoningSet bool

	// ReasoningSummary asks the provider to emit a human-readable summary of
	// its reasoning alongside the opaque payload, so an autonomous run's
	// session record shows WHY it acted and not only what it did. Values are
	// provider-defined; the OpenAI Responses/codex backend accepts "auto",
	// "concise", and "detailed". Empty (the default) requests no summary and
	// leaves the request byte-identical to one built without this field.
	// Only the codex client acts on it today — a summary is only ever emitted
	// when asked for, so this is inert everywhere else.
	ReasoningSummary string

	// EphemeralContext is host-assembled, host-wrapped text injected into
	// the model's context for THIS request only — never part of Messages
	// and never persisted to the transcript. Providers append it as a
	// trailing message AFTER the cache breakpoint so the cached prefix
	// (system + tools + history) still hits and only this block is
	// re-processed. Used for standing context that changes between turns
	// (e.g. an extension's live task card). Empty means inject nothing.
	EphemeralContext string

	// PromptCacheKey is a stable per-conversation identifier forwarded to
	// providers whose prefix cache uses it for routing (OpenAI's
	// prompt_cache_key, on both Responses and Chat Completions). Without
	// it, concurrent conversations on one account — a coordinator plus its
	// swarm children — hash into overlapping cache shards and evict each
	// other's prefixes. The agent loop sets it from the session's meta
	// UUID (globally unique where the file basename is not — every swarm
	// child's transcript is named session.json); empty sends nothing.
	// Only clients known to accept the field forward it
	// (OpenAI-compatible backends may reject unknown parameters).
	PromptCacheKey string

	// WorkingDir is the directory a provider writes generated files into.
	//
	// It exists because Gemini returns a generated image as inline base64 in
	// the response body, and the client saves it to disk so the assistant
	// message can carry a path. That save used to join against "." — the
	// PROCESS working directory, which terva never changes (see
	// packages/relaunch: --cwd moves the AGENT's workspace, not the process).
	// So a session started from one directory against a workspace in another
	// dropped its images wherever the binary happened to be launched.
	//
	// Empty preserves that old behavior (the process cwd), which keeps every
	// caller that does not set it — tests, embedders, one-shot helper
	// requests — working exactly as before.
	WorkingDir string

	// ImageOutput, when non-nil, enables native (in-protocol) image output
	// for this request: the model may draw images inline in its own turn via
	// the provider's built-in image tool (OpenAI Responses image_generation),
	// which arrive as ImageBlocks in the assistant Message. Nil means the
	// tool is not offered. Only clients that implement it act on this; the
	// rest ignore the field. The host gates this (opt-in config + the model's
	// CapImageOutput + not plan mode) before setting it.
	ImageOutput *ImageOutputConfig
}

Request is a single LLM call.

type ResetStatus added in v0.120.0

type ResetStatus string

ResetStatus is the lifecycle of a single reset credit.

const (
	// ResetAvailable is a credit that can be redeemed now.
	ResetAvailable ResetStatus = "available"
	// ResetPending is a credit whose redemption has started but not completed
	// (the provider reports an in-flight redeem). Rare; shown as not-yet-usable.
	ResetPending ResetStatus = "pending"
	// ResetRedeemed is a spent credit.
	ResetRedeemed ResetStatus = "redeemed"
	// ResetExpired is a credit past its expiry that was never redeemed.
	ResetExpired ResetStatus = "expired"
)

type Role

type Role string

Role is the speaker of a Message.

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

type ScalarKind added in v0.108.4

type ScalarKind int

ScalarKind classifies an editor-managed scalar parameter.

const (
	ScalarText  ScalarKind = iota // free string (base url)
	ScalarInt                     // non-negative integer (context window, max tokens)
	ScalarFloat                   // bounded float (temperature)
)

type ScalarParam added in v0.108.4

type ScalarParam struct {
	Key   string
	Label string
	Kind  ScalarKind
	Min   float64 // ScalarFloat: inclusive lower bound
	Max   float64 // ScalarFloat: inclusive upper bound

	// Default renders the catalog/live default shown as "inherit (...)" in
	// the editor. It may report a sentinel (e.g. "n/a") when the parameter
	// doesn't apply to m.
	Default func(m Model) string
	// Override renders the user's current override as a string ("" = inherit).
	Override func(um UserModel) string
	// SetOverride parses an already-trimmed editor value ("" clears the
	// override) and writes it onto um, returning a validation error for an
	// out-of-range or malformed value. It is the single validation authority.
	SetOverride func(um *UserModel, s string) error
	// Merge copies a SET override (Model->Model) onto dst during the user
	// layer merge; a no-op when the user left the parameter unset. src is the
	// override entry already converted to a Model by the loader.
	Merge func(dst *Model, src Model)
}

ScalarParam declares one scalar model override end to end.

func ScalarParams added in v0.108.4

func ScalarParams() []ScalarParam

ScalarParams returns the editor-managed scalar model parameters, in editor row order. The slice is shared; callers must not mutate it.

type ServerCompactor added in v0.131.5

type ServerCompactor interface {
	// CompactServerSide asks the backend to compact req.Messages and returns
	// the replacement transcript. The result is meant to REPLACE the messages
	// it was given, not to be appended to them.
	//
	// The Usage is the compaction call's own spend, and it is returned rather
	// than dropped because the request is transcript-sized and billed like any
	// other. A server-side compaction that reported nothing would read in an A/B
	// against the client summarizers as FREE, which is the one number that would
	// make the comparison say the opposite of the truth.
	CompactServerSide(ctx context.Context, req Request) ([]Message, Usage, error)
}

ServerCompactor is implemented by clients whose backend compacts the transcript itself, rather than leaving the client to summarize it.

Probed as an optional interface through clientAs rather than declared as a ClientCapabilities bool, because the capability and the call are the same fact: a client that can answer the question implements the method, and there is no way to assert one without providing the other. clientAs walks the wrapper chain (renamedClient, pollingUsageClient), so a wrapped client still reports honestly — the failure mode a bare type assertion on the outer client would reintroduce.

func ServerCompactorFor added in v0.131.5

func ServerCompactorFor(c Client) (ServerCompactor, bool)

ServerCompactorFor returns c's server-side compactor, looking through any wrapper layers.

type StopReason

type StopReason string

StopReason describes why a turn ended.

const (
	StopEnd     StopReason = "end"
	StopLength  StopReason = "length"
	StopToolUse StopReason = "tool_use"
	StopError   StopReason = "error"
	StopAborted StopReason = "aborted"
)

type TextBlock

type TextBlock struct {
	Text string `json:"text"`
}

TextBlock is plain text content.

type Tool

type Tool struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Schema      json.RawMessage `json:"schema"` // JSON Schema for arguments
}

Tool is a tool definition advertised to the LLM.

type ToolCallBlock

type ToolCallBlock struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// Arguments is ALWAYS valid JSON — see FinalizeToolArguments, which every
	// provider routes its streamed buffer through. Callers may marshal a block
	// without checking; an invalid value here would break the session writer,
	// the request builders, and the ctrlproto wire alike.
	Arguments json.RawMessage `json:"arguments"`
	// RawArguments holds the model's original argument text when it could not
	// be parsed or repaired, and is empty otherwise. Arguments is "{}" in that
	// case, so the block stays marshalable while the evidence of what the model
	// tried to send survives for the transcript and for the error the model is
	// given back.
	RawArguments string `json:"raw_arguments,omitempty"`
	// Signature is an opaque provider-issued token that must be replayed
	// verbatim on the request that carries this call back in history.
	//
	// 🪤 Gemini 3 issues a `thoughtSignature` on the part that carries a
	// functionCall and REJECTS the next request with HTTP 400 "Function call is
	// missing a thought_signature in functionCall parts" if it does not come
	// back. The signature is the model's sealed reasoning for that call, so it
	// cannot be reconstructed — any surface that drops this field turns a
	// working tool loop into a hard 400 on the very next turn, which is exactly
	// how it was found. Round-trip it byte for byte; never inspect or synthesize
	// one.
	Signature string `json:"signature,omitempty"`
}

ToolCallBlock is an assistant-issued call to a tool.

type ToolResultBlock

type ToolResultBlock struct {
	CallID  string    `json:"call_id"`
	Content []Content `json:"content"`
	IsError bool      `json:"is_error"`
}

ToolResultBlock is the result of a tool execution, attached to a Message with Role == RoleTool.

type TransportInfo added in v0.131.1

type TransportInfo struct {
	// ConnReused is true when the request rode an existing keep-alive
	// connection; false means a fresh dial (new TLS session, new edge
	// assignment).
	ConnReused bool `json:"conn_reused"`
	// RemoteAddr is the peer the connection terminates at — for a fronted
	// endpoint this is the CDN edge, so a change here means a re-dial landed
	// somewhere else.
	RemoteAddr string `json:"remote_addr,omitempty"`
	// Proto is the negotiated protocol from the response (HTTP/2.0 vs
	// HTTP/1.1). H2 multiplexes every dispatch over one connection; H1 pools
	// per-host and can churn.
	Proto string `json:"proto,omitempty"`
	// RequestID is the provider's x-request-id response header, the handle a
	// provider-side investigation needs to look anything up.
	RequestID string `json:"request_id,omitempty"`
	// Ray is the CDN trace header (cf-ray for Cloudflare-fronted endpoints);
	// its suffix names the edge colo that served the request.
	Ray string `json:"ray,omitempty"`
	// ProcessingMS is the provider's own openai-processing-ms figure, when
	// present. A cache miss re-reads the whole prompt, so this tends to jump
	// with one.
	ProcessingMS int64 `json:"processing_ms,omitempty"`
}

TransportInfo describes how one dispatch physically reached the provider.

type Usage

type Usage struct {
	InputTokens      int     `json:"input_tokens"`
	OutputTokens     int     `json:"output_tokens"`
	CacheReadTokens  int     `json:"cache_read_tokens"`
	CacheWriteTokens int     `json:"cache_write_tokens"`
	CostUSD          float64 `json:"cost_usd"`

	// CacheSavedUSD is what the prompt cache saved on this usage: the
	// counterfactual cost of the same prompt tokens at full input price,
	// minus what was actually billed for them. Output is untouched by
	// caching and plays no part.
	//
	// Stamped per response, at the prices of the model that answered it
	// (ApplyCost), because it cannot be recovered later: a session that
	// switches models mid-way has no single price sheet, and the usage row
	// records no model. Summed by Add, so a session total stays exact
	// across switches.
	//
	// Signed on purpose. Writing a cache costs MORE than not caching
	// (Anthropic bills creation at 1.25x input), so a session that keeps
	// invalidating its prefix and re-writing it genuinely runs negative —
	// which is the single most useful thing this number can say.
	CacheSavedUSD float64 `json:"cache_saved_usd,omitempty"`

	// ReasoningTokens is how much of OutputTokens the model spent thinking.
	//
	// A SUBSET of OutputTokens, not a fourth disjoint bucket. The prompt
	// fields above are disjoint because each is billed at its own rate;
	// reasoning is billed at the output rate and is already inside
	// OutputTokens, so subtracting it here would silently change every bill.
	// ComputeCost does not read this field, and a guard pins that.
	//
	// Informational on purpose: it is the one part of a reasoning model's
	// spend that is otherwise invisible, and on this codebase the question
	// "how much of that output was thinking?" comes up whenever a session
	// costs more than its transcript explains.
	ReasoningTokens int `json:"reasoning_tokens,omitempty"`

	// ReasoningTokensKnown separates "the model reported 0 reasoning tokens"
	// from "this provider does not break reasoning out at all". Without the
	// flag a session total would quietly understate, which is the failure
	// mode a cost breakdown exists to avoid.
	//
	// 🪤 Anthropic is BOTH cases depending on the model, which is why this
	// cannot be decided per-provider. Budget-thinking models fold thinking
	// into output_tokens with no separate count; adaptive-thinking ones
	// report usage.output_tokens_details.thinking_tokens. Measured live on
	// sonnet-5: 258 output tokens, 254 of them thinking.
	ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`

	// ImageOutputTokens is how much of OutputTokens was image data.
	//
	// A SUBSET of OutputTokens, exactly like ReasoningTokens above, and for
	// the same reason: Gemini reports it as a breakdown of the same
	// candidate total (candidatesTokensDetails[].modality), not as an extra
	// bucket beside it. Measured on a 1024x1024 generation:
	// candidatesTokenCount 1450, of which modality IMAGE 1120 — the
	// remaining 330 are the text and thinking that came with the picture.
	//
	// UNLIKE ReasoningTokens, ComputeCost does read this one, because these
	// tokens are billed at their own rate (Model.PriceOutputImage). It is
	// subtracted from the text-rate base rather than added to it, so the two
	// rates partition OutputTokens instead of double-counting it.
	ImageOutputTokens int `json:"image_output_tokens,omitempty"`
}

Usage aggregates token counts and cost for a turn.

InputTokens is the UNCACHED remainder of the prompt, never the whole prompt. Anthropic reports it that way natively; the OpenAI, Codex and Gemini decoders subtract their cached count to match. Every consumer depends on that normalization — the context gauge, the compaction thresholds and CacheHitRate all read the prompt as input+cache_read+cache_write, which double-counts the moment a decoder stops subtracting.

func (Usage) Add

func (u Usage) Add(v Usage) Usage

Add returns u plus v.

func (Usage) CacheHitRate added in v0.130.0

func (u Usage) CacheHitRate() (rate float64, ok bool)

CacheHitRate is the share of the prompt served from cache, in [0,1].

Zero prompt tokens returns (0, false) rather than 0: "no requests yet" and "every request missed" are different states, and a panel that draws an empty bar for both says the cache is failing when nothing has been asked of it. Every caller must decide which it is showing.

func (Usage) PromptTokens added in v0.130.0

func (u Usage) PromptTokens() int

PromptTokens is everything the model read: the uncached remainder plus whatever came from, or went into, the cache. The denominator of every cache ratio, and the same sum the context gauge shows.

type UsageRefresher added in v0.110.0

type UsageRefresher interface {
	RefreshUsage(ctx context.Context) (UsageSnapshot, bool)
}

UsageRefresher is implemented by clients whose usage must be PULLED from a dedicated endpoint (a balance/usage GET) rather than observed passively from response headers. RefreshUsage performs that fetch and returns the latest snapshot; it BLOCKS on network I/O, so callers must run it off any UI goroutine. Implementations cache with a TTL so repeated calls don't hammer the endpoint.

type UsageReporter added in v0.110.0

type UsageReporter interface {
	// UsageSnapshot returns the latest snapshot the client has seen,
	// with ok=false when it has nothing to report (no usage data yet,
	// or this provider doesn't expose any).
	UsageSnapshot() (UsageSnapshot, bool)
}

UsageReporter is implemented by the (few) concrete clients that can report subscription usage — today only openai-codex, whose response headers carry it. A client that cannot report usage simply does not implement this; ClientUsage then returns ok=false and the harness shows nothing for that provider. This is intentionally an optional reporter interface rather than a ClientCapabilities field: usage is dynamic per-turn state to be pulled, not a static wire-format flag.

type UsageReset added in v0.120.0

type UsageReset struct {
	// ID is the provider's opaque credit identifier, passed back to redeem.
	ID string
	// Kind is the provider's own reset-type tag (codex: "codex_rate_limits"),
	// carried verbatim for display/telemetry; terva does not switch on it.
	Kind string
	// Title/Description are the provider's human labels ("Full reset (Weekly +
	// 5 hr)"), shown to the user verbatim.
	Title       string
	Description string
	// Status classifies the credit for display and gating (only ResetAvailable
	// credits may be consumed).
	Status ResetStatus
	// GrantedAt / ExpiresAt bound the credit's life; RedeemedAt is set once
	// spent (zero otherwise).
	GrantedAt  time.Time
	ExpiresAt  time.Time
	RedeemedAt time.Time
}

UsageReset is one consumable reset credit. Fields mirror what a provider exposes; a provider fills what it has and leaves the rest zero. Times are UTC; a zero time means the provider did not report that timestamp.

func ClientListResets added in v0.120.0

func ClientListResets(ctx context.Context, c Client) ([]UsageReset, error)

ClientListResets lists c's reset credits, looking through wrapper layers for a UsageResetProvider. Returns (nil, nil) when the client offers no resets, so a caller can treat "no support" and "supported but empty" the same way.

func (UsageReset) Available added in v0.120.0

func (r UsageReset) Available() bool

Available reports whether the credit can be redeemed right now.

type UsageResetProvider added in v0.120.0

type UsageResetProvider interface {
	// ListResets returns the account's reset credits — available and spent —
	// newest grant first. A nil slice with nil error means "none".
	ListResets(ctx context.Context) ([]UsageReset, error)
	// ConsumeReset redeems the credit named by id. It returns the updated
	// credit and how many windows were cleared. Redeeming a non-available
	// credit is the provider's error to return.
	ConsumeReset(ctx context.Context, id string) (UsageResetResult, error)
}

UsageResetProvider is implemented by the (few) clients that expose consumable usage resets — today only openai-codex. Optional interface, like UsageReporter: a client that offers no resets simply does not implement it, and ClientSupportsResets then reports false so the harness hides the feature.

ConsumeReset is IRREVERSIBLE and spends a scarce, provider-granted credit, so callers must gate it behind explicit user confirmation (never auto-redeem). Implementations make redemption idempotent per credit so a retried or timed-out call cannot double-spend (see the codex client's deterministic request id).

type UsageResetResult added in v0.120.0

type UsageResetResult struct {
	// Reset is the credit in its post-redemption state (Status ResetRedeemed,
	// RedeemedAt set).
	Reset UsageReset
	// WindowsReset is how many usage windows the redemption cleared, when the
	// provider reports it (codex returns this); 0 when unknown.
	WindowsReset int
}

UsageResetResult is the outcome of redeeming one credit.

func ClientConsumeReset added in v0.120.0

func ClientConsumeReset(ctx context.Context, c Client, id string) (UsageResetResult, error)

ClientConsumeReset redeems a credit on c, looking through wrapper layers. Returns ErrResetsUnsupported when the client offers no resets, so a mis-routed consume fails loudly rather than silently no-opping (this call spends a credit — a silent success would be a lie).

type UsageSeeder added in v0.120.0

type UsageSeeder interface {
	SeedUsage(UsageSnapshot)
}

UsageSeeder is implemented by clients whose passively-observed snapshot can be primed from a predecessor client's. A client rebuild (re-login, endpoint change, a cross-then-back provider hop) starts with an empty snapshot that nothing refills until the next turn's response headers arrive — so the meters a user was just looking at vanish. Seeding carries the last observation across the swap; implementations must ignore snapshots that are not theirs (wrong Provider) and never let a seed displace a fresher live observation.

type UsageSnapshot added in v0.110.0

type UsageSnapshot struct {
	Provider   string
	Windows    []UsageWindow
	Credits    *Credits
	CapturedAt time.Time
}

UsageSnapshot is the most recent usage picture a client observed. It is a value type: callers read it and render, they never mutate it.

func ClientRefreshUsage added in v0.110.0

func ClientRefreshUsage(ctx context.Context, c Client) (UsageSnapshot, bool)

ClientRefreshUsage pulls a fresh usage snapshot, looking through wrapper layers for a UsageRefresher. Falls back to ClientUsage (the passively observed snapshot) when no refresher is present, so a header-based reporter (codex) and a poll-based one return through the same call.

func ClientUsage added in v0.110.0

func ClientUsage(c Client) (UsageSnapshot, bool)

ClientUsage returns c's latest usage snapshot, looking through any wrapper layers (renamedClient, pollingUsageClient) via the shared clientAs walk — openai-responses ships wrapped in a renamedClient, so a direct assertion on the outer client would miss it.

type UsageWindow added in v0.110.0

type UsageWindow struct {
	// Label is the provider's name for the window ("5h", "weekly", …),
	// shown to the user verbatim.
	Label string
	// UsedPercent is how much of the window is consumed, 0..100. A
	// negative value means "the provider reports this window but not a
	// usable percentage" (render as unknown, not 0%).
	UsedPercent float64
	// WindowMinutes is the window length in minutes; 0 when unknown.
	WindowMinutes int
	// ResetsAt is when the window rolls over; zero when unknown.
	ResetsAt time.Time
	// Kind classifies the window for display/filtering. Zero value is
	// WindowPlan, so existing producers need no change.
	Kind WindowKind
}

UsageWindow is one provider-defined usage budget the user is spending against: a rolling 5-hour window, a weekly cap, a monthly quota — whatever the provider reports. terva renders the windows a provider hands it; it does not assume a fixed set of hourly/weekly/monthly slots, because different subscriptions expose different ones.

type UserModel

type UserModel struct {
	ID                   string          `json:"id"`
	Name                 string          `json:"name"`
	Reasoning            *bool           `json:"reasoning"`
	ContextWindow        int             `json:"contextWindow"`
	DesiredContextWindow int             `json:"desiredContextWindow"`
	MaxTokens            int             `json:"maxTokens"`
	PriceInput           float64         `json:"priceInput"`
	PriceOutput          float64         `json:"priceOutput"`
	PriceCacheRead       float64         `json:"priceCacheRead"`
	PriceCacheWrite      float64         `json:"priceCacheWrite"`
	PriceOutputImage     float64         `json:"priceOutputImage"` // output rate for IMAGE tokens; 0 = one output rate
	BaseURL              string          `json:"baseUrl,omitempty"`
	Temperature          *float32        `json:"temperature,omitempty"`      // default sampling temperature (0–2); nil = inherit
	DefaultReasoning     string          `json:"defaultReasoning,omitempty"` // per-model reasoning level when no global level is set; "" = inherit
	Capabilities         map[string]bool `json:"capabilities,omitempty"`
	Input                []string        `json:"input"` // legacy capability spelling, see above
	API                  string          `json:"api"`   // informational only
}

UserModel is a single model entry in the user's models.json.

Reasoning is a *bool so "not mentioned" is distinguishable from "explicitly false": a price-only override of a catalog reasoning model must not silently disable reasoning (which made OpenAI reasoning models 400 under the old bool field).

Capabilities carries per-model capability tags ({"image-input": false, ...}); key presence IS the explicit-set marker, so the same tri-state lesson needs no extra side flags. Input is the legacy spelling for the image-input capability ("image" present ⇒ vision); an explicit Capabilities key wins over it.

func FindUserModel added in v0.108.1

func FindUserModel(path, providerKey, id string) (UserModel, bool, error)

FindUserModel returns the raw models.json entry for providerKey/id, reporting whether one exists. The editor needs the RAW entry (not the merged Model) to tell "explicitly overridden" apart from "inheriting the catalog default" on a per-field basis. A missing file yields (zero, false, nil).

providerKey is the canonical provider; an entry filed under a legacy spelling of it is found too, because the loader treats that entry as live.

type UserModelsFile

type UserModelsFile struct {
	Providers map[string]UserProvider `json:"providers"`
}

UserModelsFile is the JSON format for user-defined models. Place a models.json in $TERVA_HOME to add models that aren't in the baked-in catalog or to override catalog entries.

Example:

{
  "providers": {
    "openai": {
      "models": [
        {
          "id": "gpt-5.5",
          "name": "GPT-5.5",
          "reasoning": true,
          "contextWindow": 400000,
          "maxTokens": 128000,
          "temperature": 0.7,
          "priceInput": 2.50,
          "priceOutput": 15.00,
          "priceCacheRead": 0.25,
          "priceOutputImage": 60.00,
          "capabilities": { "image-input": true }
        }
      ]
    }
  }
}

capabilities keys are the provider.Capability names ("image-input", "image-output", "reasoning"); absent keys fall back to per- capability defaults. This is how a non-vision local model is marked text-only so terva drops images instead of bricking the session.

func ReadUserModelsFile added in v0.108.1

func ReadUserModelsFile(path string) (UserModelsFile, error)

ReadUserModelsFile reads and parses a models.json file. A missing or empty file is not an error: it returns a file with a ready-to-use (non-nil) Providers map. A malformed file IS an error, so a caller that's about to rewrite the file never silently clobbers content it couldn't understand.

type UserOverride

type UserOverride struct {
	Model        Model
	ReasoningSet bool
}

UserOverride is one models.json entry held in the user layer: the converted Model plus which tri-state fields the user explicitly set (a flattened Model can't distinguish false from absent).

func LoadUserModelsWithWarnings

func LoadUserModelsWithWarnings(path string) ([]UserOverride, []string)

LoadUserModelsWithWarnings reads a models.json file, returning the models converted to the internal Model type plus human-readable warnings about every recoverable issue it found in the file (unknown provider id, empty model id, malformed JSON for a single provider block, etc.). The caller is responsible for surfacing the warnings; the file is never rejected wholesale unless the top-level JSON itself fails to parse.

type UserProvider

type UserProvider struct {
	Models []UserModel `json:"models"`
}

UserProvider groups models under a provider key.

type WindowKind added in v0.110.0

type WindowKind int

WindowKind classifies a usage window so consumers can treat the kinds differently: the /usage dialog shows every kind, but the compact status-bar hint shows only WindowPlan/WindowCredit — ephemeral rate-limit windows would churn the always-visible bar. See docs/plans/usage-merged-view.md.

const (
	// WindowPlan is a subscription budget window (Codex 5h / weekly). It is the
	// ZERO VALUE, so windows that predate this field keep their meaning.
	WindowPlan WindowKind = iota
	// WindowCredit is a window derived from a credit/spend budget — reserved
	// for future credit-period windows (usage-merged-view.md §8).
	WindowCredit
	// WindowRateLimit is an ephemeral throughput window (RPM/TPM) parsed from
	// x-ratelimit-* response headers.
	WindowRateLimit
)

Directories

Path Synopsis
Package auth handles credential storage and the two login flows supported by terva: API key and (experimental) subscription OAuth.
Package auth handles credential storage and the two login flows supported by terva: API key and (experimental) subscription OAuth.
assets
Package assets holds static resources embedded in the terva binary.
Package assets holds static resources embedded in the terva binary.

Jump to

Keyboard shortcuts

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