2026-01-31 19:11:25 +08:00
|
|
|
|
"""
|
|
|
|
|
|
BillingRule 查找逻辑
|
|
|
|
|
|
|
|
|
|
|
|
查找顺序(与 .plans/humming-seeking-marble.md 一致):
|
2026-02-03 18:48:39 +08:00
|
|
|
|
1) 读取 GlobalModel/Model 价格配置 → 2) 使用代码内置计费模板生成规则(config-file mode)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
注意:
|
|
|
|
|
|
- CLI 在计费域等同于 chat:billing_rules.task_type 不含 "cli"
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
2026-02-03 18:48:39 +08:00
|
|
|
|
from typing import Any, Literal, Protocol
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
from src.config.settings import config
|
|
|
|
|
|
from src.models.database import GlobalModel, Model
|
|
|
|
|
|
from src.services.billing.cache import BillingCache
|
|
|
|
|
|
from src.services.billing.default_rules import DefaultBillingRuleGenerator, VirtualBillingRule
|
|
|
|
|
|
from src.services.billing.rule_templates import CodeBillingRuleTemplateService
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
TaskType = Literal["chat", "cli", "video", "image", "audio"]
|
2026-02-03 18:48:39 +08:00
|
|
|
|
BillingRuleScope = Literal["model", "global", "default"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BillingRuleLike(Protocol):
|
|
|
|
|
|
id: str
|
|
|
|
|
|
name: str
|
|
|
|
|
|
expression: str
|
|
|
|
|
|
variables: dict[str, Any]
|
|
|
|
|
|
dimension_mappings: dict[str, Any]
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def effective_rule_task_type(task_type: str) -> str:
|
|
|
|
|
|
"""CLI 在计费规则域里恒等于 chat。"""
|
|
|
|
|
|
t = (task_type or "").lower()
|
|
|
|
|
|
return "chat" if t == "cli" else t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class BillingRuleLookupResult:
|
2026-02-03 18:48:39 +08:00
|
|
|
|
rule: BillingRuleLike
|
|
|
|
|
|
scope: BillingRuleScope
|
2026-01-31 19:11:25 +08:00
|
|
|
|
effective_task_type: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BillingRuleService:
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def find_rule(
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider_id: str | None,
|
|
|
|
|
|
model_name: str,
|
|
|
|
|
|
task_type: str,
|
|
|
|
|
|
) -> BillingRuleLookupResult | None:
|
|
|
|
|
|
effective_task = effective_rule_task_type(task_type)
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
# Normalize provider_id for cache key to avoid duplicate entries (None vs "").
|
|
|
|
|
|
pid = provider_id or ""
|
|
|
|
|
|
# Cache must include runtime knobs that affect fallback behavior.
|
|
|
|
|
|
cache_key = (
|
|
|
|
|
|
f"{pid}:{model_name}:{effective_task}:require={int(config.billing_require_rule)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
cached = BillingCache.get_rule(cache_key)
|
|
|
|
|
|
if cached is not None:
|
|
|
|
|
|
return cached
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
global_model = (
|
|
|
|
|
|
db.query(GlobalModel)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
GlobalModel.name == model_name,
|
|
|
|
|
|
GlobalModel.is_active == True, # noqa: E712
|
|
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not global_model:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
model_obj: Model | None = None
|
|
|
|
|
|
|
|
|
|
|
|
# Provider Model(用于覆盖价格配置)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if provider_id:
|
|
|
|
|
|
model_obj = (
|
|
|
|
|
|
db.query(Model)
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
Model.provider_id == provider_id,
|
|
|
|
|
|
Model.global_model_id == global_model.id,
|
|
|
|
|
|
Model.is_active == True, # noqa: E712
|
|
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
# Code templates (config-file mode)
|
|
|
|
|
|
code_rule = CodeBillingRuleTemplateService.resolve_rule(
|
|
|
|
|
|
global_model=global_model,
|
|
|
|
|
|
model=model_obj,
|
|
|
|
|
|
provider_id=provider_id,
|
|
|
|
|
|
model_name=model_name,
|
|
|
|
|
|
task_type=effective_task,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if code_rule is not None:
|
|
|
|
|
|
result = BillingRuleLookupResult(
|
|
|
|
|
|
rule=code_rule,
|
|
|
|
|
|
scope="default",
|
|
|
|
|
|
effective_task_type=effective_task,
|
|
|
|
|
|
)
|
|
|
|
|
|
BillingCache.set_rule(cache_key, result)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
# Runtime default rule (backward compatible)
|
|
|
|
|
|
#
|
|
|
|
|
|
# - Always applies to chat-domain billing (cli is normalized to chat).
|
|
|
|
|
|
# - For video/image/audio:
|
|
|
|
|
|
# - When BILLING_REQUIRE_RULE=true, caller expects an explicit BillingRule (missing -> no_rule/error).
|
|
|
|
|
|
# - When BILLING_REQUIRE_RULE=false, fallback to default rule to preserve legacy pricing semantics
|
|
|
|
|
|
# (avoid silent $0 billing due to missing rule).
|
|
|
|
|
|
if effective_task == "chat" or not config.billing_require_rule:
|
|
|
|
|
|
default_rule = DefaultBillingRuleGenerator.generate_for_model(
|
|
|
|
|
|
global_model=global_model,
|
|
|
|
|
|
model=model_obj,
|
|
|
|
|
|
task_type=effective_task,
|
|
|
|
|
|
)
|
|
|
|
|
|
result = BillingRuleLookupResult(
|
|
|
|
|
|
rule=default_rule,
|
|
|
|
|
|
scope="default",
|
|
|
|
|
|
effective_task_type=effective_task,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
BillingCache.set_rule(cache_key, result)
|
|
|
|
|
|
return result
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
return None
|