feat(trace): 调用原文追踪「查看IO」前端闭环+按项目费用页(2026-09-10定夺落地)

- json/llm_call_trace_list.json: noedit只读列表;exclouded隐藏id/req_path/resp_path/org_id;
  url/note加宽;kind/method内联枚举(含gen同步生成,此前草稿引用的get_llm_kind_options.dspy不存在);
  删color_mapping(bricks全库0消费的死配置);toolbar查看IO+binds urlwidget→PopupWindow(85%)
  url不带?id=${id}$——生成期ArgsConvert会eval成<built-in function id>垃圾串,
  行数据id经_add_event_data自动并入params(dapi/llmage生产同款)
- api/llm_call_trace_io.dspy: 薄代理(get_user门禁+get_userorgid机构隔离)→env.llm_call_trace_io;
  修草稿4处实锤bug:get_user未await/user.get当dict/sor块外使用/返回PopupWindow套娃
- init.py: llm_call_trace_io读表+同批次+llm_usage上下文(块内读全拷dict)→读文件
  (trace.trace_root realpath前缀校验防穿越)→TabPanel三tab(摘要Html+pre escape防注入/
  请求/响应CodeEditor readonly mode=null);单侧150K截断提示盘上路径;env注册
- api/llm_project_cost_query.dspy: 改{total,rows}契约接Tabular data_url(PageDataLoader);
  wwwroot/llm_project_cost/index.ui: InlineForm(date_from/date_to/status)+Tabular;
  script内getWidgetById起点bricks.app(弹窗DOM挂body下,app.root搜不到)
- card_popup.dspy: titles加llm_call_trace;新增page_targets白名单挂按项目费用页;
  index.ui挂两张卡片(调用原文追踪/按项目费用)
- accounting.py: _maybe_cleanup_traces每日一次接cleanup_expired(trace.py铁律3
  文档承诺worker每日调但循环没接,补齐);放抢锁前(清理幂等多进程同日无害)
