pricing

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package pricing resolves a model's per-million-token rates across a layered set of sources so usage costs stay accurate as new models ship and operators add overrides.

Lookup chain (first exact-match wins; longest-prefix only at the end):

  1. cfg.Model.Pricing[name] — operator override in .agents/config.json, keyed by model name (case-insensitive). Survives /model switches.
  2. .agents/pricing.json — project-local additions (team-internal model variants, project-specific routing).
  3. ~/.mast/pricing.json — user-global file. Two sections: `manual` (operator-curated, hand-edited or set via the operator API's /pricing set) and `external` (auto-fetched from LiteLLM by Refresh).
  4. builtin — the compiled-in fallback table; the zero-config baseline. Generated from LiteLLM's catalog by dev/regen-builtin-pricing into ./builtin.go; regenerated weekly by .github/workflows/pricing-regen.yml.
  5. longest-prefix match across the merge of (1)..(4) — handles `gemini-3.1-pro-preview-customtools`-style suffixes.
  6. (Rates{}, false) — rate unknown; callers (e.g. cost displays) should render "$—" rather than "$0".

The catalog is built once at startup from these sources (see NewCatalog) and consulted on every per-turn cost append; lookups are read-only and lock-free.

What the mast binary wires today is layer 4 only: internal/compose builds a catalog from empty Options, so the compiled-in table is the whole answer and the builtin regen is what keeps rates current. Layers 1-3 and Refresh are the embedder's to wire — an unattended daemon fetching from the public internet at startup is a deployment decision, not a default.

Index

Constants

View Source
const (
	ProjectFileName = "pricing.json"
	UserFileName    = "pricing.json"
)

File names. Exposed so callers can reference them in error messages and tests.

View Source
const (
	SourceCfgOverride  = "cfg-override"
	SourceProjectFile  = "project-file"
	SourceUserManual   = "user-manual"
	SourceUserExternal = "user-external"
	SourceBuiltin      = "builtin"
)

Layer source names surfaced via LookupWithSource + the attach /pricing endpoint. Stable strings — operators grep for them, docs reference them. Don't rename without a deprecation cycle.

View Source
const DefaultRefreshInterval = 24 * time.Hour

DefaultRefreshInterval is the daily cadence Refresh enforces: if the cache's FetchedAt is younger than this, Refresh is a no-op. 24h is long enough that the network cost is amortized but short enough that rate changes propagate within an operator's next session.

View Source
const DefaultRefreshSource = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"

DefaultRefreshSource is the canonical LiteLLM pricing JSON URL. Community-maintained, MIT-licensed, structured, covers hundreds of models from every major provider. Overridable via RefreshOptions for tests + air-gapped mirrors.

View Source
const SchemaVersion = 1

SchemaVersion is the on-disk schema version for pricing files. Bumped only on incompatible schema changes; new fields with `omitempty` don't require a bump.

Variables

This section is empty.

Functions

func Builtin

func Builtin() map[string]Rates

Builtin returns a defensive copy of the compiled-in table. Used by tests + by tools that want to inspect what shipped (e.g. a future `/pricing list builtin` view).

func BuiltinContextWindow added in v0.4.0

func BuiltinContextWindow(modelID string) (int, bool)

BuiltinContextWindow returns the compiled-in max input window for modelID and whether one is known. Keys are lowercase, matching the rest of this package's case-insensitive lookup contract; callers holding an operator-typed id should lower it first.

Separate from Rates because a context window is a capability, not a price: operator pricing overrides (.agents/pricing.json, `/pricing set`) must not be able to move a model's window, and a model can be repriced without its window changing.

func BuiltinContextWindows added in v0.4.0

func BuiltinContextWindows() map[string]int

BuiltinContextWindows returns a defensive copy of the whole window table, for tests and cross-table invariant checks.

func SaveUserFile

func SaveUserFile(userHome string, uf *UserFile) error

SaveUserFile writes uf to <userHome>/<UserFileName> atomically. Used by the LiteLLM refresher and the /pricing set slash. Empty userHome is an error (caller should resolve a real home first).

Types

type Catalog

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

Catalog is the merged view of all pricing sources, queried by model name. Construct with NewCatalog; consult with Lookup.

Layers are stored separately so the daily LiteLLM refresh can rewrite the external slice without touching the others, and so the precedence chain stays explicit (no "where did this rate come from" mystery).

func NewCatalog

func NewCatalog(opts Options) (*Catalog, error)

NewCatalog reads every configured source and returns the merged catalog. Missing files are not errors (the common case); only I/O failures and malformed JSON return non-nil.

The returned catalog is read-only after construction. Callers that want a refresh (the daily LiteLLM fetch, or after /reload) build a new Catalog and swap atomically via the consumer's chosen pointer-store mechanism.

func (*Catalog) Counts

func (c *Catalog) Counts() CountByLayer

Counts returns per-layer entry counts.

func (*Catalog) Lookup

func (c *Catalog) Lookup(modelID string) (Rates, bool)

Lookup returns the resolved rates for modelID plus a found flag. !found means the caller should treat the cost as unknown ($—) rather than zero.

Resolution: exact match scan across layers in precedence order, then a longest-prefix scan across the union of all layers.

func (*Catalog) LookupWithSource

func (c *Catalog) LookupWithSource(modelID string) (Rates, string, bool)

LookupWithSource is Lookup + the name of the catalog layer that served the rate (SourceCfgOverride / SourceProjectFile / SourceUserManual / SourceUserExternal / SourceBuiltin). Empty source string when !ok. Used by /pricing so operators can spot stale builtin rates that should have been overridden by a fresh LiteLLM refresh but weren't — the visibility that #259 asked for.

Resolution matches Lookup: exact match by precedence first, then longest-prefix across the union. The prefix-fallback path returns the source of the LAYER that held the winning prefix entry.

type CountByLayer

type CountByLayer struct {
	CfgOverride  int
	ProjectFile  int
	UserManual   int
	UserExternal int
	Builtin      int
}

CountByLayer reports how many model entries each layer holds. Surfaced via /pricing list and useful for tests that want to assert the expected number of rows landed in each layer.

type ExternalSource

type ExternalSource struct {
	FetchedAt time.Time             `json:"fetched_at"`
	Source    string                `json:"source"` // canonical URL the data was pulled from
	ETag      string                `json:"etag,omitempty"`
	Models    map[string]ModelRates `json:"models,omitempty"`
}

ExternalSource is the auto-fetched section, populated by Refresh from LiteLLM's catalog. The fetched_at + etag fields drive cache-validity logic (skip refresh if <24h, send If-None-Match on revalidation).

type ManualSection

type ManualSection struct {
	Models map[string]ModelRates `json:"models,omitempty"`
}

ManualSection is the operator-curated section. Round-trips intact across refreshes (the fetcher only rewrites External).

type ModelRates

type ModelRates struct {
	InputPerMTok              float64   `json:"input_per_mtok,omitempty"`
	CachedInputPerMTok        float64   `json:"cached_input_per_mtok,omitempty"`
	CacheCreationInputPerMTok float64   `json:"cache_creation_input_per_mtok,omitempty"`
	OutputPerMTok             float64   `json:"output_per_mtok,omitempty"`
	UpdatedAt                 time.Time `json:"updated_at,omitempty"`
}

ModelRates is the on-disk per-model rate, mirroring config's PricingConfig field names so operator-edited files use the same spelling regardless of whether they live in pricing.json or in .agents/config.json's `model.pricing` override map.

func (ModelRates) Rates added in v0.4.0

func (m ModelRates) Rates() Rates

Rates converts the JSON-tagged form into the Rates type. Field order + names are identical so a direct conversion suffices. Exported because embedders that build a UserFile/ProjectFile in memory (rather than on disk) need a way to feed those rows into Options without re-declaring the struct.

type Options

type Options struct {
	// CfgOverride is the operator's per-model override from
	// .agents/config.json's `model.pricing` map. Highest precedence.
	CfgOverride map[string]ModelRates

	// AgentsDir is the resolved .agents/ directory (empty when no
	// project root was found). Catalog construction reads
	// <agentsDir>/pricing.json if present.
	AgentsDir string

	// UserHome is the per-user mast state directory (usually
	// ~/.mast). Catalog construction reads <UserHome>/pricing.json
	// if present (both manual + external sections).
	UserHome string
}

Options bundle the inputs NewCatalog needs to assemble the layered view. All fields are optional — an empty Options yields a catalog with only the compiled-in builtin layer (useful for tests + as the default before main.go wires the files in).

type ProjectFile

type ProjectFile struct {
	Version int                   `json:"version"`
	Models  map[string]ModelRates `json:"models,omitempty"`
}

ProjectFile is the .agents/pricing.json shape — flat models map. Project files are always operator-curated (never auto-fetched), so no manual/external split is needed.

func LoadProjectFile

func LoadProjectFile(agentsDir string) (*ProjectFile, error)

LoadProjectFile reads .agents/pricing.json from agentsDir, or returns an empty file when the file is missing (a missing project file is the common case, not an error). Returns an error for I/O failures or malformed JSON.

type Rates

type Rates struct {
	InputPerMTok              float64
	CachedInputPerMTok        float64
	CacheCreationInputPerMTok float64
	OutputPerMTok             float64
	UpdatedAt                 time.Time
}

Rates is the per-million-token cost for one model. CachedInputPerMTok is the rate applied to input tokens served from the provider's prompt cache (Gemini's `cachedContentTokenCount`, Anthropic's `cache_read_input_tokens`); a zero value means the cache-read rate isn't known and callers should bill cached tokens at InputPerMTok.

CacheCreationInputPerMTok is the rate for input tokens that WRITE a cache entry — Anthropic's `cache_creation_input_tokens`, billed at a premium over base input rather than a discount. It is a single scalar and therefore holds exactly ONE write rate: the 5-minute-TTL one (1.25x base input), which is also the only one LiteLLM publishes (cache_creation_input_token_cost). Anthropic's 1-hour TTL costs 2x base input, so a caller that starts requesting `ttl: "1h"` at the cache_control site would be undercharged by 37.5% against this field; adding 1h support means adding a second rate here, not reusing this one. Gemini has no equivalent bucket: its explicit caches bill storage per hour, not per written token, so the field stays zero for Gemini rows. A zero value means the cache-write rate isn't known and callers should bill written tokens at InputPerMTok — which UNDERCOUNTS, so keep the builtin table populated (dev/regen-builtin-pricing pulls the rate from LiteLLM's cache_creation_input_token_cost). See go-steer/core-agent#263.

