Merge pull request #46 from openqj/pg-decimal-cost

修项目成本按天接口在 Postgres 上的 Decimal 序列化崩溃
This commit is contained in:
openqj authored and GitHub committed 2026-10-26 23:19:54 +08:00
commit 4c6b6ad039
2 files changed
+82 -15

No files matched your search

+33 -15
View File
@@ -48,6 +48,7 @@ import time
import uuid
from collections import deque
from contextlib import contextmanager
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlparse
@@ -135,6 +136,18 @@ TASK_STATUS_KEYS = (
)
def _json_default(obj):
"""json 兜底:Postgres 的 SUM(bigint) 返回 NUMERIC,psycopg2
映成 Decimal,不处理它在 _send_json 里直接炸 TypeError、请求
线程死掉、客户端只看到连接断开(2026-10-26 项目成本按天实弹
就是这么断的)。整数值 Decimal 转 int,非整数转 float;其它
类型照旧抛 TypeError,不静默吞掉真正的类型错误。"""
if isinstance(obj, Decimal):
return int(obj) if obj == obj.to_integral_value() else float(obj)
raise TypeError("Object of type %s is not JSON serializable"
% obj.__class__.__name__)
def _rate_limit_from_env(name, default):
try:
value = int(os.environ.get(name) or default)
@@ -471,7 +484,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
pass
def _send_json(self, status, obj):
body = json.dumps(obj).encode("utf-8")
body = json.dumps(obj, default=_json_default).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
@@ -1101,11 +1114,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" GROUP BY day",
(project_id, day)).fetchone()
if row:
daily = [{"day": row["day"], "total_tokens": row["t"],
"usd_micros": row["u"], "turns": row["c"],
"fallback_count": row["f"],
"cached_input_tokens": row["cached"],
"unpriced_actual_tokens": row["unpriced"]}]
daily = [{"day": row["day"],
"total_tokens": int(row["t"]),
"usd_micros": int(row["u"]),
"turns": int(row["c"]),
"fallback_count": int(row["f"]),
"cached_input_tokens": int(row["cached"]),
"unpriced_actual_tokens": int(row["unpriced"])}]
elif days is not None:
try:
n = int(days)
@@ -1126,11 +1141,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" FROM cost_records WHERE project_id = ? AND day >= ?"
" GROUP BY day ORDER BY day",
(project_id, cutoff)).fetchall()
daily = [{"day": r["day"], "total_tokens": r["t"],
"usd_micros": r["u"], "turns": r["c"],
"fallback_count": r["f"],
"cached_input_tokens": r["cached"],
"unpriced_actual_tokens": r["unpriced"]}
daily = [{"day": r["day"],
"total_tokens": int(r["t"]),
"usd_micros": int(r["u"]),
"turns": int(r["c"]),
"fallback_count": int(r["f"]),
"cached_input_tokens": int(r["cached"]),
"unpriced_actual_tokens": int(r["unpriced"])}
for r in rows]
finally:
try:
@@ -1206,13 +1223,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {
"priority_list": priority,
"model_stats": [
{"model": r["model"], "turns": r["turns"],
"total_tokens": r["tokens"],
"fallback_count": r["fallbacks"]} for r in rows],
{"model": r["model"], "turns": int(r["turns"]),
"total_tokens": int(r["tokens"]),
"fallback_count": int(r["fallbacks"])} for r in rows],
"fallback_events": [
{"task_id": r["task_id"], "project_id": r["project_id"],
"model": r["model"],
"fallback_count": r["fallback_count"]} for r in fb_rows],
"fallback_count": int(r["fallback_count"])}
for r in fb_rows],
})
# -- 任务 ----------------------------------------------------------
+49
View File
@@ -196,6 +196,55 @@ class EndpointsTestCase(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(body["overall"]["turns"], 2)
def test_project_cost_daily(self):
from datetime import datetime, timezone
status, task = self._req(
"POST", "/tasks",
{"project_id": self._pid(), "prompt": "做加法"}, self.owner_key)
self.assertEqual(status, 201)
store = CostStore(self.db_path)
try:
store.record(task["id"], self._pid(), "gpt-6.1-sol",
input_tokens=100, output_tokens=50,
cached_input_tokens=40, usd_micros=1234,
source="actual", fallback_count=1)
finally:
store._conn.close()
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
status, body = self._req(
"GET", "/projects/%s/cost?day=%s" % (self._pid(), today),
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["daily"]), 1)
row = body["daily"][0]
self.assertEqual(row["day"], today)
self.assertEqual(row["total_tokens"], 150)
self.assertEqual(row["usd_micros"], 1234)
self.assertEqual(row["turns"], 1)
self.assertEqual(row["fallback_count"], 1)
self.assertEqual(row["cached_input_tokens"], 40)
self.assertEqual(row["unpriced_actual_tokens"], 0)
status, body = self._req(
"GET", "/projects/%s/cost?days=7" % self._pid(),
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
[r for r in body["daily"] if r["day"] == today][0]["turns"], 1)
status, body = self._req(
"GET", "/projects/%s/cost?day=2000-01-01" % self._pid(),
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["daily"], [])
def test_json_default_decimal(self):
from decimal import Decimal
from cloud.server import _json_default
self.assertEqual(_json_default(Decimal("10424")), 10424)
self.assertIsInstance(_json_default(Decimal("10424")), int)
self.assertEqual(_json_default(Decimal("1.5")), 1.5)
with self.assertRaises(TypeError):
_json_default(object())
# -- 模型总览 -------------------------------------------------------
def test_models_overview_isolated(self):
status, task = self._req(