Explorar el Código

docs: add model display pricing design

master
fengsilin hace 1 semana
padre
commit
6dd9bc6dec
Se han modificado 1 ficheros con 243 adiciones y 0 borrados
  1. +243
    -0
      docs/superpowers/specs/2026-06-30-model-display-pricing-design.md

+ 243
- 0
docs/superpowers/specs/2026-06-30-model-display-pricing-design.md Ver fichero

@@ -0,0 +1,243 @@
# Model Display Pricing Design

## Background

The current pricing system mixes two responsibilities in one structure:

- Real billing reads `PricingConfig.table[].price` through relay pricing lookup, freezes it into `PricingDecision`, and later uses that decision for pre-consumption and final settlement.
- The pricing page also reads `PricingConfig.table[].price` to render model price summaries and matrix details.

Some video providers need a user-facing estimate such as `768p-6s, price per second`, while the actual charge is settled by token usage, provider-returned deduction, or another backend billing result. Reusing `PricingConfig` for this would make display-only prices affect real billing.

## Goal

Add a display-only pricing configuration that lets the frontend show a supplier-style price table for user estimation.

This configuration must:

- Use USD as the base amount so the existing frontend currency conversion can support USD, CNY, and custom currencies.
- Never participate in quota pre-consumption, final settlement, token billing, task polling, or billing logs.
- Override only the public pricing page display when configured.
- Preserve current pricing behavior for models without display pricing.

## Non-Goals

- Do not change how real billing is calculated.
- Do not add formula-based display pricing in this iteration.
- Do not replace `ModelPricingRules`; real matrix pricing remains unchanged.
- Do not calculate exact task cost from display pricing. It is only an estimate.

## Data Model

Add a new option key, separate from `ModelPricingRules`:

```text
ModelDisplayPricing
```

The option value is a JSON map keyed by model name:

```json
{
"kling-video": [
{
"specification": "768p-6s",
"official_supplier_tip": "Text-to-video and image-to-video, 768P 6s",
"price": 0.33333,
"unit": "second",
"sort_order": 1,
"discount_rate": 1
},
{
"specification": "768p-10s",
"official_supplier_tip": "Text-to-video and image-to-video, 768P 10s",
"price": 0.4,
"unit": "second",
"sort_order": 2,
"discount_rate": 1
},
{
"specification": "1080p",
"official_supplier_tip": "Text-to-video and image-to-video, 1080P",
"price": 0.58333,
"unit": "second",
"sort_order": 3,
"discount_rate": 1
}
]
}
```

Suggested Go type:

```go
type ModelDisplayPricingItem struct {
Specification string `json:"specification"`
OfficialSupplierTip string `json:"official_supplier_tip"`
Price float64 `json:"price"`
Unit string `json:"unit"`
SortOrder int `json:"sort_order"`
DiscountRate float64 `json:"discount_rate"`
}
```

Field semantics:

- `specification`: short SKU label shown in compact and detail views.
- `official_supplier_tip`: supplier-facing explanation shown in detail view.
- `price`: USD base display price before discount. This is not quota and not billing input.
- `unit`: display unit, such as `second`, `call`, `image`, or `video`.
- `sort_order`: stable ordering for the frontend and API response.
- `discount_rate`: display-only multiplier. Default is `1` when omitted or invalid.

Validation rules:

- Model key must not be empty.
- `specification` must not be empty.
- `price` must be greater than or equal to `0`.
- `unit` must not be empty.
- `discount_rate` defaults to `1`; accepted range is `0 <= discount_rate <= 1`.
- Items should be returned sorted by `sort_order`, then `specification`.

## Backend Design

Add a new ratio setting module, parallel to `model_pricing.go`, for display pricing only:

- `GetModelDisplayPricing(model string) []types.ModelDisplayPricingItem`
- `GetModelDisplayPricingCopy() map[string][]types.ModelDisplayPricingItem`
- `SetModelDisplayPricing(model string, items []types.ModelDisplayPricingItem) error`
- `DeleteModelDisplayPricing(model string) error`
- `UpdateModelDisplayPricingByJSONString(jsonStr string) error`
- `ModelDisplayPricing2JSONString() string`

Store the JSON in `options`, matching the existing config pattern and avoiding a cross-database migration for a display-only setting.

Extend `model.Pricing`:

