feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -2,6 +2,7 @@ package billingexpr_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -10,6 +11,94 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFixedPriceBranches(t *testing.T) {
|
||||
const expression = `(len <= 32000 ? tier("short", fixed(0.01)) : tier("long", p * 2 + c * 8)) * (param("fast") == true ? 2 : 1)`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
params billingexpr.TokenParams
|
||||
cost float64
|
||||
tier string
|
||||
}{
|
||||
{"zero usage still charges once", billingexpr.TokenParams{}, 20000, "short"},
|
||||
{"fixed branch ignores token prices", billingexpr.TokenParams{P: 32000, C: 9000, Len: 32000}, 20000, "short"},
|
||||
{"token branch uses actual usage", billingexpr.TokenParams{P: 32001, C: 100, Len: 32001}, 129604, "long"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cost, trace, err := billingexpr.RunExprWithRequest(expression, tc.params, billingexpr.RequestInput{Body: []byte(`{"fast":true}`)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.cost, cost)
|
||||
assert.Equal(t, tc.tier, trace.MatchedTier)
|
||||
require.Len(t, trace.RequestRules, 1)
|
||||
assert.True(t, trace.RequestRules[0].Matched)
|
||||
})
|
||||
}
|
||||
cost, _, err := billingexpr.RunExpr(`tier("free", fixed(0))`, billingexpr.TokenParams{P: 1000})
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, cost)
|
||||
for _, count := range []int{-1, 0, 129} {
|
||||
_, _, err := billingexpr.RunExprWithRequest(`tier("image", fixed(0.04)) * image_count`, billingexpr.TokenParams{}, billingexpr.RequestInput{ImageCount: &count})
|
||||
require.ErrorContains(t, err, "image_count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedPriceRejectsInvalidLeavesIncludingUnselectedBranches(t *testing.T) {
|
||||
for _, expression := range []string{
|
||||
`true ? tier("ok", p) : tier("bad", fixed(-0.01))`,
|
||||
`tier("bad", fixed(p))`,
|
||||
`tier("bad", fixed(0.01 + 0.02))`,
|
||||
`tier("bad", fixed(1e308))`,
|
||||
`tier("bad", fixed(0.01) + p * 2)`,
|
||||
`tier("bad", p * fixed(0.01))`,
|
||||
`tier("a", fixed(0.01)) + tier("b", p * 2)`,
|
||||
`fixed(0.01)`,
|
||||
`tier("bad", fixed(0.01)) * p`,
|
||||
`tier("bad", fixed(0.01)) * (param("fast") == true ? -2 : 1)`,
|
||||
} {
|
||||
t.Run(expression, func(t *testing.T) {
|
||||
_, err := billingexpr.CompileFromCache(expression)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedPriceQuotaBoundariesAndUnsupportedTaskSnapshots(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, expression string
|
||||
group float64
|
||||
want int
|
||||
clamped bool
|
||||
}{
|
||||
{"normal group", `tier("request", fixed(0.01))`, 1.5, 7500, false},
|
||||
{"free group", `tier("request", fixed(0.01))`, 0, 0, false},
|
||||
{"explicit free price", `tier("free", fixed(0))`, 1, 0, false},
|
||||
{"oversized price saturates instead of crediting", `tier("huge", fixed(1e20))`, 1, math.MaxInt32, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
snap := &billingexpr.BillingSnapshot{ExprString: tc.expression, ExprHash: billingexpr.ExprHashString(tc.expression), GroupRatio: tc.group, QuotaPerUnit: 500000}
|
||||
result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, result.ActualQuotaAfterGroup)
|
||||
assert.Equal(t, billingexpr.BillingUnitRequest, result.BillingUnit)
|
||||
assert.Equal(t, tc.clamped, result.Clamp != nil)
|
||||
_, err = billingexpr.QuotaRoundStrict(result.ActualQuotaBeforeGroup * tc.group)
|
||||
if tc.clamped {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
snap.TaskUsageBilling = true
|
||||
_, err = billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{})
|
||||
require.ErrorContains(t, err, "task usage")
|
||||
})
|
||||
}
|
||||
const optimized = `true ? tier("tokens", p) : tier("request", fixed(0.01))`
|
||||
assert.True(t, billingexpr.UsesFixedPricing(optimized))
|
||||
_, trace, err := billingexpr.RunExpr(optimized, billingexpr.TokenParams{P: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, billingexpr.BillingUnitToken, trace.BillingUnit)
|
||||
assert.Nil(t, trace.FixedPrice)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude-style: fixed tiers, input > 200K changes both input & output price
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1063,3 +1152,47 @@ func BenchmarkExprRunCached(b *testing.B) {
|
||||
billingexpr.RunExpr(benchComplexExpr, params)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared fixtures protect the frontend simulator against drift from the real engine.
|
||||
func TestFrontendSimulationContract(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/frontend_simulation.json")
|
||||
require.NoError(t, err)
|
||||
var cases []struct {
|
||||
Name string
|
||||
Expression string
|
||||
Tokens billingexpr.TokenParams
|
||||
Body map[string]any
|
||||
Headers map[string]string
|
||||
Usage map[string]any
|
||||
Cost float64
|
||||
Tier string
|
||||
Multipliers []float64
|
||||
Matched []bool
|
||||
BillingUnit billingexpr.BillingUnit
|
||||
FixedPrice *float64
|
||||
ImageCount *int
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(data, &cases))
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
body, err := common.Marshal(tc.Body)
|
||||
require.NoError(t, err)
|
||||
cost, trace, err := billingexpr.RunExprWithRequest(tc.Expression, tc.Tokens, billingexpr.RequestInput{Body: body, Headers: tc.Headers, Usage: tc.Usage, ImageCount: tc.ImageCount})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, tc.Cost, cost, 1e-9)
|
||||
assert.Equal(t, tc.Tier, trace.MatchedTier)
|
||||
if tc.ImageCount != nil {
|
||||
assert.Equal(t, tc.ImageCount, trace.ImageCount)
|
||||
}
|
||||
if tc.BillingUnit != "" {
|
||||
assert.Equal(t, tc.BillingUnit, trace.BillingUnit)
|
||||
assert.Equal(t, tc.FixedPrice, trace.FixedPrice)
|
||||
}
|
||||
require.Len(t, trace.RequestRules, len(tc.Multipliers))
|
||||
for i, rule := range trace.RequestRules {
|
||||
assert.Equal(t, tc.Multipliers[i], rule.Multiplier)
|
||||
assert.Equal(t, tc.Matched[i], rule.Matched)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+53
-28
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/ast"
|
||||
"github.com/expr-lang/expr/parser"
|
||||
"github.com/expr-lang/expr/vm"
|
||||
)
|
||||
|
||||
@@ -114,6 +115,7 @@ type cachedEntry struct {
|
||||
usedUsageKeys map[string]bool
|
||||
requestRules []RequestRuleTrace
|
||||
version int
|
||||
fixedPricing bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -123,33 +125,36 @@ var (
|
||||
|
||||
// compileEnvPrototypeV1 is the v1 type-checking prototype used at compile time.
|
||||
var compileEnvPrototypeV1 = map[string]any{
|
||||
"p": float64(0),
|
||||
"c": float64(0),
|
||||
"len": float64(0),
|
||||
"cr": float64(0),
|
||||
"cc": float64(0),
|
||||
"cc1h": float64(0),
|
||||
"img": float64(0),
|
||||
"img_o": float64(0),
|
||||
"ai": float64(0),
|
||||
"ao": float64(0),
|
||||
"tier": func(string, float64) float64 { return 0 },
|
||||
"_trace": func(int, bool, float64) float64 { return 1 },
|
||||
"_trace_int": func(int, bool, int) int { return 1 },
|
||||
"header": func(string) string { return "" },
|
||||
"param": func(string) any { return nil },
|
||||
"u": func(string) any { return nil },
|
||||
"has": func(any, string) bool { return false },
|
||||
"hour": func(string) int { return 0 },
|
||||
"minute": func(string) int { return 0 },
|
||||
"weekday": func(string) int { return 0 },
|
||||
"month": func(string) int { return 0 },
|
||||
"day": func(string) int { return 0 },
|
||||
"max": math.Max,
|
||||
"min": math.Min,
|
||||
"abs": math.Abs,
|
||||
"ceil": math.Ceil,
|
||||
"floor": math.Floor,
|
||||
"image_count": float64(1),
|
||||
"p": float64(0),
|
||||
"c": float64(0),
|
||||
"len": float64(0),
|
||||
"cr": float64(0),
|
||||
"cc": float64(0),
|
||||
"cc1h": float64(0),
|
||||
"img": float64(0),
|
||||
"img_cr": float64(0),
|
||||
"img_o": float64(0),
|
||||
"ai": float64(0),
|
||||
"ao": float64(0),
|
||||
"tier": func(string, float64) float64 { return 0 },
|
||||
"fixed": func(float64) float64 { return 0 },
|
||||
"_trace": func(int, bool, float64) float64 { return 1 },
|
||||
"_trace_int": func(int, bool, int) int { return 1 },
|
||||
"header": func(string) string { return "" },
|
||||
"param": func(string) any { return nil },
|
||||
"u": func(string) any { return nil },
|
||||
"has": func(any, string) bool { return false },
|
||||
"hour": func(string) int { return 0 },
|
||||
"minute": func(string) int { return 0 },
|
||||
"weekday": func(string) int { return 0 },
|
||||
"month": func(string) int { return 0 },
|
||||
"day": func(string) int { return 0 },
|
||||
"max": math.Max,
|
||||
"min": math.Min,
|
||||
"abs": math.Abs,
|
||||
"ceil": math.Ceil,
|
||||
"floor": math.Floor,
|
||||
}
|
||||
|
||||
func getCompileEnv(version int) map[string]any {
|
||||
@@ -188,6 +193,21 @@ func compileEntryFromCacheByHash(exprStr, hash string) (*cachedEntry, error) {
|
||||
cacheMu.RUnlock()
|
||||
|
||||
version, body := ParseExprVersion(exprStr)
|
||||
// Validate before optimization so unreachable fixed-price branches cannot
|
||||
// bypass validation or a host's unsupported-protocol checks.
|
||||
tree, err := parser.Parse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expr compile error: %w", err)
|
||||
}
|
||||
fixedPricing := ast.Find(tree.Node, func(node ast.Node) bool {
|
||||
identifier, ok := node.(*ast.IdentifierNode)
|
||||
return ok && identifier.Value == "fixed"
|
||||
}) != nil
|
||||
if fixedPricing {
|
||||
if err := validateFixedPricingTree(tree.Node); err != nil {
|
||||
return nil, fmt.Errorf("expr compile error: %w", err)
|
||||
}
|
||||
}
|
||||
patcher := &requestRulePatcher{}
|
||||
prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.Patch(patcher), expr.AsFloat64())
|
||||
if patcher.restrictedIdentifier != "" {
|
||||
@@ -203,6 +223,7 @@ func compileEntryFromCacheByHash(exprStr, hash string) (*cachedEntry, error) {
|
||||
usedUsageKeys: extractUsedUsageKeys(prog),
|
||||
requestRules: patcher.requestRules,
|
||||
version: version,
|
||||
fixedPricing: fixedPricing,
|
||||
}
|
||||
cacheMu.Lock()
|
||||
if len(cache) >= maxCacheSize {
|
||||
@@ -271,10 +292,14 @@ func extractUsedUsageKeys(prog *vm.Program) map[string]bool {
|
||||
// UsedVars returns the set of identifier names referenced by an expression.
|
||||
// The result is cached alongside the compiled program. Returns nil for empty input.
|
||||
func UsedVars(exprStr string) map[string]bool {
|
||||
return UsedVarsByHash(exprStr, ExprHashString(exprStr))
|
||||
}
|
||||
|
||||
// UsedVarsByHash reuses the digest captured by the host's billing snapshot.
|
||||
func UsedVarsByHash(exprStr, hash string) map[string]bool {
|
||||
if exprStr == "" {
|
||||
return nil
|
||||
}
|
||||
hash := ExprHashString(exprStr)
|
||||
cacheMu.RLock()
|
||||
if entry, ok := cache[hash]; ok {
|
||||
cacheMu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package billingexpr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"github.com/expr-lang/expr/ast"
|
||||
"github.com/expr-lang/expr/parser"
|
||||
)
|
||||
|
||||
// DetectionCostBound proves a conservative cost ceiling rather than sampling
|
||||
// a few token counts. Both conditional branches are bounded, including prices
|
||||
// selected by time/request rules. Unsupported expressions fail closed.
|
||||
func DetectionCostBound(expression string, input, output float64) (float64, error) {
|
||||
_, body := ParseExprVersion(expression)
|
||||
tree, err := parser.Parse(body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var bound func(ast.Node) (float64, error)
|
||||
unsupported := errors.New("detection pricing cannot be safely bounded")
|
||||
bound = func(node ast.Node) (float64, error) {
|
||||
switch n := node.(type) {
|
||||
case *ast.IntegerNode:
|
||||
if n.Value >= 0 {
|
||||
return float64(n.Value), nil
|
||||
}
|
||||
case *ast.FloatNode:
|
||||
if n.Value >= 0 {
|
||||
return n.Value, nil
|
||||
}
|
||||
case *ast.IdentifierNode:
|
||||
switch n.Value {
|
||||
case "p", "len", "cr", "cc", "cc1h":
|
||||
return input, nil
|
||||
case "c":
|
||||
return output, nil
|
||||
case "img", "img_cr", "img_o", "ai", "ao":
|
||||
return 0, nil
|
||||
case "image_count":
|
||||
return 1, nil
|
||||
}
|
||||
case *ast.BinaryNode:
|
||||
a, e := bound(n.Left)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
b, e := bound(n.Right)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
switch n.Operator {
|
||||
case "+":
|
||||
return a + b, nil
|
||||
case "*":
|
||||
return a * b, nil
|
||||
case "/":
|
||||
switch n.Right.(type) {
|
||||
case *ast.IntegerNode, *ast.FloatNode:
|
||||
if b > 0 {
|
||||
return a / b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.ConditionalNode:
|
||||
a, e := bound(n.Exp1)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
b, e := bound(n.Exp2)
|
||||
return max(a, b), e
|
||||
case *ast.CallNode:
|
||||
name, ok := n.Callee.(*ast.IdentifierNode)
|
||||
if !ok {
|
||||
return 0, unsupported
|
||||
}
|
||||
if name.Value == "tier" && len(n.Arguments) == 2 {
|
||||
return bound(n.Arguments[1])
|
||||
}
|
||||
if name.Value == "fixed" && len(n.Arguments) == 1 {
|
||||
v, e := bound(n.Arguments[0])
|
||||
return v * 1_000_000, e
|
||||
}
|
||||
if name.Value == "ceil" && len(n.Arguments) == 1 {
|
||||
v, e := bound(n.Arguments[0])
|
||||
return math.Ceil(v), e
|
||||
}
|
||||
if name.Value == "floor" && len(n.Arguments) == 1 {
|
||||
v, e := bound(n.Arguments[0])
|
||||
return math.Floor(v), e
|
||||
}
|
||||
if name.Value == "min" || name.Value == "max" {
|
||||
v := float64(0)
|
||||
for _, arg := range n.Arguments {
|
||||
x, e := bound(arg)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
v = max(v, x)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return 0, unsupported
|
||||
}
|
||||
value, err := bound(tree.Node)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0, unsupported
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
+193
-3
@@ -12,7 +12,7 @@ The expression is the billing contract between the administrator and the system.
|
||||
|
||||
2. **Variables are opt-in** — `p` (prompt) and `c` (completion) are the base. Cache (`cr`, `cc`, `cc1h`), image (`img`), and audio (`ai`, `ao`) variables are optional. If omitted, those tokens are included in `p`/`c` and priced at their rate. The system automatically detects which variables the expression uses (via AST introspection) and adjusts token normalization accordingly.
|
||||
|
||||
3. **Prices are real prices** — Expression coefficients are actual $/1M tokens prices as published by providers. No ratio conversion, no `/2` convention. `p * 2.5` means $2.50 per 1M prompt tokens.
|
||||
3. **Prices are real prices** — Token coefficients are actual $/1M tokens prices as published by providers. `p * 2.5` means $2.50 per 1M prompt tokens; `fixed(0.01)` means $0.01 per request. No ratio conversion or `/2` convention is required.
|
||||
|
||||
4. **Upstream-agnostic** — The expression doesn't need to know whether the upstream API is OpenAI-format (prompt_tokens includes cache) or Claude-format (input_tokens excludes cache). The system normalizes token counts before evaluation based on the upstream response format.
|
||||
|
||||
@@ -36,6 +36,7 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are
|
||||
| `cc` | 缓存创建 token 数(Claude 5分钟 TTL / 通用) |
|
||||
| `cc1h` | 缓存创建 token 数 — 1小时 TTL(Claude 专用) |
|
||||
| `img` | 图片输入 token 数 |
|
||||
| `img_cr` | 图片缓存读取 token 数;只有表达式显式引用且上游提供有效缓存模态明细时才单独拆分 |
|
||||
| `ai` | 音频输入 token 数 |
|
||||
|
||||
**输出侧变量:**
|
||||
@@ -69,13 +70,97 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are
|
||||
| `p * 3 + c * 15` | 500 | 没用 `ao`,音频输出包含在 `c` 里按 $15 计费 |
|
||||
| `p * 3 + c * 15 + ao * 50` | 400 | 用了 `ao`,音频 100 从 `c` 中扣除按 $50 计费 |
|
||||
|
||||
> **注意:** 这个自动排除仅针对 GPT/OpenAI 格式的 API(prompt_tokens 包含所有子类别)。Claude 格式的 API(input_tokens 本身就只包含纯文本)不做任何减法。系统根据上游返回格式自动判断,表达式作者无需关心。
|
||||
#### 图片缓存与兼容性
|
||||
|
||||
OpenAI 兼容图片接口可报告
|
||||
`usage.input_tokens_details.cached_tokens_details.{text_tokens,image_tokens,audio_tokens}`。
|
||||
统一 DTO 保留各字段的缺失与显式零值;复制、用量合并及计费转换不得丢失这些信息。
|
||||
当前公开 Images API 文档未明确缓存模态字段,兼容结构的合成测试不能替代真实上游缓存回包验证。
|
||||
|
||||
当表达式使用 `img_cr` 且明细有效时,`img_cr` 从 `cr` 和 `img` 中同时移出,
|
||||
再按表达式引用的变量从 `p` 中扣除各互不重叠的类别。`len` 保持原始输入总长度。
|
||||
例如总输入 1000、图片输入 600、缓存总量 300、图片缓存 200:
|
||||
`p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30` 对应
|
||||
`p=300, cr=100, img=400, img_cr=200`;输出 100 时费用为 $0.008225。
|
||||
|
||||
缺少图片缓存明细时,`img_cr=0`,缓存总量继续进入 `cr`,沿用原有通用缓存计费,
|
||||
不推测图片缓存比例。不引用 `img_cr` 的既有表达式保持原有归一化行为。
|
||||
负数、超过总量或无法形成有效输入集合的明细会记录诊断并回退原有逻辑。
|
||||
Anthropic 的独立缓存计数语义不采用这一 OpenAI 图片拆分。
|
||||
|
||||
图片缓存表达式的消费日志保存 `image_cache_tokens` 与 `billing_tokens`,后者由结算结果携带,
|
||||
通过公共日志注入记录实际参与结算的计价用量,覆盖文本、audio 和 realtime 入口。
|
||||
求值失败回退预扣时不伪造实际用量;固定价格和未引用 `img_cr` 的表达式不新增这些字段。
|
||||
文本/图片日志的 `cache_tokens` 继续保留上游缓存总量。Images 的 `output_tokens` 是图片输出,
|
||||
内置图片价格使用 `c`,无需上游额外报告 `output_tokens_details.image_tokens`。
|
||||
|
||||
### 旧定价转换
|
||||
|
||||
旧倍率和旧按次模式仍可运行,管理界面标记为弃用,新建定价默认使用表达式。
|
||||
`POST /api/option/model_pricing/convert` 接收 `{model_name, pricing}`,其中 `pricing`
|
||||
为完整的旧定价草稿,缺少的键继承运行时默认值。接口返回表达式及本次转换使用的生效定价快照,或具体不支持原因,不写数据库。
|
||||
管理界面先用该快照显示左右对照预览,确认后才更新草稿;取消不改变原草稿。
|
||||
保存仍使用现有 `PATCH /api/option/model_pricing` 及 `expected_version` 冲突检查。
|
||||
|
||||
倍率转换以 `ModelRatio * 1000000 / QuotaPerUnit` 得到 USD/百万 tokens 的基础单价,
|
||||
保留输出、缓存读取、适用的缓存创建和图片输入的生效倍率及显式零值。
|
||||
常规按次价格转换为 `tier("request", fixed(price))`。旧价格字段继续保存,供切回旧模式使用。
|
||||
转换后的预扣及舍入采用表达式规则,不保证旧模式每笔舍入结果完全一致。
|
||||
迁移、已保存价格展示与草稿价格预览共用生效价格解析:输出使用模型族的硬编码兜底/强制倍率;
|
||||
缺少配置的缓存读取、缓存创建、图片输入分别使用运行时的 1、1.25、1 倍。
|
||||
图片倍率为 1 时,转换不生成独立 `img` 项,图片输入继续包含在 `p` 中按相同价格计费;
|
||||
图片倍率为其他值(包括显式 0)时才生成 `img` 项。
|
||||
缓存读取倍率为 1 时在价格预览中合并为输入价格;没有独立图片输入或缓存写入项时,转换同时省略 `cr`。
|
||||
存在这些独立项目时,缓存计数可能交叠,输入余量的零下限使直接合并不再等价,因此保留原 `cr` 计算项而不单列同价价格。
|
||||
基础输入价为 0 时可直接省略倍率为 1 的 `cr`;其他缓存读取倍率(包括显式 0)保留独立项。
|
||||
省略 `cr` 时,Anthropic 用量中单独报告的缓存读取数会加回 `p`,而 `len` 保持完整上下文长度。
|
||||
缓存创建按对外计费名和原始配置处理:名称包含 `claude`(不区分大小写)时保留 `cc` 和 `cc1h`,
|
||||
其中 1h 价格沿用旧引擎的 `CreateCacheRatio * 6 / 3.75`;其他名称仅在草稿已配置 `CreateCacheRatio`
|
||||
时生成通用 `cc`(包括显式 0),不生成 `cc1h`。没有配置的通用 1.25 倍兜底不产生新计费项。
|
||||
不按渠道映射或模型白名单扩展缓存类型,也不要求管理员另选类型。
|
||||
定价快照条目、草稿预览响应和转换结果附带只读 `cache_write_mode`(`none` / `standard` / `claude_ttl`),
|
||||
用于统一展示。`POST /api/option/model_pricing/preview` 的响应统一为
|
||||
`{success, message, data: {effective, cache_write_mode, billing_details}}`,调用者从 `data.effective`
|
||||
读取生效价格;转换接口和快照条目的 JSON 结构不变。元信息不保存、不参与表达式执行。
|
||||
未设置与显式 0 必须区分。草稿关闭某项覆盖后按保存后的回退规则解析,不能继续读取运行进程中尚未被保存替换的旧值。
|
||||
图片和普通音频规则均可自动迁移,具体规则如下;任务插件、视频、Realtime,以及依赖上游 cost
|
||||
反推缓存写入用量的 OpenRouter Claude 分支仍返回具体原因,不按渠道类型或未知端点一概拦截。
|
||||
|
||||
Gemini 音频输入使用旧结算实际采用的美元单价生成 `ai`,不乘普通输入倍率,因此普通输入价为零时
|
||||
仍保留独立音频费用。普通音频的 `ai` 单价为基础输入价乘 AudioRatio,`ao` 再乘 AudioCompletionRatio,
|
||||
缺省倍率按 1 处理,显式零保留。普通音频结算不使用缓存/图片倍率;模型同时配置这些价格时,
|
||||
转换生成音频请求与纯文本请求两个分支。音频分支使用 `max(len - ai, 0)` 计量普通输入,避免纯文本
|
||||
分支中引用的缓存变量改变音频费用。Gemini 文本结算仍保留缓存/音频交叠后的输入余量零下限。
|
||||
只读 `billing_details` 为预览提供实际音频单价、图片数量和请求倍率规则,不成为新的持久化价格来源。
|
||||
同一计费名在 Gemini/兼容音频入口存在相互冲突的单价,或活动图片渠道采用不同请求倍率时,
|
||||
转换返回该具体冲突,不能静默选择其中一条规则覆盖其他入口。
|
||||
|
||||
图片按次价格转换为 `tier("image", fixed(price)) * image_count`,再追加旧尺寸、质量及适用的
|
||||
prompt_extend 条件倍率。DALL·E 尺寸/质量规则按原始请求模型名生成,模型映射不会额外引入这些规则。
|
||||
OpenAI 已于 2026-05-12 下线 DALL·E 2/3;其校验、默认值和倍率保留在独立 legacy 文件,
|
||||
仅用于历史配置及兼容上游。转换从 DTO 的旧计费逻辑获取倍率,不维护第二份价格表。
|
||||
图片 token 定价不额外乘数量。所有金额和倍率固化在表达式中,不再叠加旧 OtherRatios。
|
||||
|
||||
### Image Quantity
|
||||
|
||||
`image_count` 是独立于 token 的计费数量。省略上下文时默认 1;提供的数量必须是 1 到
|
||||
`dto.MaxImageN`(128)的整数。固定价格语法允许它作为乘数,仍禁止用 token 乘固定价格。
|
||||
图片入口只解析一次 provider 计费标量并随请求携带。Ali 遵循 `parameters.n → n → 1` 的优先级:
|
||||
缺失或 `null` 的嵌套数量回退顶层,显式 `parameters.n=0` 返回 400;顶层 `n=0` 仍兼容为 1。
|
||||
Ali JSON 和 multipart 请求都明确发送该生效数量。每次渠道重试或参数覆盖之后,再校验最终上游数量并在发送前补足预留,
|
||||
图片钱包预留使用原子余额检查,不能通过增加数量形成欠费后继续提交。
|
||||
`estimated_image_count` 保存本次发送前的数量;JSON/multipart 图片参数上下文只保留计费需要的标量,不保留图片或提示词内容。
|
||||
结算使用独立的实际数量,不修改被冻结的 `param("n")`。非法 Ali usage 数量记录诊断,并回退有效图片列表数量;缺少有效实际数量时保持发送数量。正常结束
|
||||
的 SSE 可以减少数量,客户端提前断开不能减少应收数量。最终消费日志记录 `image_count`。
|
||||
|
||||
> **注意:** 自动扣除针对 GPT/OpenAI 格式的 API(prompt_tokens 包含子类别)。Claude 格式的 API 不重复扣除缓存;未独立计价的缓存读取加回输入。系统根据上游返回格式自动处理。
|
||||
|
||||
### Built-in Functions
|
||||
|
||||
| Function | Signature | Purpose |
|
||||
|----------|-----------|---------|
|
||||
| `tier` | `tier(name, value) → float64` | Records which pricing tier matched; must wrap the cost expression |
|
||||
| `fixed` | `fixed(amount) → float64` | A USD price per request, used only as the complete price in `tier(name, fixed(amount))` |
|
||||
| `param` | `param(path) → any` | Reads a JSON path from the request body (uses gjson) |
|
||||
| `header` | `header(key) → string` | Reads a request header value |
|
||||
| `has` | `has(source, substr) → bool` | Substring check |
|
||||
@@ -96,6 +181,11 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are
|
||||
# Simple flat pricing
|
||||
tier("base", p * 2.5 + c * 15 + cr * 0.25)
|
||||
|
||||
# Conditional per-request pricing, with token pricing for the fallback
|
||||
len <= 32000
|
||||
? tier("short", fixed(0.01))
|
||||
: tier("long", p * 2 + c * 8)
|
||||
|
||||
# Multi-tier (Claude Sonnet style) — use len for tier conditions
|
||||
len <= 200000
|
||||
? tier("standard", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6)
|
||||
@@ -108,6 +198,40 @@ tier("base", p * 2 + c * 8 + img * 2.5)
|
||||
tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)
|
||||
```
|
||||
|
||||
### Fixed Request Prices
|
||||
|
||||
`tier("request", fixed(0.01))` replaces all token charges in the selected leaf
|
||||
with a $0.01 base price for one successful HTTP/SSE request. Other leaves may
|
||||
still use token pricing. Group ratios and request multipliers continue to apply;
|
||||
existing tool surcharges are calculated separately and added as before. A stream
|
||||
does not incur a fixed fee per chunk. Realtime and task usage expressions reject
|
||||
`fixed()` before upstream submission or reservation.
|
||||
|
||||
The amount must be a finite, non-negative numeric literal whose v1 scaled value
|
||||
is finite. Explicit `fixed(0)` is valid and stays free. Expressions using `fixed`
|
||||
must consist of a conditional pricing tree and the standard request multiplier
|
||||
factors described below, with each leaf wrapped in `tier()`. Adding fixed and
|
||||
token charges in one leaf, adding multiple tiers together, or multiplying a
|
||||
fixed price by tokens is rejected. Validation examines the original AST,
|
||||
including branches that optimization or short-circuiting would skip. Existing
|
||||
expressions without `fixed()` retain their original grammar and behavior.
|
||||
|
||||
`fixed(amount)` returns `amount * 1,000,000` internally, preserving v1's existing
|
||||
quota conversion and rounding. Pre-consume matches conditions using estimates;
|
||||
settlement reevaluates against actual or existing locally estimated usage and
|
||||
reconciles any change of branch through the normal billing session. Missing
|
||||
upstream usage retains the existing estimation path; an actual request-priced
|
||||
branch can charge even with zero tokens. Failures retain the normal refund
|
||||
policy. Settlement evaluation errors retain the reservation and its estimated
|
||||
billing unit.
|
||||
|
||||
Evaluation results expose `billing_unit` (`token` or `request`) and, for a
|
||||
request-priced leaf, `fixed_price` in USD before multipliers. Pre-consume snapshots
|
||||
carry `estimated_billing_unit` and `estimated_fixed_price`; consume logs append
|
||||
the actual values to `other`. An explicit zero price is present in these fields.
|
||||
Older snapshots and logs need no migration. The expression remains the sole
|
||||
pricing configuration.
|
||||
|
||||
### Request Rules (appended after `|||`)
|
||||
|
||||
Request-conditional multipliers are appended to the expression after a `|||` separator:
|
||||
@@ -149,6 +273,72 @@ The usage-log UI treats `request_rules` as the authoritative rule list and rende
|
||||
Task plugins can expose validated, provider-specific billing facts through
|
||||
`meta.usageSchema`. Expressions read those facts with `u("key")`. A literal key
|
||||
must be declared by the plugin schema before the expression can be saved.
|
||||
|
||||
Multiple plugins may serve the same model with different usage schemas. The
|
||||
executing channel identifies the plugin; clients do not select a billing
|
||||
provider. Task expression resolution uses this order:
|
||||
|
||||
1. `billing_setting.plugin_billing_expr["<pluginKey>::<clientModel>"]`
|
||||
2. The same plugin's override for the final mapped model, when present
|
||||
3. The client model's expression when its billing mode is `tiered_expr`
|
||||
4. The final mapped model's expression when the client model has no expression
|
||||
billing mode
|
||||
|
||||
Plugin overrides always use task-usage semantics, independently of the model's
|
||||
billing mode. The flat override map is a separate option and never contributes
|
||||
composite keys to model names or model-price synchronization exports. Existing
|
||||
prices are not rewritten. The billing snapshot freezes the selected expression;
|
||||
completion uses that snapshot even if provider pricing later changes.
|
||||
|
||||
A model-level expression save smoke-tests each candidate's selected schema,
|
||||
skipping candidates with their own override in the same complete draft.
|
||||
Provider expression saves validate the plugin/model binding and that plugin's
|
||||
schema; errors identify both model and plugin. For a shared model, an effective
|
||||
expression referencing an undeclared literal `u()` key is unconfigured for that
|
||||
provider, including references in branches skipped by the current request.
|
||||
Submission rejects it with 400 `model_price_error`; administration and public
|
||||
pricing expose the unconfigured state. Public model-level metadata remains the
|
||||
first plugin's view in ascending key order, with provider variants added
|
||||
separately.
|
||||
|
||||
Saved provider overrides remain visible when only one provider remains.
|
||||
Administration exposes unavailable plugin/model bindings as `stale` and permits
|
||||
preserving or removing their existing expressions. Changes to those expressions
|
||||
still require a valid binding; writes compare against the locked database
|
||||
snapshot rather than the process cache. No overrides are silently pruned.
|
||||
Public provider variants include their effective `billing_mode` so inherited
|
||||
ratio/per-call pricing is distinct from an unconfigured usage expression.
|
||||
|
||||
Plugins serving different model families can optionally declare
|
||||
`meta.usageProfiles: [{models: ["image-model"], schema: {...}, examples: [...]}]`.
|
||||
Each profile provides the complete usage schema and optional display examples
|
||||
for its declared models. It replaces, rather than merges with, the plugin's
|
||||
default `usageSchema` and `usageExamples`. A model may belong to only one
|
||||
profile, and profile model names must use the spelling declared in `meta.models`.
|
||||
An empty profile schema is allowed; a missing or null schema is not. Token-unit
|
||||
profiles require their own examples, using the same validation as the defaults.
|
||||
|
||||
The host resolves model aliases before selecting usage metadata. Model-level
|
||||
pricing and expression-save APIs use the declared target; an ambiguous alias
|
||||
with multiple targets in the same plugin retains the plugin defaults. Request
|
||||
and usage validation use the executing plugin and final upstream model. Polling
|
||||
selects metadata from each saved task's model, including in mixed-model batches.
|
||||
Unmatched models and plugins without profiles retain the plugin defaults.
|
||||
|
||||
Profiles do not introduce a request-body whitelist or remove legacy multiplier
|
||||
extensions. Numeric hook facts retain a consistent floating-point representation
|
||||
for expression evaluation, including fields outside the selected profile.
|
||||
Saving an expression checks literal usage keys against the selected schema;
|
||||
already stored expressions are not migrated. Single-plugin submissions retain
|
||||
the existing key-check behavior; shared models enforce the compatibility rule
|
||||
above. Existing runtime error and quota safety checks still apply.
|
||||
Completion continues to evaluate the frozen expression after overlaying measured
|
||||
facts; no schema snapshot or database migration is introduced.
|
||||
|
||||
This is an optional addition to plugin API version 1. New hosts accept existing
|
||||
plugins unchanged. Older hosts reject the new metadata field, so upgrade the
|
||||
host before installing a plugin that declares profiles.
|
||||
|
||||
Numeric facts are finite, non-negative values in the declared canonical unit
|
||||
(`second`, `count`, `token`, or `credit`); enum facts are exact strings from the
|
||||
declared value list. The schema description is display-only metadata and never
|
||||
@@ -273,7 +463,7 @@ After the upstream response returns with actual token usage:
|
||||
1. `BuildTieredTokenParams(usage, isClaudeUsageSemantic, usedVars)`:
|
||||
- Reads actual token counts from `dto.Usage`
|
||||
- For GPT-format APIs (prompt_tokens includes everything): subtracts sub-categories from P/C **only when** the expression uses their variables (detected via AST introspection of the compiled expression)
|
||||
- For Claude-format APIs (input_tokens is text-only): no adjustment needed
|
||||
- For Claude-format APIs: cache reads omitted from the expression are added to input; separately priced cache remains separate
|
||||
|
||||
2. `TryTieredSettle(relayInfo, params)`:
|
||||
- Uses the captured `BillingSnapshot`, whose group-dependent fields have been refreshed from the final selected group
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package billingexpr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/expr-lang/expr/ast"
|
||||
)
|
||||
|
||||
// UsesFixedPricing includes unselected branches, even when compilation later
|
||||
// optimizes them away. Hosts use it to reject unsupported billing entrances.
|
||||
func UsesFixedPricing(expression string) bool {
|
||||
return UsesFixedPricingByHash(expression, ExprHashString(expression))
|
||||
}
|
||||
|
||||
// UsesFixedPricingByHash avoids hashing an expression already in a snapshot.
|
||||
func UsesFixedPricingByHash(expression, hash string) bool {
|
||||
entry, err := compileEntryFromCacheByHash(expression, hash)
|
||||
return err == nil && entry.fixedPricing
|
||||
}
|
||||
|
||||
func containsPricingMarker(node ast.Node) bool {
|
||||
return ast.Find(node, func(part ast.Node) bool {
|
||||
identifier, ok := part.(*ast.IdentifierNode)
|
||||
return ok && (identifier.Value == "tier" || identifier.Value == "fixed")
|
||||
}) != nil
|
||||
}
|
||||
|
||||
func isRequestPriceMultiplier(node ast.Node) bool {
|
||||
if identifier, ok := node.(*ast.IdentifierNode); ok && identifier.Value == "image_count" {
|
||||
return true
|
||||
}
|
||||
conditional, ok := node.(*ast.ConditionalNode)
|
||||
if !ok || !usesRequestProbe(conditional.Cond) || containsPricingMarker(conditional.Cond) {
|
||||
return false
|
||||
}
|
||||
multiplier, multiplierOK := requestRuleNumber(conditional.Exp1)
|
||||
fallback, fallbackOK := requestRuleNumber(conditional.Exp2)
|
||||
return multiplierOK && fallbackOK && fallback == 1 && multiplier >= 0 && !math.IsNaN(multiplier) && !math.IsInf(multiplier, 0)
|
||||
}
|
||||
|
||||
// validateFixedPricingTree enforces one pricing leaf per execution. Without
|
||||
// this invariant, adding two tiers or multiplying a fixed price by tokens
|
||||
// would make both the request charge and its billing-unit trace ambiguous.
|
||||
func validateFixedPricingTree(node ast.Node) error {
|
||||
switch part := node.(type) {
|
||||
case *ast.ConditionalNode:
|
||||
if containsPricingMarker(part.Cond) {
|
||||
break
|
||||
}
|
||||
if err := validateFixedPricingTree(part.Exp1); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFixedPricingTree(part.Exp2)
|
||||
case *ast.BinaryNode:
|
||||
if part.Operator != "*" {
|
||||
break
|
||||
}
|
||||
if isRequestPriceMultiplier(part.Right) {
|
||||
return validateFixedPricingTree(part.Left)
|
||||
}
|
||||
if isRequestPriceMultiplier(part.Left) {
|
||||
return validateFixedPricingTree(part.Right)
|
||||
}
|
||||
case *ast.CallNode:
|
||||
callee, ok := part.Callee.(*ast.IdentifierNode)
|
||||
if !ok || callee.Value != "tier" || len(part.Arguments) != 2 || containsPricingMarker(part.Arguments[0]) {
|
||||
break
|
||||
}
|
||||
price := part.Arguments[1]
|
||||
fixed, ok := price.(*ast.CallNode)
|
||||
if ok {
|
||||
function, direct := fixed.Callee.(*ast.IdentifierNode)
|
||||
if direct && function.Value == "fixed" && len(fixed.Arguments) == 1 {
|
||||
amount, literal := requestRuleNumber(fixed.Arguments[0])
|
||||
if literal && amount >= 0 && !math.IsNaN(amount) && !math.IsInf(amount*1_000_000, 0) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("fixed price must be a finite, non-negative numeric literal with a finite v1 value")
|
||||
}
|
||||
}
|
||||
if !containsPricingMarker(price) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("fixed pricing requires tier(name, fixed(amount)) leaves, conditional tiers and request multipliers; token and fixed charges cannot be combined in one leaf")
|
||||
}
|
||||
+48
-18
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/vm"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -30,7 +32,7 @@ func RunExprWithRequest(exprStr string, params TokenParams, request RequestInput
|
||||
if err != nil {
|
||||
return 0, TraceResult{}, err
|
||||
}
|
||||
return runProgram(entry.prog, entry.requestRules, params, request)
|
||||
return runProgram(entry.prog, entry.requestRules, entry.usedVars, params, request)
|
||||
}
|
||||
|
||||
// RunExprByHash is like RunExpr but accepts a pre-computed hash for the cache
|
||||
@@ -45,31 +47,59 @@ func RunExprByHashWithRequest(exprStr, hash string, params TokenParams, request
|
||||
if err != nil {
|
||||
return 0, TraceResult{}, err
|
||||
}
|
||||
return runProgram(entry.prog, entry.requestRules, params, request)
|
||||
return runProgram(entry.prog, entry.requestRules, entry.usedVars, params, request)
|
||||
}
|
||||
|
||||
func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, params TokenParams, request RequestInput) (float64, TraceResult, error) {
|
||||
func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, usedVars map[string]bool, params TokenParams, request RequestInput) (float64, TraceResult, error) {
|
||||
trace := TraceResult{
|
||||
BillingUnit: BillingUnitToken,
|
||||
RequestRules: append([]RequestRuleTrace(nil), requestRules...),
|
||||
}
|
||||
headers := normalizeHeaders(request.Headers)
|
||||
timeForRequest := func(tz string) time.Time {
|
||||
if request.At == nil {
|
||||
return timeInZone(tz)
|
||||
}
|
||||
loc, err := time.LoadLocation(strings.TrimSpace(tz))
|
||||
if err != nil {
|
||||
return request.At.UTC()
|
||||
}
|
||||
return request.At.In(loc)
|
||||
}
|
||||
imageCount := 1
|
||||
if usedVars["image_count"] {
|
||||
if request.ImageCount != nil {
|
||||
imageCount = *request.ImageCount
|
||||
}
|
||||
if imageCount < 1 || imageCount > dto.MaxImageN {
|
||||
return 0, trace, fmt.Errorf("image_count must be between 1 and %d", dto.MaxImageN)
|
||||
}
|
||||
trace.ImageCount = &imageCount
|
||||
}
|
||||
|
||||
env := map[string]any{
|
||||
"p": params.P,
|
||||
"c": params.C,
|
||||
"len": params.Len,
|
||||
"cr": params.CR,
|
||||
"cc": params.CC,
|
||||
"cc1h": params.CC1h,
|
||||
"img": params.Img,
|
||||
"img_o": params.ImgO,
|
||||
"ai": params.AI,
|
||||
"ao": params.AO,
|
||||
"image_count": float64(imageCount),
|
||||
"p": params.P,
|
||||
"c": params.C,
|
||||
"len": params.Len,
|
||||
"cr": params.CR,
|
||||
"cc": params.CC,
|
||||
"cc1h": params.CC1h,
|
||||
"img": params.Img,
|
||||
"img_cr": params.ImgCR,
|
||||
"img_o": params.ImgO,
|
||||
"ai": params.AI,
|
||||
"ao": params.AO,
|
||||
"tier": func(name string, value float64) float64 {
|
||||
trace.MatchedTier = name
|
||||
trace.Cost = value
|
||||
return value
|
||||
},
|
||||
"fixed": func(amount float64) float64 {
|
||||
trace.BillingUnit = BillingUnitRequest
|
||||
trace.FixedPrice = &amount
|
||||
return amount * 1_000_000
|
||||
},
|
||||
requestRuleTraceFunction: func(ruleIndex int, matched bool, multiplier float64) float64 {
|
||||
if matched && ruleIndex >= 0 && ruleIndex < len(trace.RequestRules) {
|
||||
trace.RequestRules[ruleIndex].Matched = true
|
||||
@@ -114,11 +144,11 @@ func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, params TokenP
|
||||
}
|
||||
return strings.Contains(fmt.Sprint(source), substr)
|
||||
},
|
||||
"hour": func(tz string) int { return timeInZone(tz).Hour() },
|
||||
"minute": func(tz string) int { return timeInZone(tz).Minute() },
|
||||
"weekday": func(tz string) int { return int(timeInZone(tz).Weekday()) },
|
||||
"month": func(tz string) int { return int(timeInZone(tz).Month()) },
|
||||
"day": func(tz string) int { return timeInZone(tz).Day() },
|
||||
"hour": func(tz string) int { return timeForRequest(tz).Hour() },
|
||||
"minute": func(tz string) int { return timeForRequest(tz).Minute() },
|
||||
"weekday": func(tz string) int { return int(timeForRequest(tz).Weekday()) },
|
||||
"month": func(tz string) int { return int(timeForRequest(tz).Month()) },
|
||||
"day": func(tz string) int { return timeForRequest(tz).Day() },
|
||||
"max": math.Max,
|
||||
"min": math.Min,
|
||||
"abs": math.Abs,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package billingexpr
|
||||
|
||||
import "github.com/QuantumNous/new-api/common"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// quotaConversion converts raw expression output to quota based on the
|
||||
// expression version. This is the central dispatch point for future versions
|
||||
@@ -22,6 +26,9 @@ func ComputeTieredQuota(snap *BillingSnapshot, params TokenParams) (TieredResult
|
||||
}
|
||||
|
||||
func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, request RequestInput) (TieredResult, error) {
|
||||
if snap.TaskUsageBilling && UsesFixedPricingByHash(snap.ExprString, snap.ExprHash) {
|
||||
return TieredResult{}, fmt.Errorf("fixed pricing is not supported for task usage expressions")
|
||||
}
|
||||
cost, trace, err := RunExprByHashWithRequest(snap.ExprString, snap.ExprHash, params, request)
|
||||
if err != nil {
|
||||
return TieredResult{}, err
|
||||
@@ -31,12 +38,19 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
|
||||
afterGroup, clamp := common.QuotaRoundChecked(quotaBeforeGroup * snap.GroupRatio)
|
||||
crossed := trace.MatchedTier != snap.EstimatedTier
|
||||
|
||||
return TieredResult{
|
||||
result := TieredResult{
|
||||
ImageCount: trace.ImageCount,
|
||||
BillingUnit: trace.BillingUnit,
|
||||
FixedPrice: trace.FixedPrice,
|
||||
ActualQuotaBeforeGroup: quotaBeforeGroup,
|
||||
ActualQuotaAfterGroup: afterGroup,
|
||||
MatchedTier: trace.MatchedTier,
|
||||
RequestRules: trace.RequestRules,
|
||||
CrossedTier: crossed,
|
||||
Clamp: clamp,
|
||||
}, nil
|
||||
}
|
||||
if trace.BillingUnit == BillingUnitToken && UsedVarsByHash(snap.ExprString, snap.ExprHash)["img_cr"] {
|
||||
result.BillingTokens = ¶ms
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
[
|
||||
{
|
||||
"name": "versioned tokens and cache",
|
||||
"expression": "v1:tier(\"base\", p * 2 + c * 8 + cr * 0.2)",
|
||||
"tokens": {
|
||||
"p": 900,
|
||||
"c": 100,
|
||||
"cr": 100
|
||||
},
|
||||
"cost": 2620,
|
||||
"tier": "base"
|
||||
},
|
||||
{
|
||||
"name": "nil false and zero",
|
||||
"expression": "param(\"missing\") == nil && param(\"zero\") == 0 && param(\"enabled\") == false ? tier(\"ok\", 3.5) : tier(\"bad\", 99)",
|
||||
"body": {
|
||||
"zero": 0,
|
||||
"enabled": false
|
||||
},
|
||||
"cost": 3.5,
|
||||
"tier": "ok"
|
||||
},
|
||||
{
|
||||
"name": "normalized headers and two rules",
|
||||
"expression": "tier(\"base\", p * 2) * (header(\" X-Priority \") == \"high\" ? 2 : 1) * (param(\"service_tier\") == \"flex\" ? 0.5 : 1)",
|
||||
"tokens": {
|
||||
"p": 100
|
||||
},
|
||||
"headers": {
|
||||
" X-Priority ": " high "
|
||||
},
|
||||
"body": {
|
||||
"service_tier": "flex"
|
||||
},
|
||||
"cost": 200,
|
||||
"tier": "base",
|
||||
"multipliers": [
|
||||
2,
|
||||
0.5
|
||||
],
|
||||
"matched": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "unmatched rule",
|
||||
"expression": "tier(\"base\", 10) * (param(\"priority\") == true ? 2 : 1)",
|
||||
"body": {},
|
||||
"cost": 10,
|
||||
"tier": "base",
|
||||
"multipliers": [
|
||||
2
|
||||
],
|
||||
"matched": [
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rules skipped by short circuit",
|
||||
"expression": "param(\"enabled\") == true ? tier(\"yes\", 10) * (header(\"tier\") == \"fast\" ? 3 : 1) : tier(\"no\", 2)",
|
||||
"body": {},
|
||||
"headers": {
|
||||
"tier": "fast"
|
||||
},
|
||||
"cost": 2,
|
||||
"tier": "no",
|
||||
"multipliers": [
|
||||
3
|
||||
],
|
||||
"matched": [
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "noncanonical factor still evaluates",
|
||||
"expression": "tier(\"base\", 10) * (param(\"priority\") == true ? 2 : 1.5)",
|
||||
"body": {},
|
||||
"cost": 15,
|
||||
"tier": "base"
|
||||
},
|
||||
{
|
||||
"name": "path escapes array indices and length",
|
||||
"expression": "param(\"a\\\\.b.items.0.price\") + param(\"a\\\\.b.items.#\")",
|
||||
"body": {
|
||||
"a.b": {
|
||||
"items": [
|
||||
{
|
||||
"price": 4
|
||||
},
|
||||
{
|
||||
"price": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"cost": 6,
|
||||
"tier": ""
|
||||
},
|
||||
{
|
||||
"name": "literal path to constructor",
|
||||
"expression": "param(\"constructor\") == nil ? 4 : 99",
|
||||
"body": {},
|
||||
"cost": 4,
|
||||
"tier": ""
|
||||
},
|
||||
{
|
||||
"name": "contains scalars and objects",
|
||||
"expression": "has(param(\"n\"), \"12\") && has(param(\"object\"), \"map[a:1 b:[true <nil>]]\") && !has(nil, \"x\") && !has(\"abc\", \"\") ? 8 : 99",
|
||||
"body": {
|
||||
"n": 123,
|
||||
"object": {
|
||||
"b": [
|
||||
true,
|
||||
null
|
||||
],
|
||||
"a": 1
|
||||
}
|
||||
},
|
||||
"cost": 8,
|
||||
"tier": ""
|
||||
},
|
||||
{
|
||||
"name": "math and numeric division",
|
||||
"expression": "min(10, max(2, 5)) + abs(-2) + ceil(1.1) + floor(2.9) + 5 / 2 + 7 % 3",
|
||||
"cost": 14.5,
|
||||
"tier": ""
|
||||
},
|
||||
{
|
||||
"name": "task token scale and request rule",
|
||||
"expression": "tier(\"base\", 0.2 + u(\"tokens\") * 10 / 1000000) * (header(\"tier\") == \"fast\" ? 2 : 1)",
|
||||
"usage": {
|
||||
"tokens": 500000
|
||||
},
|
||||
"headers": {
|
||||
"tier": "fast"
|
||||
},
|
||||
"cost": 10.4,
|
||||
"tier": "base",
|
||||
"multipliers": [
|
||||
2
|
||||
],
|
||||
"matched": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "task enum conditions",
|
||||
"expression": "u(\"mode\") == \"pro\" ? tier(\"pro\", u(\"seconds\") * 0.8) : tier(\"std\", u(\"seconds\") * 0.4)",
|
||||
"usage": {
|
||||
"mode": "pro",
|
||||
"seconds": 5
|
||||
},
|
||||
"cost": 4,
|
||||
"tier": "pro"
|
||||
},
|
||||
{
|
||||
"name": "scientific rates and parentheses",
|
||||
"expression": "(tier(\"base\", (p * 2e-3 + c * 1E+1)))",
|
||||
"tokens": {
|
||||
"p": 1000,
|
||||
"c": 2
|
||||
},
|
||||
"cost": 22,
|
||||
"tier": "base"
|
||||
},
|
||||
{
|
||||
"name": "fixed request with zero usage",
|
||||
"expression": "tier(\"request\", fixed(0.01))",
|
||||
"cost": 10000,
|
||||
"tier": "request",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0.01
|
||||
},
|
||||
{
|
||||
"name": "explicit free request",
|
||||
"expression": "tier(\"free\", fixed(0))",
|
||||
"tokens": {
|
||||
"p": 1000
|
||||
},
|
||||
"cost": 0,
|
||||
"tier": "free",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0
|
||||
},
|
||||
{
|
||||
"name": "mixed expression token fallback",
|
||||
"expression": "len <= 32000 ? tier(\"short\", fixed(0.01)) : tier(\"long\", p * 2 + c * 8)",
|
||||
"tokens": {
|
||||
"p": 32001,
|
||||
"c": 100,
|
||||
"len": 32001
|
||||
},
|
||||
"cost": 64802,
|
||||
"tier": "long",
|
||||
"billingUnit": "token"
|
||||
},
|
||||
{
|
||||
"name": "fixed request with multiplier",
|
||||
"expression": "(len <= 32000 ? tier(\"short\", fixed(0.01)) : tier(\"long\", p * 2 + c * 8)) * (param(\"fast\") == true ? 2 : 1)",
|
||||
"tokens": {
|
||||
"len": 1
|
||||
},
|
||||
"body": {
|
||||
"fast": true
|
||||
},
|
||||
"cost": 20000,
|
||||
"tier": "short",
|
||||
"multipliers": [
|
||||
2
|
||||
],
|
||||
"matched": [
|
||||
true
|
||||
],
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0.01
|
||||
},
|
||||
{
|
||||
"name": "fixed time condition",
|
||||
"expression": "hour(\"Asia/Shanghai\") >= 0 && hour(\"Asia/Shanghai\") < 24 ? tier(\"today\", fixed(0.02)) : tier(\"fallback\", p * 2)",
|
||||
"cost": 20000,
|
||||
"tier": "today",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0.02
|
||||
},
|
||||
{
|
||||
"name": "image count is distinct from requested n",
|
||||
"expression": "tier(\"image\", fixed(0.04)) * image_count",
|
||||
"imageCount": 2,
|
||||
"body": {
|
||||
"n": 3
|
||||
},
|
||||
"cost": 80000,
|
||||
"tier": "image",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0.04
|
||||
},
|
||||
{
|
||||
"name": "image count and quality adjustment",
|
||||
"expression": "tier(\"image\", fixed(0.04)) * image_count * (param(\"quality\") == \"hd\" ? 2 : 1)",
|
||||
"imageCount": 3,
|
||||
"body": {
|
||||
"quality": "hd"
|
||||
},
|
||||
"cost": 240000,
|
||||
"tier": "image",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0.04,
|
||||
"multipliers": [
|
||||
2
|
||||
],
|
||||
"matched": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "free image count maximum",
|
||||
"expression": "tier(\"image\", fixed(0)) * image_count",
|
||||
"imageCount": 128,
|
||||
"cost": 0,
|
||||
"tier": "image",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0
|
||||
},
|
||||
{
|
||||
"name": "default image quantity",
|
||||
"expression": "tier(\"image\", fixed(0.04)) * image_count",
|
||||
"cost": 40000,
|
||||
"tier": "image",
|
||||
"billingUnit": "request",
|
||||
"fixedPrice": 0.04
|
||||
},
|
||||
{
|
||||
"name": "audio pricing ignores text cache discounts",
|
||||
"expression": "(ai > 0 || ao > 0) ? tier(\"audio\", max(len - ai, 0) * 2 + c * 6 + ai * 4 + ao * 4) : tier(\"base\", p * 2 + c * 6 + cr * 0.2)",
|
||||
"tokens": {
|
||||
"len": 1000,
|
||||
"ai": 200,
|
||||
"ao": 50,
|
||||
"c": 50
|
||||
},
|
||||
"cost": 2900,
|
||||
"tier": "audio",
|
||||
"displayTiers": [
|
||||
{
|
||||
"label": "audio",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"ai": 4,
|
||||
"ao": 4
|
||||
},
|
||||
"conditionText": "Audio requests"
|
||||
},
|
||||
{
|
||||
"label": "base",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"cr": 0.2
|
||||
},
|
||||
"conditionText": "Text-only requests"
|
||||
}
|
||||
],
|
||||
"conversion": {
|
||||
"model_name": "contract-audio",
|
||||
"pricing": {
|
||||
"ModelRatio": 1,
|
||||
"CompletionRatio": 3,
|
||||
"CacheRatio": 0.1,
|
||||
"AudioRatio": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "text requests retain configured cache discount",
|
||||
"expression": "(ai > 0 || ao > 0) ? tier(\"audio\", max(len - ai, 0) * 2 + c * 6 + ai * 4 + ao * 4) : tier(\"base\", p * 2 + c * 6 + cr * 0.2)",
|
||||
"tokens": {
|
||||
"p": 600,
|
||||
"c": 100,
|
||||
"cr": 400,
|
||||
"ai": 0,
|
||||
"ao": 0
|
||||
},
|
||||
"cost": 1880,
|
||||
"tier": "base",
|
||||
"displayTiers": [
|
||||
{
|
||||
"label": "audio",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"ai": 4,
|
||||
"ao": 4
|
||||
},
|
||||
"conditionText": "Audio requests"
|
||||
},
|
||||
{
|
||||
"label": "base",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"cr": 0.2
|
||||
},
|
||||
"conditionText": "Text-only requests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "audio display survives redundant parentheses",
|
||||
"expression": "(ai > 0 || ao > 0) ? tier(\"audio\", max((len - ai), (0)) * 2 + c * 6 + ai * 4 + ao * 4) : tier(\"base\", p * 2 + c * 6 + cr * 0.2)",
|
||||
"tokens": {
|
||||
"len": 1000,
|
||||
"ai": 200,
|
||||
"ao": 50,
|
||||
"c": 50
|
||||
},
|
||||
"cost": 2900,
|
||||
"tier": "audio",
|
||||
"displayTiers": [
|
||||
{
|
||||
"label": "audio",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"ai": 4,
|
||||
"ao": 4
|
||||
},
|
||||
"conditionText": "Audio requests"
|
||||
},
|
||||
{
|
||||
"label": "base",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"cr": 0.2
|
||||
},
|
||||
"conditionText": "Text-only requests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "audio display survives commuted operands",
|
||||
"expression": "(ao > 0 || ai > 0) ? tier(\"audio\", 2 * max(0, len - ai) + 6 * c + 4 * ai + ao * 4) : tier(\"base\", p * 2 + 6 * c + cr * 0.2)",
|
||||
"tokens": {
|
||||
"len": 1000,
|
||||
"ai": 200,
|
||||
"ao": 50,
|
||||
"c": 50
|
||||
},
|
||||
"cost": 2900,
|
||||
"tier": "audio",
|
||||
"displayTiers": [
|
||||
{
|
||||
"label": "audio",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"ai": 4,
|
||||
"ao": 4
|
||||
},
|
||||
"conditionText": "Audio requests"
|
||||
},
|
||||
{
|
||||
"label": "base",
|
||||
"conditions": [],
|
||||
"prices": {
|
||||
"p": 2,
|
||||
"c": 6,
|
||||
"cr": 0.2
|
||||
},
|
||||
"conditionText": "Text-only requests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+36
-10
@@ -3,30 +3,36 @@ package billingexpr
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
type RequestInput struct {
|
||||
At *time.Time `json:"at,omitempty"`
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
Usage map[string]any
|
||||
// ImageCount is a validated billing quantity, separate from the frozen
|
||||
// request's n. Settlement can replace it with the actual returned count.
|
||||
ImageCount *int
|
||||
}
|
||||
|
||||
// TokenParams holds all token dimensions passed into an Expr evaluation.
|
||||
// Fields beyond P and C are optional — when absent they default to 0,
|
||||
// which means cache-unaware expressions keep working unchanged.
|
||||
type TokenParams struct {
|
||||
P float64 // prompt tokens (text) — auto-excludes sub-categories priced separately
|
||||
C float64 // completion tokens (text) — auto-excludes sub-categories priced separately
|
||||
Len float64 // total input context length for tier conditions (non-Claude: raw prompt_tokens; Claude: text + cache read + cache creation)
|
||||
CR float64 // cache read (hit) tokens
|
||||
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
|
||||
CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
|
||||
Img float64 // image input tokens
|
||||
ImgO float64 // image output tokens
|
||||
AI float64 // audio input tokens
|
||||
AO float64 // audio output tokens
|
||||
P float64 // prompt tokens (text) — auto-excludes sub-categories priced separately
|
||||
C float64 // completion tokens (text) — auto-excludes sub-categories priced separately
|
||||
Len float64 // total input context length for tier conditions (non-Claude: raw prompt_tokens; Claude: text + cache read + cache creation)
|
||||
CR float64 // cache read (hit) tokens
|
||||
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
|
||||
CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
|
||||
Img float64 // image input tokens
|
||||
ImgCR float64 // image cache read tokens, separated only when explicitly priced
|
||||
ImgO float64 // image output tokens
|
||||
AI float64 // audio input tokens
|
||||
AO float64 // audio output tokens
|
||||
}
|
||||
|
||||
// RequestRuleTrace describes one request-dependent multiplier detected at compile time.
|
||||
@@ -36,8 +42,18 @@ type RequestRuleTrace struct {
|
||||
Matched bool `json:"matched"`
|
||||
}
|
||||
|
||||
type BillingUnit string
|
||||
|
||||
const (
|
||||
BillingUnitToken BillingUnit = "token"
|
||||
BillingUnitRequest BillingUnit = "request"
|
||||
)
|
||||
|
||||
// TraceResult holds side-channel info captured while an expression runs.
|
||||
type TraceResult struct {
|
||||
ImageCount *int `json:"image_count,omitempty"`
|
||||
BillingUnit BillingUnit `json:"billing_unit"`
|
||||
FixedPrice *float64 `json:"fixed_price,omitempty"`
|
||||
MatchedTier string `json:"matched_tier"`
|
||||
RequestRules []RequestRuleTrace `json:"request_rules,omitempty"`
|
||||
Cost float64 `json:"cost"`
|
||||
@@ -48,6 +64,7 @@ type TraceResult struct {
|
||||
// auto-group retry and settlement. It is fully serializable and contains no
|
||||
// compiled program pointers.
|
||||
type BillingSnapshot struct {
|
||||
EstimatedImageCount *int `json:"estimated_image_count,omitempty"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
ModelName string `json:"model_name"`
|
||||
ExprString string `json:"expr_string"`
|
||||
@@ -58,6 +75,8 @@ type BillingSnapshot struct {
|
||||
EstimatedQuotaBeforeGroup float64 `json:"estimated_quota_before_group"`
|
||||
EstimatedQuotaAfterGroup int `json:"estimated_quota_after_group"`
|
||||
EstimatedTier string `json:"estimated_tier"`
|
||||
EstimatedBillingUnit BillingUnit `json:"estimated_billing_unit,omitempty"`
|
||||
EstimatedFixedPrice *float64 `json:"estimated_fixed_price,omitempty"`
|
||||
QuotaPerUnit float64 `json:"quota_per_unit"`
|
||||
ExprVersion int `json:"expr_version"`
|
||||
TaskUsageBilling bool `json:"task_usage_billing,omitempty"`
|
||||
@@ -66,6 +85,13 @@ type BillingSnapshot struct {
|
||||
|
||||
// TieredResult holds everything needed after running tiered settlement.
|
||||
type TieredResult struct {
|
||||
// BillingTokens records the actual normalized inputs for successful token
|
||||
// billing that explicitly references img_cr. Logs serialize these through
|
||||
// the shared injection path, not as additional snapshot state.
|
||||
BillingTokens *TokenParams `json:"-"`
|
||||
ImageCount *int `json:"image_count,omitempty"`
|
||||
BillingUnit BillingUnit `json:"billing_unit"`
|
||||
FixedPrice *float64 `json:"fixed_price,omitempty"`
|
||||
ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"`
|
||||
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
|
||||
MatchedTier string `json:"matched_tier"`
|
||||
|
||||
Reference in new issue
Block a user