UpdatedAt records when the rate was last verified against its source (LiteLLM refresh time, generator run time for builtin entries, operator edit time for manual overrides). Zero when unknown. Surfaced through /pricing so operators can spot stale entries at a glance — issue #259 called out that hand-authored rates drift silently, and staleness visibility is the mitigation baked into the "regenerate builtin from LiteLLM" workflow that followed.

func (Rates) CostUSD

func (r Rates) CostUSD(inputTokens, outputTokens int) float64

CostUSD returns the dollar cost of (input, output) tokens at r. Treats every input token as uncached — see CostUSDWithCache for the cached-vs-uncached split.

func (Rates) CostUSDWithCache

func (r Rates) CostUSDWithCache(uncachedInputTokens, cachedInputTokens, outputTokens int) float64

CostUSDWithCache returns the dollar cost with cache-hit tokens billed at CachedInputPerMTok. When CachedInputPerMTok is zero (rate unknown) cached tokens fall back to InputPerMTok — no silent free-riding.

Providers that also report cache-WRITE tokens should call CostUSDWithCacheWrites instead; this signature folds them into the uncached bucket, which undercounts (go-steer/core-agent#263).

func (Rates) CostUSDWithCacheWrites added in v0.4.0

func (r Rates) CostUSDWithCacheWrites(uncachedInputTokens, cacheReadTokens, cacheWriteTokens, outputTokens int) float64

CostUSDWithCacheWrites is CostUSDWithCache plus the cache-write bucket: tokens that created a cache entry this turn, billed at CacheCreationInputPerMTok.

The three input buckets are mutually exclusive and must not overlap — pass uncached = total prompt - cache reads - cache writes. Unknown rates fall back to InputPerMTok for both cache buckets rather than to zero, so a missing catalog entry degrades to the old (understated) number instead of billing cached or written tokens as free.

func (Rates) IsZero

func (r Rates) IsZero() bool

IsZero reports whether the rates carry no useful pricing. Used by callers to distinguish "free model" from "rate unknown" — only the latter should render "$—". CachedInputPerMTok isn't part of this check: a row that carries only a cache rate but no base input/output rates is still "unpriced" in the useful sense.

type RefreshOptions

type RefreshOptions struct {
	// Source is the URL to fetch. Defaults to DefaultRefreshSource.
	Source string

	// Client is the HTTP client used. Defaults to a fresh one with
	// a 30s timeout — long enough for slow links, short enough that
	// startup doesn't hang forever.
	Client *http.Client

	// MinInterval is the minimum age the cache must reach before
	// Refresh actually fetches. Zero defaults to
	// DefaultRefreshInterval (24h). Set to a negative duration to
	// force a fetch regardless of cache age (used by the
	// /pricing refresh slash command).
	MinInterval time.Duration

	// Now is the clock Refresh uses to compute cache age. Tests
	// override to deterministically drive stale-vs-fresh. Defaults
	// to time.Now.
	Now func() time.Time
}

RefreshOptions controls the refresh fetch. All fields optional; defaults match a production launch (LiteLLM upstream, 24h cadence, no max-size cap, 30s timeout). Tests + air-gapped mirrors override Source; integration tests override Now for deterministic stale-vs-fresh decisions.

type RefreshOutcome

type RefreshOutcome struct {
	Skipped       bool          // true when the cache was fresh enough to skip the fetch
	NotModified   bool          // true when the server replied 304 (cache still authoritative)
	NetworkFailed bool          // true when the fetch itself failed; cache was preserved
	NetworkError  error         // populated when NetworkFailed
	StaleAge      time.Duration // age of the cache at decision time; zero for fresh writes
	ModelCount    int           // number of models written to the external section (zero on Skipped/NotModified)
	FetchedAt     time.Time     // timestamp stored in the cache file (or pre-existing one for Skipped)
}

RefreshOutcome is the result of a Refresh call. Surfaced so the caller (daemon startup + a future /pricing refresh surface) can render a meaningful one-liner: "Refreshed 247 models from LiteLLM" / "Cache is 4h old; skipped" / "Using 6-day-old cache; network unreachable: connection refused".

func Refresh

func Refresh(ctx context.Context, userHome string, opts RefreshOptions) (RefreshOutcome, error)

type UserFile

type UserFile struct {
	Version  int             `json:"version"`
	External *ExternalSource `json:"external,omitempty"`
	Manual   *ManualSection  `json:"manual,omitempty"`
}

UserFile is the ~/.mast/pricing.json shape — sectioned so the LiteLLM refresh can overwrite the `external` section without touching `manual` entries the operator hand-edited (or set via /pricing set).

func LoadUserFile

func LoadUserFile(userHome string) (*UserFile, error)

LoadUserFile reads ~/.mast/pricing.json (or whatever userHome resolves to), or returns an empty file when missing. Same not-an-error treatment for missing files as LoadProjectFile.

Jump to

Keyboard shortcuts

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