Files
new-api-custom/.agents/rules/billing.md
T

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, video seconds/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.MaxImageN for image generation count, relaycommon.MaxTaskDurationSeconds for task video duration, maxTokensLimit (relay/helper/valid_request.go) for max_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"]), task metadata maps, 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 declare duration-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 int with a bare cast like int(float64(quota) * ratio), int(math.Round(...)) on unbounded input, or int(decimal.IntPart()). All quota rounding/conversion is centralized in common/quota_math.go; use those helpers: common.QuotaFromFloat (truncating) for float products, common.QuotaRound (half-away-from-zero) where rounding is intended, and common.QuotaFromDecimal for decimal products. billingexpr.QuotaRound delegates to common.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 uses common.WalletQuotaFromDecimalStrict with the JavaScript-safe common.MaxWalletQuota boundary. Every clamp/NaN fallback is logged via common.SysError.
  • Saturation events are also audited: each helper has a *Checked variant (common.QuotaFromFloatChecked / QuotaRoundChecked / QuotaFromDecimalChecked) that additionally returns a *common.QuotaClamp when clamping occurred. Billing paths that compute a charge capture that clamp onto relayInfo.QuotaClamp (or thread it into task settlement) and, right before writing the consume/task log, call attachQuotaSaturation (in service/log_info_generate.go) which nests the marker under the log's other.admin_info.quota_saturation and emits a request-correlated logger.LogWarn. Nesting under admin_info makes it admin-only for free (non-admin log views strip admin_info). When adding a new billing path, use the *Checked variant 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 to PriceData.OtherRatios directly, 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 >= 0 check 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, and common/quota_math_test.go for 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 data with gjson data.#, len(data), or any element count. Providers behind OpenAI-compatible channels return non-standard shapes: data as a single object, one image split into a url entry and a b64_json entry, or entries carrying only revised_prompt.
  • An object-shaped data is one image when it has a non-empty url or b64_json, otherwise zero. For arrays the count is max(entries with non-empty url, entries with non-empty b64_json): a standard response uses one response_format so this equals the entry count, a split image bills once, and payload-less entries bill nothing. The reference implementation is openaiImageResponseCount in relay/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 through OpenaiImageHandler.
  • When an OpenAI-format JSON body is re-emitted as SSE, forward one image_generation.completed event 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.completed events 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 alibaba task plugin through the openai_image host protocol. Its imageUsage reads usage.image_count, then usage.output_image_count (Qwen-Image-3.0), and otherwise counts image payloads flattened across every choices[].message.content[] entry and results[]; 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 read data.#.
  • 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.