- 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/生成目录不入库
180 lines
7.4 KiB
Python
180 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
||
"""llm_call_trace_io 行为验证(stub harness,不依赖真实 DB/宿主)。
|
||
|
||
覆盖场景:
|
||
1. 无 id / 超长 id → Message 提示
|
||
2. 查无记录 → Message 提示
|
||
3. 机构隔离:普通机构看别家行 → 拒绝;平台 org '0' → 放行;本机构 → 放行
|
||
4. 正常行 → TabPanel 三 tab(summary/req/resp),CodeEditor 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_db(trace.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 '<script>' in s2)
|
||
TRACE_ROW['note'] = ''
|
||
# CodeEditor value 是 JSON 字符串(非 HTML),req 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())
|