public.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. from __future__ import annotations
  2. import re
  3. from datetime import datetime
  4. from typing import List, Optional, Union
  5. from urllib.parse import urlparse
  6. import json
  7. from app.utils.price_parser import parse_prices
  8. from app.utils.apikey_crypto import try_encrypt
  9. from fastapi import APIRouter, HTTPException, Request
  10. from pydantic import BaseModel
  11. from app.db import get_pool
  12. from app.services.geo import geo_resolver
  13. router = APIRouter()
  14. class PublicPriceOut(BaseModel):
  15. url: str
  16. model_name: str
  17. prices: dict
  18. model_info: Optional[dict] = None
  19. rate_limits: Optional[dict] = None
  20. tool_prices: Optional[list] = None
  21. icon: Optional[str] = None
  22. scraped_at: datetime
  23. discount: Optional[float] = None
  24. api_key: Optional[str] = None # 向后兼容:保留单个key
  25. api_keys: Optional[List[str]] = None # 新增:号池多个key(来自分组的group_keys表)
  26. group_name: Optional[str] = None
  27. class ParsedPriceItem(BaseModel):
  28. url: str
  29. model_name: str
  30. tier_min: Optional[float] = None
  31. tier_max: Optional[float] = None
  32. tier_unit: Optional[str] = None
  33. input_price: Optional[float] = None
  34. output_price: Optional[float] = None
  35. currency: str = "CNY"
  36. unit: Optional[str] = None
  37. label: Optional[str] = None
  38. model_config = {"extra": "allow"}
  39. class DiscountedPriceItem(BaseModel):
  40. url: str
  41. model_name: str
  42. tier_min: Optional[float] = None
  43. tier_max: Optional[float] = None
  44. tier_unit: Optional[str] = None
  45. input_price: Optional[float] = None
  46. output_price: Optional[float] = None
  47. currency: str = "CNY"
  48. unit: Optional[str] = None
  49. label: Optional[str] = None
  50. discount: Optional[float] = None # None 表示未知,1.0 表示原价
  51. model_config = {"extra": "allow"}
  52. class ModelTypeItem(BaseModel):
  53. model_name: str
  54. type: List[str]
  55. class PricesResponse(BaseModel):
  56. version: int
  57. models: List[PublicPriceOut]
  58. parsed_prices: List[ParsedPriceItem]
  59. discounted_prices: List[DiscountedPriceItem]
  60. types: List[ModelTypeItem]
  61. class UpToDateResponse(BaseModel):
  62. up_to_date: bool = True
  63. version: int
  64. def _extract_domain(referer: Optional[str]) -> Optional[str]:
  65. if not referer:
  66. return None
  67. try:
  68. return urlparse(referer).netloc or None
  69. except Exception:
  70. return None
  71. @router.get("/prices", response_model=Union[PricesResponse, UpToDateResponse])
  72. async def get_public_prices(
  73. request: Request,
  74. url: Optional[str] = None,
  75. ) -> Union[PricesResponse, UpToDateResponse]:
  76. pool = get_pool()
  77. # referer 必须提供
  78. referer = request.headers.get("referer") or request.headers.get("origin")
  79. if not referer:
  80. raise HTTPException(status_code=400, detail="Missing Referer header")
  81. # version 从 Header 读取,默认 0(首次请求)
  82. try:
  83. version = int(request.headers.get("version", "0") or "0")
  84. except ValueError:
  85. version = 0
  86. # 记录调用来源
  87. ip = request.client.host if request.client else "unknown"
  88. geo = geo_resolver.resolve(ip)
  89. try:
  90. await pool.execute(
  91. "INSERT INTO price_api_logs (ip, referer, org, country, city) VALUES ($1, $2, $3, $4, $5)",
  92. ip, referer, geo.org, geo.country, geo.city,
  93. )
  94. except Exception:
  95. pass
  96. # 查调用方域名对应的折扣
  97. caller_domain = _extract_domain(referer)
  98. discount_rate: float = 1.0
  99. # 域名级别的模型自定义价格:{model_name: {input_price, output_price}}
  100. model_custom_prices: dict = {}
  101. if caller_domain:
  102. row = await pool.fetchrow("SELECT discount FROM discounts WHERE domain = $1", caller_domain)
  103. if row:
  104. discount_rate = float(row["discount"])
  105. # 加载该域名下所有模型的自定义折扣
  106. mp_rows = await pool.fetch(
  107. "SELECT model_name, discount FROM domain_model_prices WHERE domain = $1",
  108. caller_domain,
  109. )
  110. for mp in mp_rows:
  111. model_custom_prices[mp["model_name"]] = float(mp["discount"])
  112. def _j(v):
  113. if v is None:
  114. return None
  115. return v if isinstance(v, (dict, list)) else json.loads(v)
  116. # 读取版本号:优先用域名专属版本,没有则回退到全局版本
  117. if caller_domain:
  118. ver_row = await pool.fetchrow(
  119. "SELECT version FROM domain_version WHERE domain = $1", caller_domain
  120. )
  121. if ver_row:
  122. current_version = int(ver_row["version"])
  123. else:
  124. ver_row = await pool.fetchrow("SELECT version FROM price_snapshot_version WHERE id = 1")
  125. current_version = int(ver_row["version"]) if ver_row else 0
  126. else:
  127. ver_row = await pool.fetchrow("SELECT version FROM price_snapshot_version WHERE id = 1")
  128. current_version = int(ver_row["version"]) if ver_row else 0
  129. # version != 0 且与当前一致 → 无需更新(0 视为首次请求,强制返回数据)
  130. if version != 0 and version == current_version:
  131. return UpToDateResponse(up_to_date=True, version=current_version)
  132. # 从 models 表出发,LEFT JOIN price_snapshot 取价格数据
  133. # 这样手动添加的模型也会被包含
  134. # 同时从 group_keys 获取分组的多个key(号池)
  135. if url is None:
  136. rows = await pool.fetch(
  137. """
  138. SELECT m.url, COALESCE(ps.model_name, m.name) AS model_name,
  139. COALESCE(ps.prices, '{}') AS prices, COALESCE(ps.model_info, '{}') AS model_info,
  140. COALESCE(ps.rate_limits, '{}') AS rate_limits,
  141. COALESCE(ps.tool_prices, '{}') AS tool_prices, ps.icon,
  142. COALESCE(ps.updated_at, m.created_at) AS updated_at,
  143. k.key_value AS api_key,
  144. m.group_id, g.name AS group_name,
  145. COALESCE(
  146. ARRAY_AGG(gk_keys.key_value) FILTER (WHERE gk_keys.key_value IS NOT NULL),
  147. ARRAY[]::text[]
  148. ) AS group_api_keys
  149. FROM models m
  150. LEFT JOIN price_snapshot ps ON ps.url = m.url
  151. LEFT JOIN api_keys k ON k.id = m.api_key_id
  152. LEFT JOIN model_groups g ON g.id = m.group_id
  153. LEFT JOIN group_keys gk ON gk.group_id = m.group_id AND gk.is_active = TRUE AND gk.status = 'active'
  154. LEFT JOIN api_keys gk_keys ON gk_keys.id = gk.api_key_id
  155. GROUP BY m.url, m.name, ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
  156. ps.tool_prices, ps.icon, ps.updated_at, m.created_at, k.key_value, m.group_id, g.name
  157. ORDER BY m.url
  158. """
  159. )
  160. else:
  161. rows = await pool.fetch(
  162. """
  163. SELECT ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
  164. ps.tool_prices, ps.icon, ps.updated_at,
  165. k.key_value AS api_key,
  166. m.group_id, g.name AS group_name,
  167. COALESCE(
  168. ARRAY_AGG(gk_keys.key_value) FILTER (WHERE gk_keys.key_value IS NOT NULL),
  169. ARRAY[]::text[]
  170. ) AS group_api_keys
  171. FROM price_snapshot ps
  172. LEFT JOIN models m ON m.url = ps.url
  173. LEFT JOIN api_keys k ON k.id = m.api_key_id
  174. LEFT JOIN model_groups g ON g.id = m.group_id
  175. LEFT JOIN group_keys gk ON gk.group_id = m.group_id AND gk.is_active = TRUE AND gk.status = 'active'
  176. LEFT JOIN api_keys gk_keys ON gk_keys.id = gk.api_key_id
  177. WHERE ps.url = $1
  178. GROUP BY ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
  179. ps.tool_prices, ps.icon, ps.updated_at, k.key_value, m.group_id, g.name
  180. """,
  181. url,
  182. )
  183. if not rows:
  184. raise HTTPException(status_code=404, detail="No price snapshot found for the given URL")
  185. if not rows:
  186. raise HTTPException(status_code=503, detail="Price snapshot not yet available")
  187. # version != 0 且与当前一致 → 无需更新
  188. if version != 0 and version == current_version:
  189. return UpToDateResponse(up_to_date=True, version=current_version)
  190. def _extract_type(model_info: Optional[dict]) -> Optional[List[str]]:
  191. if not model_info:
  192. return None
  193. tags = model_info.get("display_tags", [])
  194. TYPE_TAGS = {"文本生成", "图像生成", "视频生成", "向量表示", "向量模型", "多模态向量", "语音识别", "实时语音识别", "语音合成"}
  195. result = [t for t in tags if t in TYPE_TAGS]
  196. return result if result else None
  197. def _to_list(v):
  198. parsed = _j(v)
  199. return parsed if isinstance(parsed, list) else None
  200. models = [PublicPriceOut(
  201. url=r["url"],
  202. model_name=r["model_name"],
  203. prices=_j(r["prices"]) or {},
  204. model_info=_j(r["model_info"]),
  205. rate_limits=_j(r["rate_limits"]),
  206. tool_prices=_to_list(r["tool_prices"]),
  207. icon=r["icon"],
  208. scraped_at=r["updated_at"],
  209. discount=model_custom_prices.get(r["model_name"]) if model_custom_prices.get(r["model_name"]) is not None else discount_rate,
  210. api_key=try_encrypt(r["api_key"]),
  211. api_keys=[try_encrypt(k) for k in r["group_api_keys"]] if r["group_api_keys"] else None, # 号池key列表
  212. group_name=r["group_name"],
  213. ) for r in rows]
  214. parsed_prices: List[ParsedPriceItem] = []
  215. discounted_prices: List[DiscountedPriceItem] = []
  216. for r in rows:
  217. for item in parse_prices(_j(r["prices"]) or {}):
  218. # 过滤掉输入和输出价格都为 None 的条目(保留单边价格,如向量模型、图像生成等)
  219. if item.get("input_price") is None and item.get("output_price") is None:
  220. continue
  221. # 将 label 改为中文
  222. item = dict(item)
  223. if item.get("label") == "input/output":
  224. item["label"] = "输入/输出"
  225. parsed_prices.append(ParsedPriceItem(url=r["url"], model_name=r["model_name"], **item))
  226. d_item = dict(item)
  227. model_name = r["model_name"]
  228. custom = model_custom_prices.get(model_name)
  229. # 模型级折扣优先,没有则用域名全局折扣
  230. effective_discount = custom if custom is not None else discount_rate
  231. if effective_discount is not None:
  232. if d_item.get("input_price") is not None:
  233. d_item["input_price"] = round(d_item["input_price"] * effective_discount, 6)
  234. if d_item.get("output_price") is not None:
  235. d_item["output_price"] = round(d_item["output_price"] * effective_discount, 6)
  236. for key, value in list(d_item.items()):
  237. if key in {"url", "model_name", "tier_min", "tier_max", "tier_unit", "input_price", "output_price", "currency", "unit", "label", "discount"}:
  238. continue
  239. if isinstance(value, (int, float)):
  240. d_item[key] = round(value * effective_discount, 6)
  241. discounted_prices.append(DiscountedPriceItem(url=r["url"], model_name=r["model_name"], discount=effective_discount, **d_item))
  242. all_types = [
  243. ModelTypeItem(model_name=r["model_name"], type=_extract_type(_j(r["model_info"])) or [])
  244. for r in rows
  245. ]
  246. return PricesResponse(
  247. version=current_version,
  248. models=models,
  249. parsed_prices=parsed_prices,
  250. discounted_prices=discounted_prices,
  251. types=all_types,
  252. )