8.5 KiB
Billing Rules
Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and usage-quantity code in the Go backend. AGENTS.md defines when a task touches billing (see Billing rules (mandatory read gate)); any task that matches MUST read this whole file before planning, coding, or reviewing. Tasks that match none of those triggers may skip it. Rules use the same MUST / Do NOT wording as AGENTS.md and carry the same weight.
Billing expression system: When working on tiered/dynamic billing (expression-based pricing), MUST read pkg/billingexpr/expr.md first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document.
Built-in model pricing: New built-in model prices MUST be defined as self-contained billing expressions in setting/billing_setting/builtin_billing.go, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories.
Billing safety invariants: Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth:
- Every user-controlled quantity that becomes a billing multiplier (image
n, videoseconds/duration, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds:dto.MaxImageNfor image generation count,relaycommon.MaxTaskDurationSecondsfor task video duration,maxTokensLimit(relay/helper/valid_request.go) formax_tokens-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one. - Watch for validation bypass paths: passthrough fields (e.g.
Extra["parameters"]), taskmetadatamaps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. For task plugins the host does this: it rejects out-of-range duration and count members at any depth of the decoded request. The one exception is a duration of -1 (the vendor picks the length) for plugins that declareduration-auto@1; they must reserve the model's longest duration and settle on the usage the vendor reports. - Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling
FinalUnitDeduction) can claim absurd values. Convert them with saturation before they become token counts. - Never convert a computed quota or token count to
intwith a bare cast likeint(float64(quota) * ratio),int(math.Round(...))on unbounded input, orint(decimal.IntPart()). All quota rounding/conversion is centralized incommon/quota_math.go; use those helpers:common.QuotaFromFloat(truncating) for float products,common.QuotaRound(half-away-from-zero) where rounding is intended, andcommon.QuotaFromDecimalfor decimal products.billingexpr.QuotaRounddelegates tocommon.QuotaRound. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion usescommon.WalletQuotaFromDecimalStrictwith the JavaScript-safecommon.MaxWalletQuotaboundary. Every clamp/NaN fallback is logged viacommon.SysError. - Saturation events are also audited: each helper has a
*Checkedvariant (common.QuotaFromFloatChecked/QuotaRoundChecked/QuotaFromDecimalChecked) that additionally returns a*common.QuotaClampwhen clamping occurred. Billing paths that compute a charge capture that clamp ontorelayInfo.QuotaClamp(or thread it into task settlement) and, right before writing the consume/task log, callattachQuotaSaturation(inservice/log_info_generate.go) which nests the marker under the log'sother.admin_info.quota_saturationand emits a request-correlatedlogger.LogWarn. Nesting underadmin_infomakes it admin-only for free (non-admin log views stripadmin_info). When adding a new billing path, use the*Checkedvariant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs. - Multiplier maps go through
types.PriceData.AddOtherRatio, which rejects non-positive, NaN, and +Inf ratios. Do not write toPriceData.OtherRatiosdirectly, and do not weaken these guards. - Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants.
- Fields parsed into unsigned types (
*uint) accept huge positive JSON numbers (e.g.18446744073686646784, a wrapped negative); a>= 0check is not sufficient, an upper bound is mandatory. - Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See
relay/helper/openai_image_request_test.go,relay/common/relay_utils_test.go, andcommon/quota_math_test.gofor the expected style.
Upstream image quantity settlement: The billable image count may be lowered or raised from the upstream response only through RelayInfo.UpdateImageCount on the HTTP image relay, or through the image_count usage fact a task plugin returns on completion; a zero or out-of-range count leaves the reserved request quantity in place. Derive the count from image payloads, never from JSON structure:
- Do NOT count
datawith gjsondata.#,len(data), or any element count. Providers behind OpenAI-compatible channels return non-standard shapes:dataas a single object, one image split into aurlentry and ab64_jsonentry, or entries carrying onlyrevised_prompt. - An object-shaped
datais one image when it has a non-emptyurlorb64_json, otherwise zero. For arrays the count ismax(entries with non-empty url, entries with non-empty b64_json): a standard response uses oneresponse_formatso this equals the entry count, a split image bills once, and payload-less entries bill nothing. The reference implementation isopenaiImageResponseCountinrelay/channel/openai/relay_image.go; reuse it (or the same rule) for any channel that forwards or rebuilds OpenAI-format image responses. The xAI adaptor already inherits it throughOpenaiImageHandler. - When an OpenAI-format JSON body is re-emitted as SSE, forward one
image_generation.completedevent per image payload using the same shape handling (object → one event, payload-less entries dropped). The client must never receive only[DONE]while being charged for images. - Real SSE streams count
image_generation.completed/image_edit.completedevents and only trust a lower count when upstream finished the stream (done/eof); a client abort must not reduce the charge below the requested quantity. - Ali (Bailian) image models are served by the
alibabatask plugin through theopenai_imagehost protocol. ItsimageUsagereadsusage.image_count, thenusage.output_image_count(Qwen-Image-3.0), and otherwise counts image payloads flattened across everychoices[].message.content[]entry andresults[]; payload-less parts bill nothing, and a fractional, negative, oversized, or payload-inconsistent upstream count keeps the reserved request quantity. Any other channel with its own usage field applies the same order: trust the bounded upstream count first, then count payloads. Channels that rebuild responses from typed structs count the typed slice; none of them may readdata.#. - Regression cases for object, split, and payload-less shapes belong in
relay/channel/openai/image_stream_test.go(fixed-price, tiered-expression, and JSON-as-SSE forwarding tables). Extend those tables instead of adding new files.