Design: AgentRun-level dollar cost estimation¶
Status: approved design, v1 scope. Repo: github.com/sympozium-ai/sympozium.
1. Overview & goals¶
Add an estimated dollar cost to completed AgentRuns, computed from the already-captured status.tokenUsage times a per-model price table.
Goals (mapped to maintainer requirements):
- R1 —
AgentRun.status.costEstimateis written by the controller at run completion, alongsidetokenUsage. - R2 — Local/self-hosted providers (
ollama,lm-studio,llama-server,unsloth,vllm,llamacpp,local) and allmodelRef-backed runs are exempt: no persisted estimate, nothing shown by default. - R3 — Users can define simulated prices in the web UI Settings page. Simulated prices may target exempt local providers (chargeback). Simulated estimates are a read-time overlay computed by the apiserver, returned as a separate
simulatedCostEstimateJSON field, and always visually labeled. - R4 — A default price table ships as a Helm-rendered ConfigMap, editable via
values.yamlorkubectlwithout a binary upgrade. Unknown model/provider with no match → estimate absent, never$0. - R5 — No floats in CRD types: integer micro-USD (
int64) plus an explicitcurrencyfield. Model lookup is longest-prefix match within a provider to survive dated model suffixes. - R6 — All repo conventions honored:
make generate && make manifestsfor CRD/chart sync, controller-side derivation (no mutating webhook), agent output treated as adversarial, no new secret plumbing, no pod-spec changes.
Core architectural invariant: persisted status carries only "real" estimates from the RBAC-protected default table; simulated numbers never touch CR status, metrics, or enforcement. This single decision satisfies R2+R3 labeling, makes simulation retroactive and reversible, and caps the blast radius of the apiserver's opt-in auth.
2. Non-goals (v1)¶
- No enforcement. Cost never gates admission;
checkTokenBudgetstays token-denominated. No dollar budgets (halt/warn on $X) until usage metering moves off the agent-writable log-marker channel. - No spec-level pricing override on AgentRun/Agent (unanimous panel position; see §13).
- No persisted ensemble cost aggregate. Ensemble totals are computed read-side by the apiserver.
- No UI editing of the real (default) price table. Real-price corrections go through Helm values or
kubectl editon the ConfigMap. - No multi-currency.
currencyis present and validated== "USD"; the field is forward-compat only. - No cache/reasoning token pricing tiers. The table prices input/output tokens only, matching what
TokenUsagecaptures today (extending it touches the runner result-marker contract, deliberately frozen). - No recomputation/backfill of historical estimates when the table changes (frozen-at-completion; see unresolved question if backfill becomes a product ask).
- No fix for the retry undercount (
strings.LastIndexkeeps only the last attempt's marker) or the failed-run usage gap — both documented on the field and in the UI tooltip.
3. Provider categories & exemption mechanism¶
New package internal/llmprovider:
package llmprovider
// LocalProviders returns the canonical list of self-hosted provider ids.
func LocalProviders() []string {
return []string{"ollama", "lm-studio", "llama-server", "unsloth", "vllm", "llamacpp", "local"}
}
func IsLocal(provider string) bool { /* switch over the list */ }
cmd/agent-runner/main.goisLocalProvider(~line 69) becomes a one-line delegate;canary.go:195and the retry/timeout call sites are unchanged in behavior. One compiled source of truth for runner + controller + apiserver.- The apiserver includes
localProvidersinGET /api/v1/pricingso the React UI renders exemption state from the server (no hardcoded copy). - No CRD-visible category field — it would be a second writable source of truth requiring controller-side defaulting and webhook policing.
Exemption predicate (lives in internal/pricing, used by the controller):
// Exempt: no persisted cost estimate is ever produced.
func Exempt(m sympoziumv1alpha1.ModelSpec) bool {
return m.ModelRef != "" || llmprovider.IsLocal(m.Provider)
}
Critical detail (verified in code): reconcilePending sets agentRun.Spec.Model.Provider = "openai" for modelRef runs in-memory only (agentrun_controller.go ~342) — the spec in etcd still has provider: "" + modelRef set at completion. The exemption predicate therefore keys on spec.model.modelRef != "" as read from etcd, never on any resolved provider string. Model-CR-backed runs are cluster-local inference and are exempt (a future remote-gateway Model type revisits this).
Opt-in for exempt providers is via simulation only. A simulated price entry naming a local provider (or the reserved provider key modelref for Model-CR runs, matched against the Model name) makes the apiserver emit simulatedCostEstimate for those runs. No spec-level opt-in.
Unknown providers are treated as remote (priceable if the table matches, absent otherwise) — OpenAI-compatible remote gateways use arbitrary provider strings and must be priceable.
4. Pricing data model & storage¶
4.1 Default table (real prices)¶
A plain ConfigMap sympozium-model-pricing in the release namespace, data key pricing.yaml, rendered by new template charts/sympozium/templates/model-pricing.yaml from charts/sympozium/files/pricing/defaults.yaml (.Files.Get) merged with values.pricing.extraEntries. Values keys: pricing.enabled (default true), pricing.extraEntries (same schema, appended, wins ties — documented as the place for persistent local corrections so Helm upgrades don't clobber them).
Schema (version: 1):
version: 1
currency: USD
entries:
- provider: openai # exact, case-insensitive
match: gpt-4o # literal prefix of spec.model.model
inputPerMTokMicro: 2500000 # $2.50 / 1M input tokens, integer micro-USD
outputPerMTokMicro: 10000000
- provider: anthropic
match: claude-sonnet-4-5
...
- provider: bedrock
match: anthropic.claude-3-5-sonnet
...
The controller learns its location from env SYMPOZIUM_PRICING_CONFIGMAP / SYMPOZIUM_PRICING_NAMESPACE on the controller Deployment (same pattern as SYMPOZIUM_IMAGE_TAG).
Why not a CRD: a static lookup table has no reconcile loop; a CRD drags in deepcopy, make manifests, the two-chart CRD sync, and webhook validation for zero benefit. The chart already ships default resources this way. Why not a second apiserver-owned "overrides" ConfigMap (dev's original proposal): it required new RBAC (a resourceName-scoped Role, plus an unscopable create), a merge layer, and — fatally — let a possibly-unauthenticated apiserver write prices that displace the real persisted estimate (qa-security blocking objection, upheld; see §13).
4.2 Simulated prices¶
Persist in the existing SympoziumConfig CR (singleton the apiserver already writes for canary config — verified: apiserver ClusterRole has full sympoziumconfigs CRUD at rbac.yaml:201; canary handlers auto-create the CR). New spec block in api/v1alpha1/sympoziumconfig_types.go:
// PricingSpec configures simulated model pricing (display-only overlay).
type PricingSpec struct {
// SimulatedEnabled toggles the read-time simulated-cost overlay.
// +optional
SimulatedEnabled bool `json:"simulatedEnabled,omitempty"`
// SimulatedPrices are user-defined rates applied at read time only.
// +optional
// +kubebuilder:validation:MaxItems=500
SimulatedPrices []SimulatedPrice `json:"simulatedPrices,omitempty"`
// UpdatedAt records the last modification of this block.
// +optional
UpdatedAt *metav1.Time `json:"updatedAt,omitempty"`
// UpdatedBy records the caller identity when apiserver auth is enabled.
// +optional
UpdatedBy string `json:"updatedBy,omitempty"`
}
type SimulatedPrice struct {
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$`
Provider string `json:"provider"` // may be a local provider, or "modelref"
// Match is a literal prefix of the model name.
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$`
Match string `json:"match"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=1000000000
InputPerMTokMicro int64 `json:"inputPerMTokMicro"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=1000000000
OutputPerMTokMicro int64 `json:"outputPerMTokMicro"`
}
4.3 Precedence — deliberately NOT a merge chain¶
There are exactly two sources and they never mix:
- Persisted
status.costEstimate— computed once by the controller from the default ConfigMap table only.source: "defaultTable". - Read-time
simulatedCostEstimate— computed by the apiserver when serving run JSON, fromSympoziumConfig.spec.pricingonly.source: "simulated". Never written to any CR status, never displaces the real estimate; when both exist the UI shows both.
This structural split makes mislabeling impossible (the source is the storage), makes simulation retroactive (applies to already-completed runs), makes toggling it off leave zero residue in etcd, and lets exempt local providers get simulated chargeback numbers without violating their status-level exemption.
source is an extensible enum so a future userTable / specOverride slots in without schema change.
4.4 Lookup rules (internal/pricing)¶
- Case-insensitive exact match on
provider; no cross-provider fallback. - Within the provider: exact model match first, else the entry whose
matchis the longest literal prefix of the model name (handlesgpt-4ovsgpt-4o-mini, dated suffixes,anthropic.claude-3-5-sonnet-20241022-v2:0). Prefix only — no globs. values.pricing.extraEntrieswin ties against shipped defaults at equal prefix length.- No match →
nil(absent). Exempt →nil. Never$0.
4.5 Cost arithmetic — overflow-proof by construction¶
// costMicro = rate (micro-USD per 1M tokens) applied to tokens, round half-up.
func costMicro(tokens, ratePerMTokMicro int64) int64 {
q, r := tokens/1_000_000, tokens%1_000_000
return ratePerMTokMicro*q + (ratePerMTokMicro*r+500_000)/1_000_000
}
With validation caps (tokens ≤ 1e10 per field after clamping, rate ≤ 1e9), the largest intermediate is rate*q ≤ 1e9 * 1e4 = 1e13 and rate*r < 1e9 * 1e6 = 1e15 — no int64 overflow possible. A naive tokens*rate would overflow at the caps (1e19 > MaxInt64), which is why the split formula is mandatory and unit-tested at exactly tokens=1e10, rate=1e9, asserting the correct value (1e13), not a clamp.
Package layout: internal/pricing/{table.go,lookup.go,estimate.go} + tests. Table parsing tolerates unknown fields; version != 1 → table treated as absent + Warning event (fail open).
5. CRD changes¶
5.1 api/v1alpha1/agentrun_types.go — next to TokenUsage (~line 392)¶
// CostEstimate is an estimated dollar cost derived from tokenUsage and a
// price table. It is an ESTIMATE, not billing data: on retried runs only the
// final attempt's tokens are counted, and failed runs report no usage, so
// values are a floor relative to provider invoices. Absent when the provider
// is local/self-hosted, the run uses modelRef, or no price-table entry
// matches — never zero.
type CostEstimate struct {
// AmountMicro is the total estimated cost in integer micro-USD (1e-6 USD).
AmountMicro int64 `json:"amountMicro"`
// +optional
InputAmountMicro int64 `json:"inputAmountMicro,omitempty"`
// +optional
OutputAmountMicro int64 `json:"outputAmountMicro,omitempty"`
// Currency is always "USD" in v1.
Currency string `json:"currency"`
// Source identifies the price table used. Persisted estimates are always
// "defaultTable"; "simulated" appears only in apiserver JSON, never in status.
// +kubebuilder:validation:Enum=defaultTable;simulated
Source string `json:"source"`
// PriceKey is the matched table entry ("provider/matchPrefix") for audit.
// +optional
PriceKey string `json:"priceKey,omitempty"`
// EstimatedAt records when the estimate was frozen.
// +optional
EstimatedAt *metav1.Time `json:"estimatedAt,omitempty"`
}
AgentRunStatus gains (next to TokenUsage, ~line 323):
5.2 sympoziumconfig_types.go¶
SympoziumConfigSpec gains Pricing *PricingSpec (+optional) as in §4.2.
5.3 No other CRD changes¶
No Ensemble status field, no spec fields on AgentRun/Agent/Schedule. After editing: make generate && make manifests (regenerates deepcopy, config/crd/bases/, and syncs charts/sympozium/crds/ + charts/sympozium-crds/templates/; CI helm-sync-check gates drift).
6. Controller changes¶
6.1 PR0 prerequisite — clamp adversarial token counts (live bug fix)¶
In parseAgentResultFromLogs (~3624), before constructing TokenUsage:
- Reject the marker's usage if any of
InputTokens,OutputTokens,ToolCalls,DurationMsis negative (verified live bug: the current guard isInputTokens > 0 || OutputTokens > 0, so{inputTokens: -5e9, outputTokens: 1}passes, yields negativeTotalTokens, andupdateTokenBudgetadds it toEnsemble.status.tokenBudgetUsed— an adversarial agent can decrement the ensemble ledger and defeat ahaltbudget today). - Cap each token field at
1e10; clamp with a Warning event. - Ignore any
cost/price/currencyfield an agent puts in the marker JSON — cost is computed exclusively server-side.
This ships as its own PR (with an envtest regression proving a negative marker cannot reduce tokenBudgetUsed) because it fixes existing token-budget enforcement, not just pricing. Consider backport.
6.2 Cost computation in succeedRun (~3490)¶
After usage is parsed and inside the existing updateStatusWithRetry closure (one status write, no new phases):
if usage != nil && !pricing.Exempt(ar.Spec.Model) {
table, err := r.Pricing.Load(ctx) // see 6.3; fail open
if err != nil { emit Warning event; skip }
if est := pricing.Estimate(table, ar.Spec.Model.Provider, ar.Spec.Model.Model, usage); est != nil {
ar.Status.CostEstimate = est // Source=defaultTable, frozen
}
}
Rules:
- Fail open: missing/malformed ConfigMap → run still succeeds, estimate absent, Warning event. Estimation must never block or fail a run.
- Frozen at completion: never recomputed when the table changes (audit integrity: table edits can't retroactively rewrite spend history).
PriceKey+EstimatedAtmake estimates auditable against a drifting table. - Idempotent by construction: written in the same guarded status update as
tokenUsage.
6.3 ConfigMap read path — do NOT use the cached client¶
Verified: internal/controller never Gets ConfigMaps through the manager client today (only Creates), and cmd/controller/main.go has no cache.Options.ByObject scoping — the first cached Get would spin up a cluster-wide ConfigMap informer (list+watch of every ConfigMap in the cluster). Instead, internal/pricing reads via mgr.GetAPIReader() (uncached) behind a ~30s in-process TTL cache, with a code comment explaining why plain r.Get is forbidden here. (The ByObject field-selector alternative is acceptable if an implementer prefers it; APIReader+TTL is chosen as the smaller change.)
6.4 No ensemble status aggregation¶
Dev's proposal to accumulate Ensemble.status.costEstimateMicro inside updateTokenBudget is rejected on verified grounds: updateTokenBudget early-returns when pack.Spec.SharedMemory.Membrane.TokenBudget == nil (~3055), so the rollup would silently never accumulate for ensembles without a token budget — wrong by construction. Ensemble totals are read-time (§8). When dollar budgets ship (post trusted metering), a dedicated accumulator with its own costCountedAnnotation gated on a CostBudget spec is the designed seam.
6.5 Metrics¶
A controller-side promauto counter (e.g. sympozium_run_cost_estimate_microusd_total, labels: provider, priceKey) emitting real estimates only. Simulated values never enter metrics: a pricing_source label would not survive aggregation/recording rules/screenshots, and an apiserver-side read-time metric would be read-rate-dependent garbage.
7. Runner changes¶
Effectively none. The result-marker contract is frozen (SYMPOZIUM_IMAGE_TAG skew between control plane and run pods is a real deployment mode, so lockstep upgrades are unacceptable). The only change: isLocalProvider in cmd/agent-runner/main.go becomes a delegate to internal/llmprovider (the runner already imports internal packages, e.g. internal/ipc). The runner never sees or emits dollar figures.
8. apiserver API¶
New file internal/apiserver/pricing_handlers.go, routes registered beside the gateway routes (~server.go:200). Deliberately not under /api/v1/density/cost — that namespace is GPU placement cost (density_cost.go); this feature says "pricing" / "estimated spend" everywhere to avoid the collision.
| Route | Method | Behavior |
|---|---|---|
/api/v1/pricing |
GET | { currency, defaultTable: []PriceEntry (source:"defaultTable"), simulated: { enabled, prices, updatedAt, updatedBy }, localProviders: []string } — merged view for the UI. Read follows existing route auth policy. |
/api/v1/pricing/simulated |
PUT | Whole-document replace of SympoziumConfig.spec.pricing (canary-style write; auto-creates the CR like the canary handler). Stamps updatedAt (+updatedBy when auth on). |
/api/v1/pricing/simulated |
DELETE | Clears spec.pricing.simulatedPrices and disables the overlay. |
Authz carve-out (required): the write handlers check whether the server was started with a non-empty bearer token; if auth is disabled they return 403 with body "pricing edits require apiserver authentication (apiserver.authToken)". Rationale: apiserver auth is opt-in (buildMux warns the API is otherwise open; flagged systemic in the July 2026 review); prices are cluster-wide financial-looking state. This is enforceable and degrades gracefully — estimation from the Helm defaults still works read-only. When auth IS enabled, routes sit behind the existing bearer middleware like everything else.
Server-side validation on PUT (mirrors kubebuilder markers, returns 400 with field-level messages): rates > 0 and ≤ 1_000_000_000 micro-USD/MTok (catches the per-token-vs-per-million 1e6x typo); provider/match match ^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$ (no control chars/whitespace — these strings render in the UI); ≤ 500 entries; currency omitted or USD.
Run JSON: the apiserver serializes AgentRun CRs, so status.costEstimate flows through automatically. Additionally, when the simulated overlay is enabled, run responses (get + list) gain a computed simulatedCostEstimate field (same shape, source:"simulated") — a pure function applied at serialization from spec.model (+modelRef → provider key modelref, model = Model name) and status.tokenUsage. Ensemble detail responses gain read-time totals: sum of children's costEstimate.amountMicro and, separately, simulatedCostEstimate — never summed together, grouped by source and (future-proofing) currency. Per §14.6, the same read-time summation also backs accumulated-spend totals: cluster-wide (for the dashboard) and per-agent (runs filtered by agent label, for agent detail). Known limitation, documented: runs removed by cleanup: delete vanish from all these sums.
TypeScript client (web/src/lib/api.ts): CostEstimate type on AgentRunStatus (~line 187) + simulatedCostEstimate, plus getPricing / putSimulatedPrices / deleteSimulatedPrices and hooks in web/src/hooks/use-api.
9. Web UI¶
9.1 Settings — web/src/pages/settings.tsx¶
New PricingSection card (pattern-match the existing SystemCanarySection):
- Default table: read-only rendering of the shipped/merged real prices with a note that edits go through Helm values (
pricing.extraEntries) orkubectl— "Prices apply cluster-wide." - Simulated prices: enable toggle + editable table — columns: provider (free text with a
datalistof known + local providers +modelref), model prefix, input $/MTok, output $/MTok. UI accepts dollars and converts on save withMath.round(parseFloat(v) * 1e6)— integers on the wire, never dollar floats. Prominent explanation ("Simulated prices produce clearly-labeled hypothetical estimates; they may target local providers for chargeback"), a clear-all button, andupdatedAt/updatedBydisplayed next to the table. - When the server rejects writes with the auth-disabled 403, the form is disabled and shows the same message.
9.2 Run detail — web/src/pages/run-detail.tsx¶
Extend the stats grid (line 88, sm:grid-cols-4 → 5) with an "Est. spend" card:
- Rendered only when
costEstimateorsimulatedCostEstimateexists (absent → nothing; satisfies R2/R4 in the UI for free). - Format
amountMicroas$X.XXXX(4dp under $1, 2dp above). - Simulated values get a tilde prefix and a distinct amber "SIMULATED" badge; tooltip shows
priceKeyand the caveat "estimate covers the final attempt only". - When both real and simulated exist, both are shown, visually distinct.
9.3 Elsewhere¶
- Accumulated spend (maintainer requirement, §14.6):
dashboard.tsxgains an "Est. spend so far" stat tile (cluster-wide read-time total from the apiserver) andagent-detail.tsxa per-agent running total — real and simulated always separate, with thecleanup: deleteundercount noted in the tooltip. runs.tsx: optional compact cost column (nice-to-have, cuttable).ensemble-detail.tsx: read-time totals from the ensemble API — real and simulated shown separately, never merged.spec.model.modelstrings are untrusted display data: rely on React's default escaping, nodangerouslySetInnerHTML.
10. Security & threat model¶
Trust chain. Token usage originates from a __SYMPOZIUM_RESULT__ marker in the agent pod's own stdout; tool subprocesses share that stdout, and strings.LastIndex means a backgrounded process printing a forged marker after the runner exits wins. Every cost figure is therefore an estimate an adversarial agent can distort. The design's job is to ensure spoofing buys nothing beyond a wrong number on a dashboard:
- Cost never feeds enforcement (unanimous).
checkTokenBudget(~2998) is untouched; no admission decision readscostEstimate. Dollar-denominated budgets are vetoed until provider-side/attested usage capture exists. - Bounds clamp at the parse boundary (§6.1) — also fixes the live negative-token budget bypass. Agent-supplied cost fields in the marker are ignored.
- Pricing lookup inputs are
spec.model.provider/model— operator-authored spec fields, not agent-written. Caveat: agents can spawn child AgentRuns; since there is no spec-level price override, a child's spec cannot carry a price. - Real prices are guarded by ordinary K8s RBAC on the
sympozium-model-pricingConfigMap. The UI cannot write them in v1, so the weakly-authed apiserver can at worst alter clearly-labeled simulated numbers. - Simulated prices can never displace or masquerade as real estimates: they live in
SympoziumConfig.spec, are applied read-time into a separate JSON field, are excluded from metrics, and carryupdatedAt/updatedByfor audit. Write endpoints 403 when the apiserver runs tokenless. - RBAC delta: zero. The apiserver already has full
sympoziumconfigsCRUD (verified rbac.yaml:201); the controller reads the pricing ConfigMap under its existing configmaps grant via APIReader. No new Roles, no widened ClusterRoles, no new grants to run pods — an agent cannot touch price tables. - Frozen-at-completion real estimates are tamper-evident relative to later table edits (recomputation-on-read would let any table editor rewrite spend history).
- Multi-tenancy stance: explicitly single-tenant — one cluster-global table, edit rights = possession of the bearer token, simulated prices visible to all viewers (server-side, not localStorage, so every viewer sees the same numbers — required for chargeback consistency). Documented in the Settings panel and chart README.
Residual risks (accepted, documented): estimates are systematically low on retried/failed runs; simulated numbers in screenshots can still be socially misread despite badges; the systemic opt-in-auth issue remains for all other mutating routes (out of scope, tracked from the July review).
11. Test plan¶
Unit (go test -race, internal/pricing, internal/llmprovider, controller):
- Lookup: exact match, longest-prefix (incl. gpt-4o vs gpt-4o-mini, bedrock IDs, tie via extraEntries), unknown model → nil, unknown provider → remote-treated, exempt (local provider; modelRef set with empty provider) → nil. Case-insensitivity.
- Arithmetic: split-formula correctness, round-half-up, and the boundary case tokens=1e10, rate=1e9 asserting the exact value (no clamp, no overflow).
- Table parsing: malformed YAML, wrong version, unknown fields tolerated.
- Adversarial-marker suite for parseAgentResultFromLogs: negative/huge/mixed-sign tokens, multiple markers (forged second marker), marker text echoed inside agent response content, malformed JSON, agent-supplied cost field ignored.
envtest (make test-system, extend agentrun tests):
- Remote provider + populated defaults CM → costEstimate written with correct micro-USD amount, source=defaultTable, priceKey; written exactly once across repeated reconciles.
- llama-server provider → absent. modelRef run + populated defaults table → absent (regression for the in-memory-provider trap).
- Unpriced model → absent, never 0. Deleted/garbled pricing CM → run succeeds, cost absent, Warning event.
- PR0 regression: negative-token marker cannot reduce Ensemble.status.tokenBudgetUsed.
apiserver httptest (pattern: server_providers_test.go):
- GET works per existing auth policy; PUT/DELETE 401 wrong token, 403 when auth disabled, 400 per validation rule (negative/oversized rate, bad key pattern, >500 entries, non-USD, oversized body); update-conflict retry; simulatedCostEstimate computed for local-provider and modelref runs when overlay enabled, absent when disabled; real estimate never displaced.
Cypress (web/cypress/e2e):
- pricing-settings.cy.ts: CRUD round-trip; exact dollar→micro conversion (2.50 → 2500000); client+server rejection of bad input; auth-disabled banner/disabled form.
- run-cost-display.cy.ts: real cost shown; SIMULATED badge on a local-provider run under simulation; no cost rendered for exempt/unknown/legacy runs.
CI/chart: helm lint, make manifests + make helm-sync-check, go vet, gofmt.
12. Rollout & PR breakdown¶
All intermediate states shippable; version skew safe by construction (absence is the default everywhere: old controllers never set the field, new controller with missing CM produces no estimate, old UIs ignore unknown JSON keys). Rollback: downgrade leaves stale-but-harmless optional status fields and an orphaned ConfigMap.
- PR0 (0.5–1d) — token clamp in
parseAgentResultFromLogs+ adversarial-marker tests + envtest budget regression. Fixes a live vulnerability; independent of pricing; consider backport. - PR1 (1.5d) —
internal/llmprovider(moveisLocalProvider, refactor runner call sites) +internal/pricing(schema, lookup, split-formula estimate,Exempt) with table-driven unit tests. - PR2 (2d) —
api/v1alpha1:CostEstimate+AgentRunStatus.costEstimate+SympoziumConfigSpec.pricing→make generate && make manifests(single CRD-ceremony PR); controller wiring insucceedRunincl. APIReader+TTL read path and fail-open; envtest coverage. - PR3 (1.5d) — chart:
files/pricing/defaults.yamlgenerated from LiteLLM's price dataset via a newhack/update-pricinggenerator (committed output, release-checklist refresh — §14.1),templates/model-pricing.yaml,values.pricingblock, controller env vars;helm lint. No RBAC changes. - PR4 (1.5d) — apiserver
pricing_handlers.go+ routes + validation + auth carve-out + read-time overlay/ensemble totals + httptest suite. - PR5 (3d) — web: api.ts types/functions, hooks,
PricingSection, run-detail card, ensemble totals, accumulated-spend tiles on dashboard + agent detail (§14.6), Cypress specs.
Merge order: PR0 → PR1 → PR2 → PR3 strictly; PR4 and PR5 parallel after PR3. Total ~9–10 engineer-days.
13. Explicitly rejected alternatives¶
- Persisting simulated estimates into AgentRun.status via an apiserver-owned overrides ConfigMap that BEATS the defaults (dev). Rejected — qa-security's blocking objection upheld: with opt-in apiserver auth, this creates an unauthenticated network write path that displaces the real persisted estimate for all subsequent runs and bakes stale simulated dollars into etcd with no scrub path; any
kubectl/script consumer missing thesourcecheck reads attacker-chosen numbers as spend. It also breaks R3 in practice (no retroactive application; qa-security's own freeze-at-completion argument only applies to real estimates — a labeled simulated number tracking the current simulated table is the expected semantic). Dev conceded in cross-review; systems' read-time overlay adopted. - Tri-source enum with a UI-writable
userTableof real prices (qa-security). Rejected per systems' objection: on an opt-in-auth apiserver it reintroduces mint-real-prices risk that endpoint 403s only partially mitigate, whereas the two-source model makes mislabeling structurally impossible (storage determines label). Real-price edits go through values/kubectl; UI editing of real prices deferred to v2 behind real authn. qa-security's provenance-propagation requirements retained on the two-source enum. - Second
sympozium-pricing-overridesConfigMap + new resourceName-scoped Role (dev/qa-security variants). Rejected:SympoziumConfig.spec.pricingneeds zero RBAC changes and reuses the tested canary write path; the CM route needed a new Role + RoleBinding + unscopablecreate, and dev's original placement of the grant on the apiserver ClusterRole would have authorized writing any same-named ConfigMap in every namespace (qa-security objection, verified against rbac.yaml). Ensemble.status.costEstimateMicroaccumulated inupdateTokenBudget(dev). Rejected on verified code grounds: the early-return whenTokenBudget == nil(~3055) means the rollup silently never accumulates for budget-less ensembles — wrong by construction — and it couples display-only cost to enforcement machinery. Read-time apiserver sums in v1; a properly-gated accumulator is the seam for future cost budgets.- A Pricing CRD (considered by all, proposed by none): deepcopy/manifests/two-chart-sync/webhook ceremony for drifting reference data with no reconcile loop.
resource.Quantityfor money (allowed by R5, rejected unanimously): suffixed-string serialization leaks into the TS client, DecimalSI surprises, semantically for compute resources. Integer micro-USD chosen.- Spec-level pricing override on AgentRun/Agent (unanimous rejection): agents can spawn child AgentRuns, so a spec-carried price is a self-declared rate an adversarial agent could zero; no mutating webhook exists for defaulting; the simulated table covers the chargeback use case.
sourceenum leaves the seam. - Re-resolving
modelRefat completion to categorize by resolved provider (dev). Rejected on verified code grounds: resolution setsprovider="openai"in-memory only, so following it literally routes cluster-local Model runs through the OpenAI defaults table (false dollars on self-hosted inference, violating R2). Exemption pins onmodelRef != ""in etcd. - Reading the pricing ConfigMap via the controller's cached client (systems, mechanism detail). Rejected per dev's verified objection: no ConfigMap informer exists today and no ByObject scoping is configured, so the first cached Get creates a cluster-wide ConfigMap list+watch. APIReader + 30s TTL cache instead.
- Simulated costs in OTel metrics with a
pricing_sourcelabel (qa-security). Rejected per dev: labels don't survive aggregation/recording rules, and an apiserver-side read-time metric is read-rate-dependent garbage. Metrics carry real estimates only, emitted controller-side. - Runner-computed cost / extending the result marker: hands a dollar channel to an adversarial pod and forces lockstep image upgrades across the SYMPOZIUM_IMAGE_TAG skew boundary.
- Reusing
/api/v1/density/cost: that namespace is GPU placement cost; collision avoided by the/api/v1/pricingnamespace and "estimated spend" copy.
14. Maintainer decisions (2026-07-13)¶
The panel's open questions were resolved by the maintainer as follows:
- Defaults-table seed: generate from a public dataset. LiteLLM maintains the de-facto community price list (
model_prices_and_context_window.jsonin github.com/BerriAI/litellm) with per-token input/output costs for OpenAI/Anthropic/Bedrock and most gateways. PR3 gains a small generator (hack/update-pricing) that converts it intocharts/sympozium/files/pricing/defaults.yaml(micro-USD per MTok). The output is committed — generated at release time, never fetched at runtime (supply chain, determinism, air-gapped clusters). Refreshing becomes a release-checklist step: run the generator, review the diff, commit. - Bedrock: region-insensitive pricing accepted for v1, keyed on model ID. A note ships in the chart README and the docs pricing page stating that estimates ignore region multipliers.
- Dollar budgets: not now. v1 stays estimation/display-only as designed; the trusted-metering prerequisite remains attached to whenever budgets are picked up.
- No backfill. Frozen-at-completion is the permanent semantic; no recompute command.
- Input/output split: confirmed in.
inputAmountMicro/outputAmountMicrostay onCostEstimate(§5.1), the table keeps separate input/output rates, and the UI cost card tooltip shows the split. Cache/reasoning-token tiers stay out of v1 (runner contract stays frozen). - Accumulated spend in the UX (new requirement). The UI must show estimated spend so far, not just per-run numbers: a cluster-wide running total on the dashboard, a per-agent total on agent detail, plus the ensemble totals already designed. Implemented read-time in the apiserver — sums of
costEstimate.amountMicroover stored AgentRuns, real and simulated always separate. Caveat inherited from §8: runs removed bycleanup: deleteleave the sum; if that undercount proves unacceptable, the follow-up is a persisted per-agent accumulator using the same annotation-guard pattern asupdateTokenBudget(the seam named in §13.4).
Still open, deferred and non-blocking: per-user apiserver authn/authz (blocks UI editing of real prices, per-tenant visibility, and price-change audit trails).
15. Post-v1 maintainer UX revisions (2026-07-13, after live testing)¶
- Single effective spend value. The dual real+simulated presentation (amber SIMULATED badge,
~prefix, two values side by side) was judged unhelpful in practice. Every view now renders ONE number per run:costEstimatewhen present, elsesimulatedCostEstimate. Source distinction lives in tooltips only. Rationale: on all-local clusters, simulated rates are the only source of numbers, so the loud labeling dominated every screen. The storage/API invariant is unchanged — simulated values are still read-time-only, never persisted, never in metrics; only the presentation merged. Totals sum effective values and note "includes simulated rates" in their tooltip when applicable. - Totals shipped earlier than planned: runs page gets a per-row spend column + total across listed runs; the conversation view (agent-detail) gets a per-conversation (session) total. Computed client-side from the runs list response.
- Known trap found in testing: UI-created runs get
provider: custom(apiservercreateRuninfers provider by baseURL substring and misses e.g.http://172.18.0.1:8080/v1), while YAML-created runs sayllama-server— so simulated entries may need both provider keys. Follow-up:createRunshould prefer the Agent's declared provider over URL sniffing. - UX nit found in testing: saving simulated price rows without flipping the enable toggle silently does nothing; consider auto-enabling on first save or making the toggle state more prominent.