```go
DisplayPricing []types.ModelDisplayPricingItem `json:"display_pricing,omitempty"`
```

During `model.updatePricing()`, after the model metadata and real pricing fields are assembled, attach display pricing for the model if configured. This keeps `/api/pricing` as the single source used by the existing pricing page.

`/api/pricing/user/:model` should also return `display_pricing` when present, but it must not use it for `original_price`, `user_price`, `original_input`, `original_output`, or savings calculation.

Admin APIs should mirror the existing model pricing rule APIs:

- `GET /api/option/model_display_pricing`
- `GET /api/option/model_display_pricing/:model`
- `PUT /api/option/model_display_pricing/:model`
- `DELETE /api/option/model_display_pricing/:model`

These endpoints manage only display data and call `model.RefreshPricing()` after updates.

## Frontend Design

Add display pricing helpers in the model pricing utilities:

- `hasDisplayPricing(record)`
- `summarizeDisplayPricing({ record, displayPrice })`
- `formatDisplayPricingItem({ item, displayPrice })`

Display calculation:

```js
original = displayPrice(item.price)
discounted = displayPrice(item.price * item.discount_rate)
```

If `discount_rate` is `1`, show only the regular price. If lower than `1`, show discounted price and optionally the original price with strikethrough.

Display priority:

1. If `record.display_pricing` exists and has items, render display pricing.
2. Otherwise, keep the existing matrix pricing, per-token pricing, and per-call pricing behavior.

List and card compact display:

```text
CNY 2.41 - CNY 4.22 / second
Display price
```

If all visible items share one price:

```text
CNY 2.41 / second
Display price
```

Detail view:

| Specification | Supplier Tip | Price | Discount |
| --- | --- | --- | --- |
| 768p-6s | Text-to-video and image-to-video, 768P 6s | CNY 2.41 / second | - |
| 768p-10s | Text-to-video and image-to-video, 768P 10s | CNY 2.90 / second | - |
| 1080p | Text-to-video and image-to-video, 1080P | CNY 4.22 / second | - |

Every display pricing block must include an i18n note with this meaning:

```text
Display price is only an estimate. Actual consumption is based on the final quota deduction after the task completes.
```

When a model has `display_pricing`, the old user discount comparison card should be hidden or replaced with the display pricing table. The current discount card is derived from real billing fields and would be misleading for models whose visible price is only an estimate.

## Billing Isolation

The implementation must not reference display pricing in:

- `relay/helper/price.go`
- `relay/helper/pricing_lookup.go`
- `controller/relay.go` pricing preload logic
- `service/task_polling.go`
- `types.PricingDecision`
- task final deduction calculation
- quota pre-consumption
- consume logs used for settlement

Only `model.Pricing` and pricing-page APIs should expose it.

## Data Flow

```text
Admin saves display pricing
-> options.ModelDisplayPricing
-> ratio_setting display pricing cache
-> model.RefreshPricing()
-> model.updatePricing() attaches display_pricing
-> GET /api/pricing returns display_pricing
-> frontend price page renders display SKU table

User creates a task
-> relay pricing preload reads ModelPricingRules or legacy price/ratio
-> PricingDecision is frozen
-> task completes
-> final settlement uses real billing data
-> display_pricing is never read
```

## Testing

Backend tests:

- Parse and validate `ModelDisplayPricing` JSON.
- Default `discount_rate` to `1`.
- Reject invalid model keys, empty specification, negative price, empty unit, and invalid discount rates.
- Confirm `model.GetPricingByModel()` includes sorted `DisplayPricing`.
- Confirm real `PricingConfig` behavior is unchanged when display pricing exists.
- Confirm `/api/pricing/user/:model` returns `display_pricing` but does not use it for computed prices.

Frontend tests:

- `hasDisplayPricing()` detects display pricing before matrix pricing.
- Summary renders min-max range with converted currency.
- Detail table renders specification, supplier tip, unit, and discount.
- Existing matrix and legacy pricing rendering remains unchanged when no display pricing exists.
- User discount comparison is hidden or replaced when display pricing exists.

Manual verification:

- Configure one model with display pricing and token-based real billing.
- Confirm public price page shows the display table.
- Run a real task and confirm quota deduction follows the existing billing path, not `display_pricing.price`.

Cargando…
Cancelar
Guardar