- scripts/load_path.py: 注释说明/**通配已覆盖新页面(rbac check_roles_path前缀匹配)
- models/mysql.ddl/README/design-spec: 9张表同步(kind补gen枚举;3.9节;F19/F20)
- scripts/test_llm_call_trace_io.py: stub harness 22断言全过(隔离/穿越/转义/截断/每日门控)
- .gitignore: wwwroot/llm_call_trace/生成目录不入库
This commit is contained in:
yumoqing 2026-09-11 14:58:21 +08:00
parent 427cd7cbc1
commit 69fe362dde
15 changed files with 422 additions and 18 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ wwwroot/llm_org_policy/
wwwroot/llm_org_quota/
wwwroot/llm_user_quota/
wwwroot/llm_usage/
wwwroot/llm_call_trace/
# venv
py3/

View File

@ -12,7 +12,7 @@
4. **计费**:双维度记账(成本侧记账号、消费侧记组织/个人),模型挂定价 `pricing_program.ppid`
5. **端点选择**端点目录配在供应商下全球配置一次账号选用端点客户按区域偏好prefer/must
## 数据表(8 张)
## 数据表(9 张)
| 表 | 说明 |
|---|---|
@ -24,6 +24,7 @@
| `llm_org_quota` | 组织限额限流(池余额 + QPM |
| `llm_user_quota` | 个人限额限流(额度上限 + QPM |
| `llm_usage` | 用量流水双维度记账事实表status 统一枚举 SUCCEEDED/FAILED/PENDING/RUNNING/rechargeaccounting_status 仅 SUCCEEDED 行有意义否则 NULL**usages 是计费唯一事实源**——上游 usage 原文 JSON 全量不截断token 计数与按量因子都在其中req_tokens/resp_tokens 冗余列已删m0021统计查询走 JSON_EXTRACTproject_id 关联 sd_projects 按项目统计费用(非项目调用哨兵'0',查询不传=不区分项目m0025call_id 关联 llm_call_trace 上行/下行原文) |
| `llm_call_trace` | 调用原文追踪2026-09-10 用户定夺):每次上游 HTTP 往返一行chat/gen/submit/query请求/响应原文脱敏落盘 filesroot/llm_trace/<>/<call_id>_<seq>.req/.resp.jsonAuthorization 等敏感 header 值替换 [REDACTED]URL 去 querycall_id 与 llm_usage.call_id 同值——按流水反查全部原文列表页「查看IO」弹窗llm_call_trace_io.dspy → env.llm_call_trace_io展示上下文摘要+请求/响应 CodeEditor 三 Tabrealpath 前缀校验防路径穿越机构隔离org_id='0' 平台可看全部);保留 90 天TRACE_RETENTION_DAYS过期清理由记账 worker 循环每日调 cleanup_expiredaccounting._maybe_cleanup_traces文件不进 dbackup 库备份,需另行同步备份 |
## 调用门禁链

View File

@ -39,7 +39,7 @@ llm_vendor (协议+端点目录) llm_org_policy (组织容错策略)
任何一级不过:快速失败 + 明确原因(不挂起、不静默)
```
## 3. 数据表(8 张,全部 `llm_` 前缀,建在 `pipeline` 库)
## 3. 数据表(9 张,全部 `llm_` 前缀,建在 `pipeline` 库)
### 3.1 llm_vendor — 供应商
| 字段 | 类型 | 说明 |
@ -169,6 +169,30 @@ must 模式:候选端点耗尽宁可报错冒泡,绝不跨端点。
一次调用两条线:**成本侧** cost 记账号(供应商对账),**消费侧** charge 记组织池+个人
(客户计费)。充值是 balance 变动事件,记 `source=recharge`model_id/account_id 空)。
### 3.9 llm_call_trace — 模型调用原文追踪(上行/下行)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | str(32) PK | |
| call_id | str(32) | 调用批次ID与 llm_usage.call_id 同值;一次 chat_inference=N 个往返) |
| seq | int | 往返序号(同步重试 0..2;异步提交 0轮询 1..N |
| kind | str(20) | chat 对话 / gen 同步生成 / submit 异步提交 / query 任务轮询 |
| url | str(500) | 上游 URL去 query 防签名泄露) |
| method | str(10) | HTTP 方法 |
| status_code / elapsed_ms | int | 响应状态码 / 耗时 |
| req_path / resp_path | str(300) | 原文文件路径filesroot 相对headers 已脱敏) |
| org_id | str(32) | 机构ID查看端点机构隔离用 |
| note | str(200) | 错误摘要 |
| created_at | timestamp | |
原文落盘(唯一收敛点 inference._post_upstream / _http_request 层各记一次):
`filesroot/llm_trace/<YYYYMMDD>/<call_id>_<seq>.req.json / .resp.json`。铁律:
脱敏后落盘Authorization 等敏感 header 值 → [REDACTED]);先文件后库行;
record_roundtrip 永不抛(审计不反噬计费/调用链);保留 90 天,过期清理挂记账
worker 循环accounting._maybe_cleanup_traces 每日一次);文件需纳入部署备份
dbackup 只备库。查看入口列表页「查看IO」→ api/llm_call_trace_io.dspy →
env.llm_call_trace_ioTabPanel 三页:上下文摘要/请求原文/响应原文CodeEditor
readonlyrealpath 前缀校验不逃逸 llm_trace 目录;机构隔离 org_id='0' 平台可看全部)。
## 4. 核心机制
### 4.1 选择链与轮转gateway.py `llm_call`
@ -204,7 +228,7 @@ async def llm_call(prompt, model=None, org_id=None, user_id=None, task_ref='', *
兜底(组织策略页配置),二期接定价平台实时价
- 成本侧单价:账号维度配置(供应商结算价),一期也走手动配置
## 5. 功能点清单(共 18 项)
## 5. 功能点清单(共 20 项)
| # | 功能点 | 表/文件 | 优先级 |
|---|---|---|---|
@ -226,6 +250,8 @@ async def llm_call(prompt, model=None, org_id=None, user_id=None, task_ref='', *
| F16 | 模型管理入口(主菜单) | index.ui 菜单项 | P0 |
| F17 | 与现有调用链兼容llm_bridge 切换) | llm_bridge.py 适配 | P0 |
| F18 | 机构未配置冒泡提示 | gateway 错误路径 | P0 |
| F19 | 调用原文追踪(上行/下行脱敏落盘+查看IO弹窗+90天保留 | llm_call_trace + trace.py | P1 |
| F20 | 按项目费用统计project_id 分组汇总) | llm_usage.project_id + llm_project_cost_query | P1 |
## 6. 能力类型标准capabilities 取值,先行收敛)

View File

@ -68,7 +68,7 @@
"resizable": true
},
"options": {
"url": "{{entire_url('../api/llm_call_trace_io.dspy')}}?id=${id}$"
"url": "{{entire_url('../api/llm_call_trace_io.dspy')}}"
}
}
]

View File

@ -31,7 +31,7 @@
},
{
"name": "kind",
"title": "往返类型(chat对话/submit异步提交/query任务查询)",
"title": "往返类型(chat对话/gen同步生成/submit异步提交/query任务查询)",
"type": "str",
"length": 20
},

View File

@ -1,5 +1,6 @@
-- pipeline-llm 模块数据表(8 张,全部 llm_ 前缀pipeline 库)
-- pipeline-llm 模块数据表(9 张,全部 llm_ 前缀pipeline 库)
-- 手写幂等 DDLIF NOT EXISTS + 内联索引),由宿主 scripts/create_tables.py 执行
-- (宿主实际建表走 models/*.json → json2ddl本文件为对齐参考改表两侧同步
-- 金额用 DECIMAL对账精度时间用 datetime
CREATE TABLE IF NOT EXISTS llm_vendor (
@ -134,3 +135,22 @@ CREATE TABLE IF NOT EXISTS llm_usage (
KEY `idx_llm_usage_model` (`model_id`),
KEY `idx_llm_usage_project` (`project_id`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci comment='用量流水(双维度记账)';
CREATE TABLE IF NOT EXISTS llm_call_trace (
`id` varchar(32) NOT NULL comment '主键ID',
`call_id` varchar(32) comment '调用批次ID(与llm_usage.call_id同值,一次chat_inference=N个往返)',
`seq` int DEFAULT 0 comment '往返序号(同步重试0..2;异步提交0,轮询1..N)',
`kind` varchar(20) comment '往返类型(chat对话/gen同步生成/submit异步提交/query任务查询)',
`url` varchar(500) comment '上游URL(去query防签名泄露)',
`method` varchar(10) comment 'HTTP方法',
`status_code` int DEFAULT 0 comment '响应状态码',
`elapsed_ms` int DEFAULT 0 comment '耗时(毫秒)',
`req_path` varchar(300) comment '请求原文文件路径(filesroot相对;headers已脱敏)',
`resp_path` varchar(300) comment '响应原文文件路径(filesroot相对)',
`org_id` varchar(32) comment '机构ID(下载端点机构隔离用)',
`note` varchar(200) comment '错误摘要',
`created_at` timestamp NOT NULL DEFAULT current_timestamp() comment '创建时间',
PRIMARY KEY (`id`),
KEY `idx_llm_call_trace_call` (`call_id`),
KEY `idx_llm_call_trace_org` (`org_id`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci comment='模型调用原文追踪(上行下行)';

View File

@ -18,6 +18,7 @@ import asyncio
import json
import logging
import time
from datetime import datetime
from ahserver.serverenv import ServerEnv
@ -30,6 +31,8 @@ _BATCH = 200 # 每批处理条数
_LOCK_KEY = 'llm_acc:lock'
_LOCK_TTL = 90 # 锁 TTL略大于单批预算耗时
_trace_cleanup_day = '' # trace 过期清理已跑日期(进程内每日一次)
async def _get_pending(sor):
recs = await sor.sqlExe(
@ -178,6 +181,25 @@ async def _run_one_pass():
return ok_n
async def _maybe_cleanup_traces():
"""trace 保留期清理:每日最多跑一次(按日期字符串判定,循环间隔 60s 足够)。
trace.py 铁律 3 的落地点cleanup_expired 此前无人调用文档承诺
"记账 worker 循环每日调一次"但循环里没接2026-09-11 补齐
永不抛出清理失败不反噬出账主循环
"""
global _trace_cleanup_day
today = datetime.now().strftime('%Y%m%d')
if today == _trace_cleanup_day:
return
_trace_cleanup_day = today
try:
from .trace import cleanup_expired
await cleanup_expired()
except Exception as e:
logger.warning("pipeline_llm.accounting: trace 过期清理失败(明日重试): %s", e)
async def llm_usage_accounting_loop():
"""后台循环:抢分布式锁 → 出账一批 → 睡。永不抛出。
@ -187,6 +209,9 @@ async def llm_usage_accounting_loop():
logger.info("[pipeline_llm] 异步出账循环已启动(间隔 %ds", _SCAN_INTERVAL)
while True:
try:
# trace 过期清理(每日一次;放抢锁前——锁只护出账防重复记账,
# 清理是幂等操作(目录整删+按 created_at 删行),多进程同日跑无害)
await _maybe_cleanup_traces()
r = _redis()
if r is not None:
# 抢锁多进程只跑一个Redis 故障则直接跑(与限流 fail-open 一致)

View File

@ -643,10 +643,18 @@ async def llm_call_trace_io(params_kw, user_org_id='0'):
return _msg_widget('缺少或非法的 trace id 参数')
db, dbname = _get_sor()
def _row2dict(r):
# sqlExe 行可能是 DictObject(dict 子类) 或普通对象——与 llm_usage_query 同款双兜底
try:
return dict(r)
except (TypeError, ValueError):
return {k: v for k, v in vars(r).items() if not callable(v)}
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM llm_call_trace WHERE id=${id}$", {'id': trace_id})
row = dict(recs[0]) if recs else None
row = _row2dict(recs[0]) if recs else None
batch_rows, usage_rows = [], []
if row and row.get('call_id'):
call_id = row['call_id']
@ -654,13 +662,13 @@ async def llm_call_trace_io(params_kw, user_org_id='0'):
"SELECT seq, kind, method, status_code, elapsed_ms, url, note "
"FROM llm_call_trace WHERE call_id=${c}$ ORDER BY seq, created_at",
{'c': call_id})
batch_rows = [dict(r) for r in (recs2 or [])]
batch_rows = [_row2dict(r) for r in (recs2 or [])]
recs3 = await sor.sqlExe(
"SELECT u.status, u.accounting_status, u.cost, u.charge, u.task_ref, "
"u.project_id, m.name AS model_name "
"FROM llm_usage u LEFT JOIN llm_model m ON m.id=u.model_id "
"WHERE u.call_id=${c}$", {'c': call_id})
usage_rows = [dict(r) for r in (recs3 or [])]
usage_rows = [_row2dict(r) for r in (recs3 or [])]
await sor.sqlExe("COMMIT", {})
if not row:

View File

@ -88,7 +88,7 @@ async def record_roundtrip(ctx, seq, kind, url, method, headers, req_body,
ctx govern_resolve 返回的上下文须含 call_id call_id 直接跳过
没有关联键的孤儿 trace 无意义
seq 往返序号同步重试 0..2异步提交 0轮询 1..N
kind chat / submit / query
kind chat / gen同步生成 / submit / query
resp_data 响应体dict/str 均可网络异常时 None
err 错误摘要网络异常/非200

View File

@ -29,6 +29,8 @@ PATHS_LOGINED = [
]
# api/(管理)与生成的 CRUD 目录用通配符一次覆盖
# (含 llm_call_trace 生成页、llm_project_cost/index.ui、api/*.dspy——
# rbac check_roles_path 支持 ** 后缀前缀匹配,新页面无需逐条注册)
PATHS_WILDCARD_LOGINED = [
f"/{MOD}/**",
]

View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""llm_call_trace_io 行为验证stub harness不依赖真实 DB/宿主)。
覆盖场景
1. id / 超长 id Message 提示
2. 查无记录 Message 提示
3. 机构隔离普通机构看别家行 拒绝平台 org '0' 放行本机构 放行
4. 正常行 TabPanel tabsummary/req/respCodeEditor readonly摘要含
usage 上下文 + 同批次行
5. 路径穿越req_path='../../etc/passwd' [非法路径拒绝访问]
6. 文件缺失 提示无原文文件
"""
import asyncio
import json
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ---- stub: 假 filesroot + 真实 trace 文件 ----
tmp = tempfile.mkdtemp()
FILESROOT = tmp
trace_rel = 'llm_trace/20260911/callA_000'
os.makedirs(os.path.join(FILESROOT, 'llm_trace/20260911'), exist_ok=True)
with open(os.path.join(FILESROOT, trace_rel + '.req.json'), 'w') as f:
json.dump({'url': 'https://up/v1/chat', 'method': 'POST', 'kind': 'chat',
'headers': {'Authorization': '[REDACTED]'},
'body': {'model': 'm1', 'messages': [{'role': 'user', 'content': 'hi <b>'}]}}, f)
with open(os.path.join(FILESROOT, trace_rel + '.resp.json'), 'w') as f:
json.dump({'status_code': 200, 'elapsed_ms': 123, 'error': '',
'body': {'choices': [{'message': {'content': 'hello'}}]}}, f)
# ---- stub: appPublic.jsonConfig.getConfig 返回假 filesroot ----
import types
fake_appPublic = types.ModuleType('appPublic')
fake_jc = types.ModuleType('appPublic.jsonConfig')
class _Cfg:
filesroot = FILESROOT
fake_jc.getConfig = lambda *a, **k: _Cfg()
sys.modules['appPublic.jsonConfig'] = fake_jc
# ---- stub: DBPools / sqlor ----
TRACE_ROW = {
'id': 'tid001', 'call_id': 'callA', 'seq': 0, 'kind': 'chat',
'url': 'https://up/v1/chat/completions', 'method': 'POST',
'status_code': 200, 'elapsed_ms': 123,
'req_path': trace_rel + '.req.json', 'resp_path': trace_rel + '.resp.json',
'org_id': 'org9', 'note': '', 'created_at': '2026-09-11 10:00:00',
}
BATCH_ROWS = [TRACE_ROW, {**TRACE_ROW, 'id': 'tid002', 'seq': 1, 'kind': 'query',
'status_code': 200, 'note': ''}]
USAGE_ROWS = [{'status': 'SUCCEEDED', 'accounting_status': 'accounted', 'cost': '0.01',
'charge': '0.02', 'task_ref': 'sess1', 'project_id': '0', 'model_name': 'qwen-x'}]
class FakeSor:
def __init__(self, db): self.db = db
async def sqlExe(self, sql, ns):
self.db.calls.append((sql, ns))
if 'FROM llm_call_trace WHERE id=' in sql:
return [dict(TRACE_ROW)] if not self.db.no_row else []
if 'FROM llm_call_trace WHERE call_id=' in sql:
return [dict(r) for r in BATCH_ROWS]
if 'FROM llm_usage u LEFT JOIN llm_model' in sql:
return [dict(r) for r in USAGE_ROWS]
return []
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
class FakeDB:
def __init__(self): self.databases = {}; self.no_row = False; self.calls = []
def sqlorContext(self, dbname): return FakeSor(self)
_FAKE_DB = FakeDB()
import sqlor.dbpools as dbpools
dbpools.DBPools = lambda *a, **k: _FAKE_DB
# ---- stub: ahserver.serverenv.ServerEnv ----
class FakeEnv(dict):
_inst = None
def __new__(cls):
if FakeEnv._inst is None: FakeEnv._inst = super().__new__(cls)
return FakeEnv._inst
def __getattr__(self, k):
try: return self[k]
except KeyError: raise AttributeError(k)
import ahserver.serverenv as serverenv
serverenv.ServerEnv = FakeEnv
# ---- stub: gateway._get_dbtrace.py/accounting 用)----
import pipeline_llm.gateway as gw
gw._get_db = lambda: (_FAKE_DB, 'pipeline')
import pipeline_llm.init as m
FAILS = []
def check(name, cond, detail=''):
print(('PASS' if cond else 'FAIL'), name, detail if not cond else '')
if not cond: FAILS.append(name)
def find_widget(w, wtype):
if isinstance(w, dict):
if w.get('widgettype') == wtype: return w
for v in w.values():
r = find_widget(v, wtype)
if r: return r
elif isinstance(w, list):
for v in w:
r = find_widget(v, wtype)
if r: return r
return None
async def main():
# 1. 缺 id / 超长 id
r = await m.llm_call_trace_io({}, '0')
check('缺id → Message', r['widgettype'] == 'Message')
r = await m.llm_call_trace_io({'id': 'x' * 40}, '0')
check('超长id → Message', r['widgettype'] == 'Message')
# 2. 查无记录
_FAKE_DB.no_row = True
r = await m.llm_call_trace_io({'id': 'tid001'}, '0')
check('查无 → Message', r['widgettype'] == 'Message')
_FAKE_DB.no_row = False
# 3. 机构隔离
r = await m.llm_call_trace_io({'id': 'tid001'}, 'other_org')
check('他机构 → 拒绝', r['widgettype'] == 'Message' and '无权' in r['options']['message'])
r = await m.llm_call_trace_io({'id': 'tid001'}, '0')
check("平台org'0' → 放行", r['widgettype'] == 'TabPanel')
r = await m.llm_call_trace_io({'id': 'tid001'}, 'org9')
check('本机构 → 放行', r['widgettype'] == 'TabPanel')
# 4. TabPanel 结构
items = r['options']['items']
check('3个tab', len(items) == 3)
check('tab名', [t['name'] for t in items] == ['summary', 'req', 'resp'])
ce_req = items[1]['content']
check('req是CodeEditor', ce_req['widgettype'] == 'CodeEditor')
check('readonly', ce_req['options']['readonly'] is True)
check('mode null', ce_req['options']['mode'] == 'null')
val = json.loads(ce_req['options']['value'])
check('req原文含REDACTED头', val['headers']['Authorization'] == '[REDACTED]')
check('req原文含body', val['body']['messages'][0]['content'] == 'hi <b>')
resp_val = json.loads(items[2]['content']['options']['value'])
check('resp原文body', resp_val['body']['choices'][0]['message']['content'] == 'hello')
summary_html = find_widget(items[0]['content'], 'Html')['options']['html']
check('摘要含usage模型名', 'qwen-x' in summary_html)
check('摘要含批次往返数', '同批次往返2 次)' in summary_html)
# HTML 转义:摘要走 Html widget正文里任何 < > 必须被 escape防注入
TRACE_ROW['note'] = '<script>alert(1)</script>'
r2 = await m.llm_call_trace_io({'id': 'tid001'}, '0')
s2 = find_widget(r2['options']['items'][0]['content'], 'Html')['options']['html']
check('note<script>被转义', '<script>' not in s2 and '&lt;script&gt;' in s2)
TRACE_ROW['note'] = ''
# CodeEditor value 是 JSON 字符串(非 HTMLreq body 里的 <b> 原样保留不转义
check('req原文<b>原样', 'hi <b>' in items[1]['content']['options']['value'])
# 5. 路径穿越
bad = dict(TRACE_ROW)
bad['req_path'] = '../../etc/passwd'
txt, err = m._read_trace_file('../../etc/passwd')
check('穿越拒绝', err == '[非法路径,拒绝访问]' and txt == '')
txt, err = m._read_trace_file('llm_trace/20260911/../../etc/passwd')
check('穿越拒绝2(中间..)', err == '[非法路径,拒绝访问]')
# 6. 文件缺失
txt, err = m._read_trace_file('llm_trace/20260911/nonexist.req.json')
check('缺文件报错', '文件读取失败' in err)
txt, err = m._read_trace_file('')
check('空路径提示', '无原文文件' in err)
print()
if FAILS:
print('FAILED:', FAILS); sys.exit(1)
print('ALL PASS')
asyncio.run(main())

View File

@ -20,8 +20,14 @@ titles = {
'llm_org_quota': '组织限额限流',
'llm_user_quota': '个人限额限流',
'llm_usage': '用量流水',
'llm_call_trace': '调用原文追踪',
}
if target not in titles:
# 非 CRUD 的定制页面入口(页面路径白名单)
# llm_project_cost: 按项目费用统计页2026-09-10llm_project_cost_query 的展示页)
page_targets = {
'llm_project_cost': ('按项目费用', '/pipeline-llm/llm_project_cost/index.ui'),
}
if target not in titles and target not in page_targets:
return _json.dumps({
"widgettype": "PopupWindow",
"options": {"title": "提示", "cwidth": 40, "cheight": 10, "auto_open": True},
@ -29,6 +35,18 @@ if target not in titles:
"text": "无效的功能入口:" + target, "padding": "20px"}}]
}, ensure_ascii=False)
if target in page_targets:
_page_title, _page_url = page_targets[target]
_page_resp = {
"widgettype": "PopupWindow",
"id": "llm_card_pw",
"options": {"title": _page_title, "width": "85%", "height": "85%", "auto_open": True},
"subwidgets": [{
"widgettype": "urlwidget",
"options": {"url": entire_url(_page_url)}}]
}
return _json.dumps(_page_resp, ensure_ascii=False)
subwidgets = []
if target == 'llm_model':
# 模型注册工具栏:自动配置(内部助手全链)+ 上线检查(真实调用测试+记账检查)

View File

@ -1,11 +1,15 @@
# llm_project_cost_query.dspy — 按项目统计费用2026-09-10 用户定夺)
# 入参 params_kw: org_id? / date_from? / date_to? / status?
# 出参: rows[{project_id, project_name, calls, cost, charge, req_tokens, resp_tokens}]
# + summary{project_count, calls, cost, charge}
# project_id 过滤:不传/null = 不区分项目(全部分组都出)
# 非项目调用(哨兵 project_id='0')归为「(非项目调用)」一组
# Tabular data_url 端点:出参必须是 {total, rows}PageDataLoader 契约),
# 模块函数返回 {success, rows, summary} → 此处重排;查询本身 LIMIT 500 不分页,
# total=len(rows) 即真实总数。summary 以 tailer 不适用,直接丢弃(页面顶部
# 统计如需再加)。
# 入参 params_kw: date_from? / date_to? / status?InlineForm 查询按钮注入)
uid = await get_user()
if not uid:
return {"success": False, "message": "未登录"}
return {"total": 0, "rows": []}
pk = dict(params_kw or {})
return json.loads(await llm_project_cost_query(pk))
data = json.loads(await llm_project_cost_query(pk))
if not data.get('success'):
return {"total": 0, "rows": []}
rows = data.get('rows') or []
return {"total": len(rows), "rows": rows}

View File

@ -294,6 +294,74 @@
}
}
]
},
{
"widgettype": "VBox",
"options": {
"css": "card",
"cwidth": 25,
"padding": "16px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var old=bricks.getWidgetById('llm_card_pw',bricks.app);if(old&&old.destroy){old.destroy();}var r=await fetch('{{entire_url('/pipeline-llm/api/card_popup.dspy')}}?target=llm_project_cost');var d=await r.json();if(d&&d.widgettype){bricks.widgetBuild(d,bricks.app);}else{bricks.show_message({title:'打开失败',message:'未能加载功能窗口'});}"
}
],
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "按项目费用",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "按项目分组统计调用次数/成本/客户应付/token非项目调用单列一组",
"cfontsize": 1.2
}
}
]
},
{
"widgettype": "VBox",
"options": {
"css": "card",
"cwidth": 25,
"padding": "16px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var old=bricks.getWidgetById('llm_card_pw',bricks.app);if(old&&old.destroy){old.destroy();}var r=await fetch('{{entire_url('/pipeline-llm/api/card_popup.dspy')}}?target=llm_call_trace');var d=await r.json();if(d&&d.widgettype){bricks.widgetBuild(d,bricks.app);}else{bricks.show_message({title:'打开失败',message:'未能加载功能窗口'});}"
}
],
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "调用原文追踪",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "每次上游往返的上行请求/下行响应原文脱敏落盘保留90天选中行点「查看IO」",
"cfontsize": 1.2
}
}
]
}
]
}

View File

@ -0,0 +1,52 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "project_cost_filter",
"options": {
"css": "card", "padding": "8px", "submit_label": "查询",
"fields": [
{"name": "date_from", "label": "开始日期", "uitype": "date", "cwidth": 12},
{"name": "date_to", "label": "结束日期", "uitype": "date", "cwidth": 12},
{"name": "status", "label": "状态", "uitype": "code", "cwidth": 10,
"data": [
{"value": "", "text": "全部"},
{"value": "SUCCEEDED", "text": "成功"},
{"value": "FAILED", "text": "失败"}
]}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "project_cost_table",
"script": "var tbl = bricks.getWidgetById('project_cost_table', bricks.app); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "project_cost_table",
"options": {
"title": "按项目费用统计",
"description": "按 llm_usage.project_id 分组汇总(成本=账号侧,应付=客户侧project_id='0' 为非项目调用;缺省显示全部状态",
"width": "100%", "css": "card",
"data_url": "{{entire_url('../api/llm_project_cost_query.dspy')}}",
"data_method": "GET", "page_rows": 50,
"row_options": {
"browserfields": {
"exclouded": ["project_id"]
},
"fields": [
{"name": "project_name", "title": "项目", "type": "str", "length": 100, "cwidth": 30},
{"name": "calls", "title": "调用次数", "type": "int", "cwidth": 10},
{"name": "cost", "title": "成本合计(元)", "type": "float", "cwidth": 12},
{"name": "charge", "title": "客户应付合计(元)", "type": "float", "cwidth": 14},
{"name": "req_tokens", "title": "输入token", "type": "int", "cwidth": 12},
{"name": "resp_tokens", "title": "输出token", "type": "int", "cwidth": 12}
]
}
}
}
]
}