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 import uuid
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlparse 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): def _rate_limit_from_env(name, default):
try: try:
value = int(os.environ.get(name) or default) value = int(os.environ.get(name) or default)
@@ -471,7 +484,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
pass pass
def _send_json(self, status, obj): 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_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -1101,11 +1114,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" GROUP BY day", " GROUP BY day",
(project_id, day)).fetchone() (project_id, day)).fetchone()
if row: if row:
daily = [{"day": row["day"], "total_tokens": row["t"], daily = [{"day": row["day"],
"usd_micros": row["u"], "turns": row["c"], "total_tokens": int(row["t"]),
"fallback_count": row["f"], "usd_micros": int(row["u"]),
"cached_input_tokens": row["cached"], "turns": int(row["c"]),
"unpriced_actual_tokens": row["unpriced"]}] "fallback_count": int(row["f"]),
"cached_input_tokens": int(row["cached"]),
"unpriced_actual_tokens": int(row["unpriced"])}]
elif days is not None: elif days is not None:
try: try:
n = int(days) n = int(days)
@@ -1126,11 +1141,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" FROM cost_records WHERE project_id = ? AND day >= ?" " FROM cost_records WHERE project_id = ? AND day >= ?"
" GROUP BY day ORDER BY day", " GROUP BY day ORDER BY day",
(project_id, cutoff)).fetchall() (project_id, cutoff)).fetchall()
daily = [{"day": r["day"], "total_tokens": r["t"], daily = [{"day": r["day"],
"usd_micros": r["u"], "turns": r["c"], "total_tokens": int(r["t"]),
"fallback_count": r["f"], "usd_micros": int(r["u"]),
"cached_input_tokens": r["cached"], "turns": int(r["c"]),
"unpriced_actual_tokens": r["unpriced"]} "fallback_count": int(r["f"]),
"cached_input_tokens": int(r["cached"]),
"unpriced_actual_tokens": int(r["unpriced"])}
for r in rows] for r in rows]
finally: finally:
try: try:
@@ -1206,13 +1223,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, { self._send_json(200, {
"priority_list": priority, "priority_list": priority,
"model_stats": [ "model_stats": [
{"model": r["model"], "turns": r["turns"], {"model": r["model"], "turns": int(r["turns"]),
"total_tokens": r["tokens"], "total_tokens": int(r["tokens"]),
"fallback_count": r["fallbacks"]} for r in rows], "fallback_count": int(r["fallbacks"])} for r in rows],
"fallback_events": [ "fallback_events": [
{"task_id": r["task_id"], "project_id": r["project_id"], {"task_id": r["task_id"], "project_id": r["project_id"],
"model": r["model"], "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(status, 200)
self.assertEqual(body["overall"]["turns"], 2) 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): def test_models_overview_isolated(self):
status, task = self._req( status, task = self._req(