diff --git a/cloud/server.py b/cloud/server.py index 7d180af..49ed531 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -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], }) # -- 任务 ---------------------------------------------------------- diff --git a/cloud/tests/test_server_endpoints.py b/cloud/tests/test_server_endpoints.py index c73e2a4..de6ffc4 100644 --- a/cloud/tests/test_server_endpoints.py +++ b/cloud/tests/test_server_endpoints.py @@ -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(