50 KiB
| name | description | author | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|
| pricing-module | Development patterns, pricing engine logic, and API reference for the pricing module — Sage platform's YAML-driven pricing calculation engine | Hermes Agent |
|
Pricing Module Skill
Overview
The pricing module is Sage's configurable pricing engine that calculates costs based on YAML-defined pricing rules. It supports multi-dimensional condition matching (between, in, =, >, <, >=, <=), formula evaluation, data mappings, time-based pricing periods, and bulk Excel import/export.
Module Location
~/repos/pricing/
Architecture
pricing_program (定价项目)
│
└── pricing_program_timing (定价项目时序) — 1:N, time-scoped, 拉链表
│
└── pricing_data (YAML) — {pricing_type, fields: {...}, pricings: [...]}
Timing chain (拉链表): pricing_program_timing uses a chain/zipper pattern — each record has enabled_date → expired_date. When adding a new record via add_pricing_program_timing():
- New record's
expired_dateis set to9999-12-31 - Previous latest record's
expired_dateis updated to the newenabled_date - This creates a continuous timeline:
[old_start, new_start)→[new_start, 9999-12-31)
In Sage: llmage → pricing Flow
llm.ppid ──→ pricing_program.id
│
└── buffered_charging(ppid, usages)
│
├── get_ppid_pricing(ppid) — fetch pricing_data (cached)
├── get_pricing_from_ymalstr(usages, pricing_data) — match rules
└── return [amount, cost] (no discount applied)
Key Tables
| Table | Purpose | Key Fields |
|---|---|---|
| pricing_program | Pricing project definition | id, name, ownerid, providerid, pricing_belong |
| pricing_program_timing | Time-scoped pricing periods (拉链表) | id, ppid, name, enabled_date, expired_date, pricing_data(YAML) |
| pricing_item | Individual pricing line items | id, pptid, name, formula, spec_values |
ServerEnv Functions (via load_pricing())
Available in .dspy files via globals():
| Function | Purpose |
|---|---|
buffered_charging(ppid, data) |
Calculate pricing with memory cache (primary method) |
pricing_program_charging(sor, ppid, data) |
Calculate pricing without cache (with sor cursor) |
get_pricing_program(ppid) |
Get pricing program definition |
load_pricing_data(pptid, webpath_xlsx) |
Load pricing data from Excel file |
write_pricing_patten(request, ppid) |
Generate Excel pricing template |
test_pricing(pptid, data) |
Test pricing calculation, return total amount |
generate_formula_from_factors(price_factors) |
Auto-generate formula string from price_factors array |
add_pricing_program_timing(env, sor, ns) |
NEW: Timing chain logic — sets expired_date, updates previous record |
get_pricing_display(ppid, model=None) |
Return structured human-readable pricing data for frontend display. model parameter filters items to a specific model name (exact match + model_mappings). Fallback: if filtering yields empty results, returns all items (for single-model ppids without model filter keys). |
Pricing Types
Three pricing modes controlled by pricing_type in pricing_data (default: per_use):
per_use — 按量计费(现有模式)
Standard pay-per-use. Each pricings entry has filters + price_factors + formula.
monthly_bundle — 包月套餐
Contains multiple products with quotas and overage pricing:
pricing_type: monthly_bundle
pricings:
- name: 企业AI套餐
plan_level: enterprise # filter
base_price:
amount: 9999
unit_label: 元/月
products:
- product: gpt-4
label: GPT-4
included: 1000000 # monthly quota
unit_label: Token/月
overage: # overage has its own price_factors
price_factors:
- factor: tokens
label: Token
unit_price: 0.03
unit_label: 元/千Token
formula: 0.03 * float(tokens) / 1000.0
Usage is tracked by charge/accounting module. Each product use calls calculate_bundle_usage() which checks quota, calculates overage cost.
monthly_credits — 包月额度
Buy a credit pool, products consume from it:
pricing_type: monthly_credits
pricings:
- name: AI消耗包
credit_amount: 10000
unit_label: 元
products:
- product: gpt-4
label: GPT-4
cost_per_unit: 0.03
unit_label: 元/千Token
Pricing Spec Format — REMOVED (2026-07)
The pricing_spec column on pricing_program has been removed. Field definitions now come exclusively from pricing_data.fields in the YAML, which is already self-describing. The pricing_spec was a redundant copy of the field definitions from pricing_data — removing it simplifies the data model and eliminates sync issues between the two sources.
model:
type: str
label: "模型"
role: filter # NEW: filter vs factor
options:
- "viduq3-pro"
- "viduq3-turbo"
resolution:
type: str
label: "分辨率"
role: filter
options:
- "1024p"
- "720p"
duration:
type: int
label: "时长"
role: filter
price:
type: float
label: 单价
role: factor # calculation factor, not a filter
Required: price field with type: float (for per_use pricing).
Field properties:
type: str, int, float, bool, factorlabel: Display namerole: filter (default, used for matching/display) | factor (calculation only, hidden from filters)options: Dropdown options (also used for matching)value_mode: Match mode (=, between, in, >, >=, <, <=)
Pricing Data Format (pricing_program_timing.pricing_data) — NEW DESIGN
The pricing data uses a declarative per-factor structure. Each pricing item is a single factor with its unit price and unit. Complex formulas are decomposed into independent pricing items.
# 计价单位数值映射(只包含本条记录实际用到的单位)
unit_values:
百万: 1000000
秒: 1
# 字段定义(必须包含 price_factors, unit_prices, unit, min_amount)
fields:
price_factors:
type: string
role: factor
label: "计价因子"
unit_prices:
type: float
role: factor
label: "单位定价"
unit:
type: string
role: factor
label: "计价单位"
min_amount:
type: float
role: factor
label: "最低消费"
default: 0
# derived 字段:从原始 usage 数据计算衍生字段
uncached_prompt_tokens:
type: int
role: factor
label: "非缓存输入Token"
derived: "prompt_tokens - prompt_tokens_details.cached_tokens"
cached_tokens:
type: int
role: factor
label: "缓存Token"
derived: "prompt_tokens_details.cached_tokens"
# filter fields with range conditions need value_mode: between
prompt_tokens:
type: int
role: filter
label: "输入Token数"
value_mode: between
completion_tokens:
type: int
role: factor
label: "输出Token数"
value_mode: between
model:
type: string
role: filter
label: "模型"
# 定价规则 — 每条一个 price_factors,无 formula
pricings:
# 简单按量定价
- price_factors: prompt_tokens
unit_prices: 3.2
unit: 百万
- price_factors: completion_tokens
unit_prices: 16.0
unit: 百万
# 区间定价:使用 value_mode: between + 顶层范围条件(NOT filters 数组)
- price_factors: uncached_prompt_tokens
unit_prices: 6.0
unit: 百万
model: qwen3.7-max
prompt_tokens: "0 ~= 10000000" # AND condition, not OR
- price_factors: uncached_prompt_tokens
unit_prices: 12.0
unit: 百万
model: qwen3.7-max
prompt_tokens: "10000000 ~" # 超过10M的更高价格
# 规格定价(按分辨率等 filter 匹配,使用 filters 数组)
- price_factors: duration
unit: 秒
filters:
- SR: 720
unit_prices: 0.9
- SR: 1080
unit_prices: 1.6
### unit_values: Per-record unit mapping (NEW DESIGN)
The `unit_values` mapping in pricing_data should **only contain units actually used in that record's pricings**, not a global map of all possible units. This keeps each pricing_data self-contained and avoids confusion.
**Example:**
```yaml
# Record that only uses "秒" (seconds)
unit_values:
秒: 1
fields:
price_factors: {type: string, role: factor, label: "计价因子"}
unit_prices: {type: float, role: factor, label: "单位定价"}
unit: {type: string, role: factor, label: "计价单位"}
pricings:
- price_factors: duration
unit_prices: 0.9
unit: 秒
Common units in production data:
元/百万tokens→ 1000000 (LLM token pricing)秒→ 1 (video/audio duration)次→ 1 (per-use flat pricing)张→ 1 (image generation)
Conversion script pattern: When migrating old data, scan all pricings items to collect unique unit values, then build a minimal unit_values map:
used_units = set()
for p in new_pricings:
u = p.get('unit', '')
if u:
used_units.add(u)
# Map known units to their values
UNIT_VALUES = {
'元/百万tokens': 1000000,
'秒': 1,
'次': 1,
'张': 1,
# ... other known units
}
filtered_unit_values = {k: v for k, v in UNIT_VALUES.items() if k in used_units}
Calculation Logic (pseudo-code)
total = 0
for item in pricings:
factor_name = item['price_factors']
if factor_name == 'flat':
total += item['unit_prices']
continue
usage_value = usages.get(factor_name, 0)
unit_price = item.get('unit_prices')
# Range-based pricing: top-level field with value_mode: between
# e.g., prompt_tokens: "0 ~= 10000000" — matched via check_value()
# These are AND conditions with other top-level fields
# Spec-based pricing: filters array with OR logic
if 'filters' in item:
for f in item['filters']:
if match_condition(f, usages):
unit_price = f['unit_prices']
break
unit_val = unit_values.get(item['unit'], 1)
amount = unit_price * usage_value / unit_val
# Apply min_amount if defined
if 'min_amount' in item:
amount = max(amount, item['min_amount'])
total += amount
Derived Fields (NEW — 2026-06)
Modern LLM APIs return nested token breakdowns (e.g., prompt_tokens_details.cached_tokens). The pricing engine supports derived fields that compute values from nested usage data:
fields:
uncached_prompt_tokens:
type: int
role: factor
label: "非缓存输入Token"
derived: "prompt_tokens - prompt_tokens_details.cached_tokens"
cached_tokens:
type: int
role: factor
label: "缓存Token"
derived: "prompt_tokens_details.cached_tokens"
Implementation:
- Engine evaluates
derivedexpressions before processing pricings - Dot notation (
prompt_tokens_details.cached_tokens) is converted to underscore (prompt_tokens_details_cached_tokens) for Python eval - Nested dict values are flattened:
config_data['prompt_tokens_details']['cached_tokens']→eval_env['prompt_tokens_details_cached_tokens'] - If derived expression fails, field defaults to 0
Use case: When usage data has {"prompt_tokens": 1075, "prompt_tokens_details": {"cached_tokens": 500}}, the derived field uncached_prompt_tokens computes 1075 - 500 = 575, which can then be priced separately from cached_tokens.
See references/pricing-derived-fields.md for implementation details and testing patterns.
Range Filters vs Spec Filters
Two distinct patterns for conditional pricing:
1. Range Filters (value_mode: between)
For token/duration ranges, use top-level field attributes with value_mode: between:
fields:
prompt_tokens:
type: int
role: filter
label: "输入Token"
value_mode: between
pricings:
- price_factors: uncached_prompt_tokens
unit_prices: 6.0
unit: 百万
prompt_tokens: "0 ~= 10000000" # AND condition, matched via check_value()
Key: Each pricing item with a range condition is a separate entry. The engine matches ALL top-level fields (AND logic).
2. Spec Filters (filters array)
For categorical matching (resolution, model variants), use filters array with OR logic:
pricings:
- price_factors: duration
unit: 秒
filters:
- SR: 720
unit_prices: 0.9
- SR: 1080
unit_prices: 1.6
Key: The filters array items are mutually exclusive — only one matches per request (OR logic).
Coverage Analysis
| Scenario | Supported | Example |
|---|---|---|
| Simple per-unit (token/duration) | ✅ | unit_prices * usage / unit_value |
| Range pricing (token brackets) | ✅ | filters by prompt_tokens range |
| Quality/spec pricing (resolution) | ✅ | filters by SR |
| Cache/non-cache separate pricing | ✅ | separate pricing items |
| Fixed fee (per-use flat) | ✅ | price_factors: flat |
| Minimum charge | ✅ | min_amount field |
| Cumulative tiered (first 10K free) | ❌ | current is "match bracket" not "cumulative" |
Pricing Matching Rules
get_pricing_from_ymalstr(config_data, yamlstr) matches input data against pricing rules:
- Iterates all
pricingsentries - For each rule, checks every field against input via
check_value():=(default): exact matchdata_value == spec_valuebetween: range match (0 ~ 100means0 <= value < 100)in: enumeration match (gpt-4 gpt-3.5)>/</>=/<=`: comparison
- All fields must match for a rule to be selected
- Executes
formulaviaeval()to calculateamount - Returns all matching rules (not just the first one)
Data Mappings
model_mappings:
"doubao-seed-2-0-pro-260215": "doubao-seed-2-0-pro"
Input values are mapped through *_mappings before matching.
References
references/pricing-test-guide.md— Step-by-step pricing test checklistreferences/qwen-image-pricing.md— Qwen image model pricing patternsreferences/uapiio-bricks-format.md— uapiio input_fields bricks 兼容格式及 data/response 字段规范references/rbac-redis-cache.md— rbac userperm.py Redis L2 缓存实现要点与调试经验references/pricing-type-design.md— Design decisions for pricing_type expansion (price_factors, monthly_bundle, monthly_credits, module boundaries with supplychain)references/pricing-display-implementation.md— Display API implementation details, mock-based testing pattern, deployment checklistreferences/pricing-data-formula-parsing.md— Formula pattern taxonomy, reverse-parsing approach, production data quality pitfalls, two coexisting formats (YAML dict vs JSON array)templates/model-pricing.sql— INSERT template for model pricing with cached/uncached token derived fieldsreferences/pricing-data-migration.md— Old-to-new conversion rules, filter propagation pitfalls, deployment stepsreferences/production-pricing-verification.md— End-to-end verification workflow: pull llmusage via bugfix SQL API, join for ppid, recalculate amounts, compare with production billingscripts/convert_all_pricing.py— Reusable conversion script: handles JSON arrays, YAML dicts, flat pricing, range filter propagation
Planned Enhancements (Partially Implemented)
✅ price_factors: Declarative pricing (IMPLEMENTED — new design)
The pricing data format has been redesigned to use declarative per-factor pricing instead of formula-based evaluation. See "Pricing Data Format" section above for the complete new structure.
Key change from original plan: Instead of price_factors being an array of dicts alongside formula, each pricing item IS a single factor with unit_prices and unit. No formula field needed — the engine calculates unit_prices * usage / unit_value.
pricing_type: Three pricing modes
pricing_type: per_use | monthly_bundle | monthly_credits
- per_use (default, current mode): match conditions → calculate from price_factors + unit_prices
- monthly_bundle: subscription with product quotas + overage pricing
- monthly_credits: buy credit balance, products consume from it
role field in fields definition
fields:
model:
type: str
label: 模型
role: filter # filter = matching condition shown in "适用条件"
# factor = calculation factor (not shown as filter)
# default = filter (backward compat)
monthly_bundle structure
pricings:
- name: 企业AI套餐
base_price: {amount: 9999, unit_label: 元/月}
products:
- product: gpt-4
label: GPT-4
included: 1000000 # quota
unit_label: Token/月
overage: # overage has full price_factors
price_factors:
- factor: tokens
label: Token
unit_price: 0.03
unit_label: 元/千Token
formula: 0.03 * float(tokens) / 1000.0
monthly_credits structure
pricings:
- name: AI消耗包
credit_amount: 10000
unit_label: 元
products:
- product: gpt-4
label: GPT-4
cost_per_unit: 0.03
unit_label: 元/千Token
Module separation: pricing vs charging
- pricing module: defines prices + calculates amounts (stateless, no consumption data)
- charging module: records consumption, tracks quotas, creates bills
- subscription (主表): user_id, ppid, pricing_type, base_amount, dates, status
- subscription_product (明细): sub_id, product, label, included, overage_config(JSON)
- monthly_usage: sub_id, product, month, total_used, remaining, total_cost
- usage_record: sub_id, product, usage_data, included_used, overage_used, amount
Multi-level distribution belongs to supplychain module
Distribution layers (supplier → platform → level-1 → level-2) and markup/discount chains are NOT in pricing. Pricing only defines the base price (supplier list price). Supplychain module handles:
- Distributor hierarchy (parent_id tree)
- Per-level adjustment (discount/markup/fixed)
- Per-distributor price calculation (chain multiplication from base)
Backward compatibility
- No pricing_type → default per_use
- No role → default filter
- No price_factors → display API falls back to showing formula
- New and old data coexist; migrate incrementally
CRUD JSON Provider Dropdown
pricing_program.providerid references supplychain.suppliers (see model codes table: {field: "providerid", table: "supplychain.suppliers", valuefield: "id", textfield: "supplier_name"}). The CRUD JSON MUST have a matching browserfields.alters entry — without it, the field renders as plain text with no dropdown:
"browserfields": {
"exclouded": ["id", "ownerid"],
"alters": {
"providerid": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_providerid.dspy')}}",
"valueField": "providerid",
"textField": "providerid_text"
}
}
}
The supporting dspy (get_search_providerid.dspy) queries suppliers table via get_sor_context(request._run_ns, 'rbac') (not the old organization table). Returns [{providerid: '', providerid_text: '全部'}, ...]. New dspy files need explicit RBAC permission + rolepermission entries — use python3 set_role_perm.py 'owner.superuser' '/pricing/api/get_search_providerid.dspy' and also INSERT into rolepermission for non-superuser roles. See references/providerid-dropdown-setup.md.
Pitfalls
-
pricing_specanddiscountcolumns REMOVED (2026-07) — Thepricing_specanddiscountcolumns onpricing_programhave been deleted. All field definitions now come frompricing_data.fieldsin the YAML. Pricing no longer applies a separate discount factor — cost = amount directly. Production migration requiresALTER TABLE pricing_program DROP COLUMN pricing_spec, DROP COLUMN discount;. -
formula field is REMOVED in new format: In the new pricing data design, the
formulafield is no longer used. Pricing is calculated declaratively fromunit_prices * usage / unit_value. Old data withformulastill works via backward compatibility, but new pricing_data should NOT include formulas. -
Multiple matches returned: If multiple rules match, ALL are returned (not just the first); caller must handle
-
CRITICAL — between operator zero-value trap:
~=meansa < v <= b(exclusive lower bound). When ANY factor is 0, the filter fails → pricing item skipped → amount=0 billing failure. Use=~(a <= v < b) for ranges starting at 0. Real incident: 80 records lost billing (19.18元) becausecached_tokens: 0.0 ~= 10000000.0evaluated0.0 < 0= FALSE. Bulk fix:UPDATE pricing_program_timing SET pricing_data = REPLACE(pricing_data, '~=', '=~') WHERE pricing_data LIKE '%~=%';. Always test with factor=0 after any pricing_data change. -
Timing chain (拉链表): When adding a new
pricing_program_timingrecord, useadd_pricing_program_timing()function. It setsexpired_date='9999-12-31'on the new record and updates the previous latest record'sexpired_dateto the newenabled_date. Never INSERT directly without this chain logic. -
factor type skipped: Fields with
type: factorare NOT included in Excel templates (used as calculation factors only) -
pricing_data structure: Must be
{fields: {...}, pricings: [...]}— missing either part raises an error -
Cache key is
{ppid}.{date}: Cached by pricing program ID + business date; keeps last 2 days per ppid -
Excel column headers are labels: Import maps label back to spec key — label must match exactly
-
Qwen image pricing reference: For Alibaba Cloud/DashScope
qwen-image-2.0-proand dated Qwen image models, seereferences/qwen-image-pricing.md. Key pattern: both text-to-image and image-edit charge by successful output image count (usage.image_count), not resolution; usemodel + operationmatching andformula: price * image_count. -
Timing cache invalidation events may not include
ppid:pricing_program_timing:d:afterpayloads can contain only the timing rowid, especially after SQLor delete paths. Cache refresh code must resolve the pricing program ID defensively: preferpayload.ppid, thenpayload.old/old_row/old_data, then look uppricing_program_timing.id -> ppidwhile the row still exists. For deletes, register and handlepricing_program_timing:d:beforeto pre-cachetiming_id -> ppid, then use that cache fromd:afterand clean it up. This depends on SQLor dispatchingd:beforebefore row deletion; if production behavior regresses, update SQLor before blaming pricing cache code. -
Corrupted existing pricing data: Existing pricing programs may contain stale model names (e.g.,
viduq4-turboinstead ofviduq3-turbo). When adding pricing for new capabilities, create a new independent pricing_program with clean data rather than modifying existing ones. Updatellm_api_map.ppidto point to the new pricing program. -
pricing_data is YAML, not JSON:
pricing_program_timing.pricing_datais stored as a YAML string (parsed byyaml.safe_load), not JSON. Useyaml.dump(data, allow_unicode=True)to generate, then MySQL-escape single quotes and backslashes before INSERT. -
Test before deploy: Use
POST /pricing/test_pricing_program.dspywith{"ppid": "...", "data": {...}}to validate pricing calculations. Error responses include the full YAML used for matching — use it to debug missing options or mismatched rules. Seereferences/pricing-test-guide.md. -
CRITICAL: pricing_program_timing records MUST exist:
get_ppid_pricing(ppid)queriespricing_program JOIN pricing_program_timing. If a pricing_program has zero timing rows, the query returns 0 rows and raisesdata not found. A newly created pricing_program always needs at least one timing record with validenabled_date <= today < expired_date. Before testing, verify:GET /pricing/get_all_pricing_programs.dspy— check thetimingsarray is non-empty. -
Empty pricing_data causes DictObject crash: If
pricing_program_timing.pricing_datais an empty string,yaml.safe_load('')returnsNone, thenDictObject(**None)crashes withargument after ** must be a mapping. Error signature:config_data={...} yamlstr='' data not found. Ensurepricing_datacontains valid YAML with bothfieldsandpricingskeys. -
discount variable in formulas: The
discountvalue frompricing_program.discountis available as a variable inside formula expressions. Useformula: base_rate * duration * discountrather than hardcoding the discount value. The engine multipliesamount * discountagain forcost, so formulas that already includediscountwill double-apply it to cost (but amount is correct). -
Complete pricing test workflow: Before declaring pricing deployed, run through the full checklist in
references/pricing-test-guide.mdsection "Complete Test Checklist". See the reference for the step-by-step procedure. -
Nested token detail fields in LLM usage data: Modern LLM APIs (Qwen/DashScope, OpenAI) return token breakdowns as nested dicts, NOT flat keys. The usage dict structure is:
{ "prompt_tokens": 89612, "completion_tokens": 286, "prompt_tokens_details": {"cached_tokens": 86912}, "completion_tokens_details": {"reasoning_tokens": 92}, "model": "qwen3.7-max" }Formula field references must use dot notation for nested fields:
prompt_tokens_details.cached_tokens(NOTcached_tokens),completion_tokens_details.reasoning_tokens(NOTreasoning_tokens). The engine useseval()which handlesdict['a.b']only if the key literally contains a dot — but the actual data has nested dicts, socached_tokensas a flat key will raise "没有(cached_tokens)数据". Always verify the exact usage dict structure from the provider's API response before writing formulas. -
pricing_program_timing for promotional periods: When a model has time-limited promotional pricing (e.g., half-price until a date, then full price), create TWO timing records with non-overlapping date ranges: one for the promo period and one starting from the promo end date with regular pricing. Both records must be in
pricing_program_timing— the engine filters byenabled_date <= today < expired_date. -
Distributor pricing belongs in supplychain module, NOT pricing: Multi-level distributor pricing (供应商→平台→一级→二级) is handled by the supplychain module, not pricing. Pricing module only defines the base/official price (供应商官宣价). Supplychain applies markup/discount chains on top. Never add distributor-specific pricing logic to the pricing module.
-
price_factors is a single string, NOT an array: In the new pricing data design, each pricing item's
price_factorsis a single string (the factor name like "prompt_tokens", "duration", "flat"), not an array of dicts. Each pricing item represents ONE factor with its ownunit_pricesandunit. Multi-factor pricing (e.g., input + output tokens) is expressed as separate pricing items, not one item with multiple factors. -
fields must include price_factors, unit_prices, unit: The new pricing_data format requires these three fields in
fieldsdefinition (plus optionalmin_amount). If any are missing, the engine raises "定价项中的xxx在fields中没有定义". Always include:
fields:
price_factors:
type: string
role: factor
label: "计价因子"
unit_prices:
type: float
role: factor
label: "单位定价"
unit:
type: string
role: factor
label: "计价单位"
min_amount:
type: float
role: factor
label: "最低消费"
default: 0
-
unit must reference unit_values keys: The
unitfield in each pricing item must be a key from theunit_valuesmapping (e.g., "百万", "秒", "次"), not a raw number. The engine looks up the divisor fromunit_values[unit]to calculateunit_prices * usage / unit_value. -
Old data conversion pattern: When converting old pricing_data (with
formulafield) to the new format:
- Single-factor formulas (
0.5 * image_count) →{price_factors: "image_count", unit_prices: 0.5, unit: "张"} - Multi-factor formulas (
a*x + b*y) → split into separate items per factor - Flat prices (
formula: 1.35) →{price_factors: "flat", unit_prices: 1.35, unit: "次"} - Range conditions (
prompt_tokens: 0 ~= 32000) → move tofiltersarray - See
references/pricing-data-migration.mdfor the conversion script
-
Backward compatibility for old pricing data: Old data without
pricing_typedefaults toper_use. Old data withoutroleon fields defaults tofilter. Old data withoutprice_factors— display API falls back to showing the raw formula. All three defaults ensure gradual migration. -
Usage/consumption data NEVER stored in pricing module: Pricing module is stateless — it defines prices and calculates amounts, but does NOT track usage, quotas, or consumption history. All usage state (monthly_usage, usage_record, subscription) belongs to the charging module. When bundle/credits products are used, charging calls pricing's calculation function to get the amount, then records the usage itself. Never add usage tracking tables or state to the pricing module.
-
wwwroot symlink must be top-level: When deploying pricing module to Sage, the symlink must be at the module root level:
ln -sfn ~/repos/pricing/wwwroot ~/repos/sage/wwwroot/pricing. Linking only subdirectories (e.g.,api/) is insufficient — ahserver won't discover the module. Verify:ls -la ~/repos/sage/wwwroot/pricingshould show a symlink to the full wwwroot directory. -
Two pricing_data formats coexist in production: Most
pricing_program_timing.pricing_datais YAML dict format ({fields: {...}, pricings: [...]}). However, vidu video series uses JSON array format ([{model, resolution, formula: <number>, ...}]) where formula is already a flat price, not an expression. Code that parses pricing_data must handle both: tryjson.loads()first for array detection, fall back toyaml.safe_load()for dict format. Seereferences/pricing-data-formula-parsing.mdfor details. -
Production pricing_data has common quality issues: When programmatically reading/writing pricing_data, normalize before parsing: (1) replace
\r\nwith\n, (2) replace Unicode smart quotes""with ASCII", (3)rstrip()each line to fix trailing spaces on keys like"duration: ", (4) fix 3-space to 2-space indentation under list items. These issues cause silent YAML parse failures that manifest as "pricing_data is None". -
filters uses OR logic, not AND: When a pricing item has
filtersarray (interval pricing), the engine should match ANY filter item, not require ALL to match. Common implementation mistake:
# WRONG - requires all filters to match
for filter_item in filters:
if not match(filter_item, usages):
p_ok = False # one mismatch fails the whole pricing
# CORRECT - any filter match is sufficient
filter_matched = False
for filter_item in filters:
if match(filter_item, usages):
filter_matched = True
break
if not filter_matched:
p_ok = False
Example: prompt_tokens: "0 ~ 32000" and "32000 ~ 128000" are mutually exclusive ranges — only one should match per request.
-
Data conversion validation: After running
convert_pricing_to_new_design.py, verify the generated SQL by: -
Checking
/tmp/pricing_converted.jsonfor_NEEDS_MANUAL_REVIEWflags -
Spot-checking 2-3 records with complex formulas to ensure correct decomposition
-
Running test calculations with
test_pricing_calc.pybefore applying to production All 35 production records converted successfully with zero manual review needed (commit7200454). -
Range filters use value_mode: between, NOT filters array: Token/duration range conditions (e.g.,
prompt_tokens: "0 ~= 10000000") are top-level attributes on the pricing item, matched viacheck_value()with AND logic. They are NOT inside afiltersarray. The field definition must includevalue_mode: between. Each range gets its own pricing item. Example: two pricing items for low-range and high-range token pricing, each withprompt_tokens: "0 ~= 10M"or"10M ~". Thefiltersarray is only for categorical/spec matching (resolution, model variant) with OR logic. -
derived field dot-to-underscore conversion: When defining derived expressions with nested fields (e.g.,
derived: "prompt_tokens - prompt_tokens_details.cached_tokens"), the engine converts dot notation to underscore for Python eval. The eval environment is built by flattening nested dicts:config_data['prompt_tokens_details']['cached_tokens']becomeseval_env['prompt_tokens_details_cached_tokens']. The expression itself also has dots replaced with underscores. This allows natural YAML syntax while working within Python's variable naming constraints. -
unit_prices must be per-unit, not per-token: When converting old pricing data,
unit_pricesvalues should be the price per unit (e.g., 6.0 元/百万tokens), NOT per-token (6.0e-06). The old format stored per-token prices inunit_priceswith implicit/1000000in the formula. The new format divides byunit_values[unit]automatically, so use the human-readable price (6.0) withunit: 百万. -
Backward compatibility detection in get_pricing_from_ymalstr: The calculation function must support both old (formula-based) and new (price_factors-based) formats. Detection logic:
is_new_format = p.get('price_factors') is not None and p.get('unit_prices') is not None
is_old_format = p.get('formula') is not None
if not is_new_format and not is_old_format:
continue # skip invalid entries
if is_new_format:
# New calculation: unit_prices * usage / unit_values[unit]
factor_name = p['price_factors']
usage_value = config_data.get(factor_name)
if usage_value is None:
continue
amount = p['unit_prices'] * float(usage_value) / unit_values.get(p.get('unit', '次'), 1)
elif is_old_format:
# Old calculation: eval(formula)
amount = eval(p['formula'], {}, config_data)
Key points:
- New format requires BOTH
price_factorsANDunit_pricesto be present - Old format requires
formulato be present - If neither format is detected, skip the pricing item (don't crash)
- New format handles
filterswith OR logic for tiered pricing - Old format uses
eval()which can access nested fields via dot notation (e.g.,usage.prompt_tokens)
-
get_pricing_display must NOT multiply unit_price by unit_val for new format: New format
unit_pricesalready stores the human-readable display price (e.g., 6.0 means 6.0 元/百万). Multiplying byunit_val(1000000) produces 6,000,000 — a catastrophic display bug. Same applies totieredpricing:raw_tier_priceis already display price, use directly. -
API responses: omit empty fields, filter redundant tiered — user demands zero noise: User explicitly corrected multiple times: empty fields like
"filters": {},"formula": "","min_amount": 0are confusing and unreadable. Rules:
- Omit when empty:
filters/filter_labels(not{"filters": {}}),formula(not"formula": ""),min_amountwhen 0 - Tiered entries: only include when
unit_prices != main unit_price— duplicates are meaningless noise - Tiered filters: remove internal params (
value_mode,xxx_tokensrange conditions) — only keep meaningful filters likemodel - display_text starts as empty list
[]— items naturally become first lines (header【...】定价: (...)was removed per user request) - Test before commit: verify actual output matches your description — user caught that I claimed to fix tiered duplicates but didn't verify
-
Business semantics: filters vs tiered: In pricing_data,
filters(item-level) means "applicability conditions" — which models/configs this pricing rule applies to.tiered(inside price_factors) means "price variations" — different prices for different conditions within the same rule. These are NOT interchangeable. Example:filters: {model: "qwen3-max"}means "this pricing is for qwen3-max", whiletiered: [{model: "qwen3-max", unit_prices: 10}]would be wrong — tiered is for price differences, not applicability. -
Production filters is a LIST of single-key dicts, NOT direct fields: The actual database format stores filters as
filters: [{model: xxx}, {resolution: yyy}, {duration: '1'}]— a list where each item is a single-key dict. Code extracting filters MUST handle both this list format AND direct fields on the pricing item. Pattern:
# Extract from p.items() (direct fields)
for k, v in p.items():
if k in skip_keys: continue
fdef = fields.get(k, {})
if fdef.get('role', 'filter') == 'filter':
filters[k] = v
# ALSO extract from p['filters'] list (production format)
raw_filters = p.get('filters')
if isinstance(raw_filters, list):
for fi in raw_filters:
if isinstance(fi, dict):
for k, v in fi.items():
filters[k] = v
-
display_text shows filter conditions inline: Each pricing line includes its filter conditions:
- 时长: 0.56 元/秒 [model=viduq3-turbo, resolution=1080p, off_peak=0]. This makes multi-condition pricing (like video generation with model+resolution+off_peak) readable at a glance. -
Shell escaping pitfall with API keys containing special characters: When API keys contain
-,_, or other special characters, avoid passing them inline via curl or shell commands. Instead, write Python scripts to files and execute them. Example: keyxxbM1asORWUTM7xc-lGC-causesSyntaxError: unterminated string literalwhen embedded incurl -H "Authorization: Bearer ${key}". Fix: write to.pyfile withapi_key = 'xxbM1asORWUTM7xc-lGC-'and executepython3 script.py. -
get_pricing_display MUST pass model parameter to prevent cross-model price leakage: When multiple models share a single ppid (e.g., viduq3-pro, viduq3-turbo both use ppid
sF2gcl7UeANKtnZv8hfvL), callingget_pricing_display(ppid)without themodelfilter returns ALL pricing items for ALL models. Themodelparameter does an exact-match filter onitem['filters']['model']oritem['filter_labels']['模型']. Every caller must pass the model name:get_pricing_display(ppid, model=l.name). Affected callers:llmage/utils.py(get_llms_sort_by_provider, get_llms_by_catelog),llmage/v1/pricing/index.dspy. Also fix data: model names in pricing_data filters must matchllm.nameexactly (viduq3 → viduq3-pro). -
CRITICAL: Sage deploys require
site-packages/sync, not justpkgs/git pull: Sage loads Python modules from~/sage/py3/lib/python3.10/site-packages/<module>/, NOT from~/sage/pkgs/<module>/. Aftergit pullinpkgs/<module>/, you MUSTcpthe updated.pyfiles tosite-packages/<module>/and restart Sage..dspyand.uifiles hot-reload from the wwwroot symlink (which points to pkgs), but.pyfiles in site-packages require a manual copy + Sage restart. Symptom: code changes look correct on disk inpkgs/but Sage returns old behavior — checksite-packages/<module>/first. -
model filter: three-modes for multi-model vs single-model ppids:
get_pricing_display(ppid, model=name)filters pricing items by model name. Three behaviors:
- Exact match:
item['filters']['model'] == model→ include - model_mappings match:
model_mappings.get(item_model) == model→ include (handles mapping tables likeqwen3.7-max-2026-05-17: qwen3.7-max) - Fallback: if filtered_items is empty after filtering, fall back to ALL items. This handles single-model ppids where pricing_data doesn't label items with a
modelfilter key.
Without the fallback, single-model ppids (no model field in filters) would show no pricing at all when model parameter is passed. The filter must be: if filtered_items: items = filtered_items, NOT items = filtered_items unconditionally.
-
DB model names must match llm.name: When pricing_data uses
model: viduq3but thellmtable hasname: viduq3-pro, the model filter won't match. Fix:UPDATE pricing_program_timing SET pricing_data = REPLACE(pricing_data, 'model: viduq3\\n', 'model: viduq3-pro\\n'). Always verify model name consistency betweenpricing_datafilters andllm.name. -
CRITICAL: filters can be dict OR list in YAML — BOTH formats crash if not handled: Production
pricing_datauses TWO formats for thefiltersfield:
- List of dicts:
filters: [{model: viduq3-pro}, {resolution: 1080p}, {off_peak: 0}] - Single dict:
filters: {model: qwen3.6-plus, prompt_tokens: "-0.1 ~= 256000.0"}get_pricing_from_ymalstrhas TWOfor filter_item in p['filters']:loops (line ~625 for filter check, line ~676 for tiered pricing). Whenp['filters']is a dict, iterating gives string keys, andfilter_item.get(...)crashes with'str' object has no attribute 'get'. Fix both locations:
raw_filters = p['filters']
if isinstance(raw_filters, dict):
filter_items = [raw_filters]
else:
filter_items = raw_filters
for filter_item in filter_items:
if not isinstance(filter_item, dict):
continue
...
Symptom: ppid='...', data={...}, 'str' object has no attribute 'get'. Common for qwen3.6-plus and other range-priced models where filters are a single dict with model + prompt_tokens range.
-
model_mappings must be checked in get_pricing_from_ymalstr filter matching: When a pricing item has
model: qwen3.7-max-2026-05-17and the request data hasmodel: qwen3.7-max, the filter needsmodel_mappingsto resolve the match. Parsemodel_mappings = d.get('model_mappings', {})from YAML, then in the filter check:if item_model == model or model_mappings.get(item_model) == model. This is separate from the display-side model_mappings support (pitfall 45) — both billing and display need it. -
fields must define ALL filter keys used in
pricings-- add missing definitions to YAML, not code workarounds: When pricing_data hasfilters: {prompt_tokens: "0 ~= 256000"}butfieldsdoesn't include aprompt_tokensdefinition,f = d.fields.get(fk)returns None andcontinueskips the range check. ALL range-based pricing items then pass unconditionally, charging multiple tiers simultaneously. The correct fix is DATA, not code: add the missing field definitions to the YAML fields section withvalue_mode: between. For example:
fields:
prompt_tokens:
type: int
role: filter
label: 输入Token
value_mode: between
completion_tokens:
type: int
role: filter
label: 输出Token
value_mode: between
Update the DB record: yaml.safe_load(pricing_data) -> add missing field defs -> yaml.dump() -> UPDATE pricing_program_timing. Then restart Sage. Do NOT add auto-detection code in get_pricing_from_ymalstr — it masks the data quality issue and will recur for each new field.
-
NEVER put
from X import Yinside an if-block: IfDictObjectis already imported at module top, an inlinefrom appPublic.dictObject import DictObjectinside an if-branch causes Python to treatDictObjectas a local variable (shadowing the module-level import). Error:local variable 'DictObject' referenced before assignment. Always use the top-level import. If a function needs a module that might not exist, import at function top, not inside a conditional branch. -
KTV async services: one LLM per service pair (submit + status check), not separate LLMs: Services with both an
apiname(submit) andquery_apiname(status poll) need only ONE LLM record and ONE pricing_program. Thequery_apinameinllm_api_mapenablesquery_task_status()inasyncinference.pyto poll status without creating a new llmusage. Symptom: duplicate pricing programs likepp_ktv_realesrgan(submit) +pp_ktv_realesrgan_status(status-only). Fix: delete the status-only LLM + its pricing_program + pricing_program_timing, keep only the submit LLM withquery_apinameset. Also check for missing synth-generate LLM (media endpoint exists but no LLM record — create one with query_apiname=synth-status, copy pricing_data from deleted synth-status). -
KTV media endpoints must NOT generate their own UUID IDs: 14 v1/media endpoints had
import uuid; params_kw.transno = str(uuid.uuid4()).replace("-","")which creates 32-char hex IDs incompatible withgetID()'s 21-char format. All inference paths go throughinference()→_inference_generator()which callsgetID()for IDs and only setstransnoif not already present. Remove the custom UUID line entirely from all media endpoint.dspyfiles. Clean up the now-emptyif not params_kw.transno:block too. -
Derived field paths are provider-specific — CHECK actual API response: Different LLM providers return
cached_tokensat different nesting levels. OpenAI/Qwen:prompt_tokens_details.cached_tokens(nested). Kimi/Moonshot:usage.cached_tokens(top-level peer of prompt_tokens). Writing derived for the wrong provider causes silent-eval-to-zero — all tokens bill at uncached rate. Always inspect one real API response first.
| Provider | cached_tokens path | derived expression |
|---|---|---|
| OpenAI / Qwen | prompt_tokens_details.cached_tokens |
prompt_tokens - prompt_tokens_details.cached_tokens |
| Kimi / Moonshot | usage.cached_tokens |
prompt_tokens - usage.cached_tokens |
- Multi-currency billing setup: When pricing_program sets
currency='USD', the billing chain needsexchange_ratetable with real rates. Steps:
- Create
accountingdatabase config entry insage/conf/config.jsonpointing to same DB assage(test user can't CREATE DATABASE) - Run DDL from
accounting/scripts/multi_currency_migration.sql:currencytable +exchange_ratetable + INSERT base currencies and USD/CNY rates - Chain:
llm_charging()readspricing_program.currency→get_user_currency(userorgid)→get_exchange_rate(from, to, 'buy_rate')→convert_to_base(amount, currency, 'sell_rate') - Without exchange_rate table:
get_exchange_ratereturns 1.0 (no conversion), making USD pricing behave as CNY prices. Must restart Sage after adding exchange_rate. - Test:
POST /pricing/test_pricing_program.dspy— amount should show USD pricing (not double-counted by exchange rate).
GET /pricing/api/get_pricing_display.dspy?ppid=xxx returns structured human-readable pricing data for frontend display. The endpoint calls get_pricing_display(ppid) which:
Also available as a v1 API endpoint: GET /llmage/v1/pricing?model=xxx in the llmage module. This wrapper looks up the model's ppid via llm_api_map (with isdefaultcatelog='1') and calls env.get_pricing_display(ppid). Use this when you have a model name instead of a ppid.
- Fetches the cached pricing data via
get_ppid_pricing(ppid) - Parses the YAML pricing_data
- Extracts filters (role=filter fields) with Chinese labels
- Extracts pricing items (each with price_factors, unit_prices, unit)
- Handles tiered pricing (filters with different unit_prices)
- Falls back to showing formula for old data without new format
Response structure (new format — clean, no empty fields):
{
"status": "ok",
"data": {
"ppid": "...",
"name": "通义千问 qwen3.7-max",
"pricing_type": "per_use",
"items": [
{
"price_factors": [{
"factor": "uncache_tokens",
"label": "非缓存tokens",
"unit_price": 6.0,
"unit": "百万",
"unit_label": "元/百万"
}]
},
{
"price_factors": [{
"factor": "cached_tokens",
"label": "缓存tokens",
"unit_price": 1.2,
"unit": "百万",
"unit_label": "元/百万"
}]
}
],
"display_text": "【通义千问 qwen3.7-max】定价:\n - 非缓存tokens: 6.0 元/百万\n - 缓存tokens: 1.2 元/百万"
}
}
Each item in items (fields omitted when empty/default):
{
"filters": {"model": "MiniMax-M2.7"}, // omitted if empty
"filter_labels": {"模型": "MiniMax-M2.7"}, // omitted if empty
"price_factors": [
{
"factor": "prompt_tokens",
"label": "输入Token",
"unit_price": 2.1,
"unit": "百万",
"unit_label": "元/百万",
"tiered": [ // only if price != main unit_price
{"filters": {"model": "X"}, "unit_prices": 4.0}
]
}
],
"formula": "...", // omitted if empty
"min_amount": 0.01 // omitted if 0
}
Key implementation details:
- Each item in
itemsrepresents ONE pricing rule from the YAML price_factorsis an array with ONE element (the factor name)tieredarray only appears when price differs from mainunit_pricefilter_labelscontains Chinese labels for displaydisplay_textis a human-readable price table string (like an official pricing page)- Old format (with
formula) returnsformulafield populated,price_factorsas array of dicts - CRITICAL: For new format,
unit_priceis already the display price (e.g., 6.0 元/百万). Do NOT multiply byunit_val - CLEAN OUTPUT: Omit empty dicts (
filters: {}), empty strings (formula: ""), zero defaults (min_amount: 0). Tiered entries with same price as main are redundant noise — filter them out. Internal params likevalue_mode,xxx_tokensrange conditions are not user-facing — only keep meaningful filters likemodel
generate_formula_from_factors(price_factors) is DEPRECATED — the new format does not use formulas. Retained only for backward compatibility with old data.
Planned Functions (Not Yet Implemented)
These functions are part of the pricing_type expansion plan (charging module integration):
| Function | Purpose |
|---|---|
calculate_bundle_usage(ppid, product, usage, month_used) |
Calculate quota consumption + overage cost for monthly_bundle |
calculate_credits_usage(ppid, product, usage, remaining) |
Calculate credit deduction for monthly_credits |