JsonlFileSink 把五字段 trace 落 workdir/trace.jsonl(非阻塞·写失败不阻断生成);studio.py sink 接线;SAA 扩展段 schema(接口对称五核心+内容不对称 ext);管理面 3 只读端点 roles/traces/cost(惰性 import 保住「仅 import service.app 不牵 fastapi」红线)。测试 test_jsonl_sink 3 / test_admin_routes 6 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
15 KiB
Python
273 lines
15 KiB
Python
"""service/admin_routes.py —— tier2 管理面只读 API(phase-1 · U-C3a)。
|
|
|
|
三个只读 GET 端点,供运营回看生成配置与轨迹:
|
|
GET /control/roles —— 各生成角色当前用的模型(从 worker/config.py 常量读)。
|
|
GET /control/traces/{id} —— 读 run._workdir(traceId)/trace.jsonl 返回全部步骤。
|
|
GET /control/cost/{id} —— 对 trace.jsonl 做 new-api quota 口径成本对账。
|
|
|
|
设计决策(附中文注释说明):
|
|
① 全部 GET 只读,不触发任何生成、不写文件、不起真服务;
|
|
② worker/config.py 顶层 import agentscope(mini-desktop 上有;6c6g/测试环境无),
|
|
本模块对 config 一律惰性 import + try/except,确保 6c6g py_compile / import 不炸;
|
|
③ /control/traces/{id} 文件不存在返 404(REST 语义:资源不存在即 404),响应体带 found=false
|
|
方便客户端分支一致处理,优于返回 200+found=false(后者歧义:200 暗示"有了");
|
|
④ /control/cost/{id} 取价不可达 → pricingSource="degraded" + 200(不抛 500),
|
|
对齐 best-effort 铁律(成本观测不得中断主链);
|
|
⑤ _workdir_for_trace 与 run._workdir 同口径独立实现(无需 import run),便于轻量测试打桩。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
# 路由前缀 /control;标签 admin 供 OpenAPI docs 分组展示。
|
|
router = APIRouter(prefix="/control", tags=["admin"])
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 辅助:角色 → 模型映射
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _get_roles() -> list[dict]:
|
|
"""读各生成角色当前配的模型名。
|
|
|
|
优先路:import worker.config 取真实编译常量(只在 mini-desktop 上成功,那里装了 agentscope)。
|
|
回落路:worker.config 因缺 agentscope 而 import 失败时,直接读 worker.genconfig(无 agentscope 依赖)。
|
|
两条路产出相同形状:[{"role": ..., "model": ...}, ...]。
|
|
角色说明:
|
|
writer —— tier2 单写 ReAct agent(默认 MiniMax-M3;env TIER2_MODEL 可临时压)。
|
|
cheap_flash —— 便宜档主力(deepseek-v4-flash,经 new-api OpenAI 兼容路)。
|
|
cheap_pro —— 便宜档强档(deepseek-v4-pro,退路树 Q1 用)。
|
|
baseline —— 跨族对照(当前 deepseek-v4-pro;网关开通 Opus/Fable 后改 generation.yaml 切换)。
|
|
"""
|
|
try:
|
|
# 优先路:worker.config 顶层 import agentscope(mini-desktop 真跑时走这条)。
|
|
from worker import config as _cfg # noqa: PLC0415 — 惰性 import
|
|
return [
|
|
{"role": "writer", "model": _cfg.model_name_from_env()},
|
|
{"role": "cheap_flash", "model": _cfg.DEEPSEEK_FLASH},
|
|
{"role": "cheap_pro", "model": _cfg.DEEPSEEK_PRO},
|
|
{"role": "baseline", "model": _cfg.BASELINE_CROSSCHECK},
|
|
]
|
|
except Exception:
|
|
# 回落路:agentscope 未装(6c6g / 测试环境)→ genconfig 直读,无 agentscope 依赖。
|
|
from worker import genconfig as _gc # noqa: PLC0415
|
|
return [
|
|
{"role": "writer",
|
|
"model": os.environ.get("TIER2_MODEL",
|
|
_gc.get("model", "default_model_name", "MiniMax-M3"))},
|
|
{"role": "cheap_flash",
|
|
"model": _gc.get("model", "deepseek_flash", "deepseek-v4-flash")},
|
|
{"role": "cheap_pro",
|
|
"model": _gc.get("model", "deepseek_pro", "deepseek-v4-pro")},
|
|
{"role": "baseline",
|
|
"model": _gc.get("model", "baseline_crosscheck", "deepseek-v4-pro")},
|
|
]
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 辅助:workdir 路径计算(与 run._workdir 同口径,无需 import run)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _workdir_for_trace(trace_id: str) -> Path:
|
|
"""计算 trace_id 对应的 tier2 工程目录,口径与 worker/run._workdir 完全一致。
|
|
|
|
为何独立实现而不直接 import worker.run:
|
|
run.py 顶层 import archetypes(agentscope 链),保持本模块尽量轻量(6c6g / 测试环境友好);
|
|
路径计算是纯 pathlib 操作,独立复制最小逻辑更清晰。
|
|
路径:
|
|
本文件在 tier2/gen-worker/service/admin_routes.py → 上溯 4 级到仓根:
|
|
service/ → gen-worker/ → tier2/ → 仓根(games-development-ai)
|
|
GEN_DIR = 仓根/game-runtime/games/_tier2-gen(tier2 专用生成目录)。
|
|
此函数是测试桩点:测试用 unittest.mock.patch 替换它指向临时目录即可,无需真实工程目录存在。
|
|
"""
|
|
repo_root = Path(__file__).resolve().parents[3]
|
|
gen_dir = repo_root / "game-runtime" / "games" / "_tier2-gen"
|
|
return gen_dir / trace_id
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 辅助:从 trace 步骤提取 tokens_by_model
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _extract_tokens_by_model(steps: list[dict]) -> dict[str, dict[str, int]]:
|
|
"""从 trace 步骤列表提取 tokens_by_model 聚合(按模型分 in/out/cached token 累加)。
|
|
|
|
关联规则(与 to_trace_step 映射规则一致):
|
|
- ext.raw.model_name 非空 → 更新「当前模型名」(对应 ModelCallStartEvent)。
|
|
- cost.tokens.in / out 非零 → 记入当前模型的 bucket(对应 ModelCallEndEvent)。
|
|
跨多轮模型调用:同一模型的 token 累加;未见模型名时用 "unknown" 兜底。
|
|
|
|
:param steps: trace.jsonl 解析后的步骤列表。
|
|
:return: {model_name: {"in": Σin, "out": Σout, "cached": Σcached}}。
|
|
"""
|
|
tokens_by_model: dict[str, dict[str, int]] = {}
|
|
current_model = "unknown" # 每遇 ModelCallStart 类事件更新
|
|
for step in steps:
|
|
# 更新当前模型名:从 ext.raw.model_name 读(ModelCallStartEvent 标记)。
|
|
ext = step.get("ext") or {}
|
|
raw = ext.get("raw") or {}
|
|
model_name_in_step = raw.get("model_name")
|
|
if model_name_in_step:
|
|
current_model = str(model_name_in_step)
|
|
# 读 token 计数:cost.tokens.in / out(ModelCallEndEvent / Tier2ModelCallPhaseEvent 填)。
|
|
cost = step.get("cost") or {}
|
|
tokens = cost.get("tokens") if isinstance(cost, dict) else None
|
|
if not isinstance(tokens, dict):
|
|
continue
|
|
in_tok = int(tokens.get("in") or 0)
|
|
out_tok = int(tokens.get("out") or 0)
|
|
# cached 字段在 trace 当前格式里一般不出现(由 RecordingChatModel 记录,非 trace 字段),
|
|
# 预留:若将来 trace 扩展落 tokens.cached 可直接读到。
|
|
cached_tok = int(tokens.get("cached") or 0)
|
|
if in_tok == 0 and out_tok == 0:
|
|
continue # 零 token 步骤跳过(非 ModelCallEnd)
|
|
bucket = tokens_by_model.setdefault(current_model, {"in": 0, "out": 0, "cached": 0})
|
|
bucket["in"] += in_tok
|
|
bucket["out"] += out_tok
|
|
bucket["cached"] += cached_tok
|
|
return tokens_by_model
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 端点一:GET /control/roles
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/roles")
|
|
def get_roles() -> dict:
|
|
"""GET /control/roles —— 各生成角色当前配的模型(只读,无副作用)。
|
|
|
|
数据源:worker/config.py 编译常量(role→model 静态映射);agentscope 不可达时回落 genconfig。
|
|
返回:{"roles": [{"role": "writer", "model": "MiniMax-M3"}, ...]}。
|
|
"""
|
|
return {"roles": _get_roles()}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 端点二:GET /control/traces/{trace_id}
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/traces/{trace_id}")
|
|
def get_traces(trace_id: str):
|
|
"""GET /control/traces/{traceId} —— 读 trace.jsonl 返回全部步骤。
|
|
|
|
数据源:run._workdir(traceId)/trace.jsonl(JsonlFileSink 写的 JSONL,五字段:
|
|
traceId / step / cost / verdict / timestamp + ext 扩展段)。
|
|
|
|
返回(200):{"traceId":..., "steps":[...], "count":n, "found":true}。
|
|
文件不存在(404):{"traceId":..., "steps":[], "count":0, "found":false}。
|
|
|
|
REST 选型理由(404 而非 200+found=false):
|
|
资源不存在 = 404 是 HTTP 语义约定,让客户端无需解析 found 字段即可判断;
|
|
响应体仍带 found=false + 空 steps 以方便调用方分支一致解析。
|
|
|
|
健壮性:坏行跳过不崩(best-effort,对齐 JsonlFileSink 的 best-effort 铁律)。
|
|
"""
|
|
workdir = _workdir_for_trace(trace_id)
|
|
trace_file = workdir / "trace.jsonl"
|
|
if not trace_file.exists():
|
|
# 资源不存在 → 404;带 found=false 供调用方分支处理。
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"traceId": trace_id, "steps": [], "count": 0, "found": False},
|
|
)
|
|
steps: list[dict] = []
|
|
for raw_line in trace_file.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
steps.append(json.loads(line))
|
|
except Exception:
|
|
# 坏行跳过:不崩、不计入 count(best-effort;trace 写失败不得影响读)。
|
|
pass
|
|
return {"traceId": trace_id, "steps": steps, "count": len(steps), "found": True}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 端点三:GET /control/cost/{trace_id}
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/cost/{trace_id}")
|
|
def get_cost(trace_id: str):
|
|
"""GET /control/cost/{traceId} —— 对 trace.jsonl 做 new-api quota 口径成本对账。
|
|
|
|
数据源:
|
|
① 读 trace.jsonl → 提取 tokens_by_model(按模型分 in/out token 累加)。
|
|
② 调 newapi_pricing.fetch_pricing_params() 活取计费三件套(pricing/qpu/usd_rate)。
|
|
③ cost_for_run(tokens, pricing, qpu, usd_rate) 按 new-api quota 公式折¥。
|
|
|
|
返回(200):
|
|
{"traceId":..., "costRmb":float, "byModel":{model:{quota,usd,rmb,...}}, "pricingSource":"live"}
|
|
|
|
降级处理(best-effort 铁律,成本观测不得阻断):
|
|
- trace 不存在 → 404(与 /traces 同语义)。
|
|
- 无 token 记录 → pricingSource="degraded", costRmb=0.0, byModel={} 返回 200。
|
|
- 取价不可达(网络/6c6g) → pricingSource="degraded", costRmb=0.0, byModel={} 返回 200。
|
|
- cost 模块 import 失败 → 同上 degraded 路径(防御)。
|
|
"""
|
|
workdir = _workdir_for_trace(trace_id)
|
|
trace_file = workdir / "trace.jsonl"
|
|
if not trace_file.exists():
|
|
# trace 文件不存在 → 404(与 /traces 端点语义一致)。
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"traceId": trace_id, "costRmb": 0.0, "byModel": {},
|
|
"pricingSource": "notfound"},
|
|
)
|
|
|
|
# ① 读 trace.jsonl 并解析步骤(坏行跳过,best-effort)。
|
|
steps: list[dict] = []
|
|
for raw_line in trace_file.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
steps.append(json.loads(line))
|
|
except Exception:
|
|
pass # 坏行跳过
|
|
|
|
# ② 提取 tokens_by_model。
|
|
tokens_by_model = _extract_tokens_by_model(steps)
|
|
if not tokens_by_model:
|
|
# 无 token 记录(trace 全是非 ModelCallEnd 步骤,或还没生成过)→ degraded。
|
|
print(f"[tier2-admin] GET /cost/{trace_id}: trace 无 token 记录,返回 degraded。",
|
|
flush=True)
|
|
return {"traceId": trace_id, "costRmb": 0.0, "byModel": {}, "pricingSource": "degraded"}
|
|
|
|
# ③ 惰性 import cost 模块(observability 不依赖 agentscope,但防御性兜住任何 import 错误)。
|
|
try:
|
|
from observability.newapi_pricing import fetch_pricing_params # noqa: PLC0415
|
|
from observability.cost import cost_for_run # noqa: PLC0415
|
|
except Exception as exc:
|
|
# 极端环境下 import 失败(如依赖缺失)→ degraded,不抛 500。
|
|
print(f"[tier2-admin] GET /cost/{trace_id}: 导入 cost/pricing 模块失败(degraded):{exc}",
|
|
flush=True)
|
|
return {"traceId": trace_id, "costRmb": 0.0, "byModel": {}, "pricingSource": "degraded"}
|
|
|
|
# ④ 活取 new-api 计费参数(best-effort:取不到 → degraded,不抛)。
|
|
pricing_params = fetch_pricing_params() # 内部 best-effort,失败返 None
|
|
if pricing_params is None:
|
|
# 取价不可达(6c6g / 网关下线 / httpx 超时)→ degraded 标识,返 200 不抛 500。
|
|
print(f"[tier2-admin] GET /cost/{trace_id}: new-api 取价不可达,返回 degraded。", flush=True)
|
|
return {"traceId": trace_id, "costRmb": 0.0, "byModel": {}, "pricingSource": "degraded"}
|
|
|
|
# ⑤ 按 new-api quota 公式折¥(cost_for_run 纯函数,全显式传参,不读全局)。
|
|
cost_result = cost_for_run(
|
|
tokens_by_model,
|
|
pricing_params["pricing"],
|
|
pricing_params["qpu"],
|
|
pricing_params["usd_rate"],
|
|
)
|
|
return {
|
|
"traceId": trace_id,
|
|
"costRmb": cost_result["cost_rmb"],
|
|
"byModel": cost_result.get("by_model", {}),
|
|
"pricingSource": "live",
|
|
}
|