Files
llmgw/internal/accounting/cost.go
T
Ilker ManapandClaude Opus 5.5 c87e413360 A ComfyUI model may make meshes: from a picture, with its licence inside
G3, the part that needs no model: a ComfyUI model's comfyui.output (or
external.output) says pictures or meshes. A request for a 3D model — a
job at /v1/3d/generations — carries the picture as base64; at the replica
it is uploaded to ComfyUI's input directory (the gate lets /upload/image
through), the workflow is filled in with its name, the seed and the
resolution, and the mesh the workflow saves comes back with its type,
which is the file the job keeps. comfy.Client reads every list of files an
output names, not "images" only, and types a file by its extension where
ComfyUI sends octet-stream.

A model's license (name, url, notice) is said in /v1/models and in the
job, and written into a GLB's asset.extras, since the file travels without
the API; a mesh it cannot be written into is refused. per_mesh in
auth.pricing, usage.meshes in the rows, the sums and the export
(migration 0045). A picture asked of a mesh model, or a mesh of a picture
model, is refused saying which it makes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:30:52 +02:00

108 lines
3.4 KiB
Go

// Package accounting turns finished requests into spend, enforces what a key
// is allowed, and gets both into the store without touching the request path
// (plan §7.9.3, §7.9.4).
package accounting
import (
"strings"
"sync"
)
// Price is what one model costs, in micro-units per thousand tokens.
//
// Micro-units rather than a float because spend is accumulated by repeated
// addition, and repeated addition of floats drifts. What a unit means is the
// operator's: self-hosted models have no per-token price, so it may be
// GPU-seconds, a notional rate, or 1000 everywhere — which turns a budget into
// a token quota.
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
// PerImage is what one picture costs, for a model that makes them: a
// picture's cost is the card's time, which its prompt's few tokens do
// not measure.
PerImage int64 `koanf:"per_image"`
// PerMesh is what one 3D model costs: minutes of a card, which no
// token count measures.
PerMesh int64 `koanf:"per_mesh"`
}
// Zero reports whether this model spends nothing.
//
// It matters beyond arithmetic: a zero-cost model stays available to a key
// whose budget is spent, which is what makes the plan's budget fallback work
// (§7.7).
func (p Price) Zero() bool {
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
}
// Cost computes what a request spent, rounding up.
//
// Up rather than to nearest: rounding down would make a stream of tiny requests
// free, which is exactly the shape of traffic a quota exists to bound.
func (p Price) Cost(promptTokens, completionTokens, images, meshes int64) int64 {
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000) +
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
}
func divUp(n, d int64) int64 {
if n <= 0 {
return 0
}
return (n + d - 1) / d
}
// Pricing resolves a model name to its price.
type Pricing struct {
mu sync.RWMutex
byModel map[string]Price
fallback Price
}
// NewPricing builds a price table. Names are matched case-insensitively,
// because a model name reaches us from whatever the client typed.
func NewPricing(byModel map[string]Price, fallback Price) *Pricing {
table := make(map[string]Price, len(byModel))
for name, p := range byModel {
table[strings.ToLower(name)] = p
}
return &Pricing{byModel: table, fallback: fallback}
}
// For returns a model's price, or the fallback.
func (p *Pricing) For(model string) Price {
p.mu.RLock()
defer p.mu.RUnlock()
if v, ok := p.byModel[strings.ToLower(model)]; ok {
return v
}
return p.fallback
}
// AnythingPriced reports whether any model costs anything.
//
// A budget is spend per period, and spend is computed from a price table. With
// no prices every request costs nothing, so a budget written on a team is a
// number that can never move — displayed as a limit, reported as zero percent
// used, and binding on nothing.
//
// Self-hosted models have no per-token price, so an empty table is the ordinary
// case rather than a misconfiguration. What is not ordinary is showing somebody
// a budget without saying so.
func (p *Pricing) AnythingPriced() bool {
if p == nil {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
if !p.fallback.Zero() {
return true
}
for _, price := range p.byModel {
if !price.Zero() {
return true
}
}
return false
}