From 69fe362ddee809307f12ce1b0e3ea746ecb04a1b Mon Sep 17 00:00:00 2001 From: yumoqing Date: Fri, 11 Sep 2026 14:58:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(trace):=20=E8=B0=83=E7=94=A8=E5=8E=9F?= =?UTF-8?q?=E6=96=87=E8=BF=BD=E8=B8=AA=E3=80=8C=E6=9F=A5=E7=9C=8BIO?= =?UTF-8?q?=E3=80=8D=E5=89=8D=E7=AB=AF=E9=97=AD=E7=8E=AF+=E6=8C=89?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=B4=B9=E7=94=A8=E9=A1=B5(2026-09-10?= =?UTF-8?q?=E5=AE=9A=E5=A4=BA=E8=90=BD=E5=9C=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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成垃圾串, 行数据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/生成目录不入库 --- .gitignore | 1 + README.md | 3 +- docs/design-spec.md | 30 +++- json/llm_call_trace_list.json | 2 +- models/llm_call_trace.json | 2 +- mysql.ddl.sql | 22 ++- pipeline_llm/accounting.py | 25 ++++ pipeline_llm/init.py | 14 +- pipeline_llm/trace.py | 2 +- scripts/load_path.py | 2 + scripts/test_llm_call_trace_io.py | 179 ++++++++++++++++++++++++ wwwroot/api/card_popup.dspy | 20 ++- wwwroot/api/llm_project_cost_query.dspy | 18 ++- wwwroot/index.ui | 68 +++++++++ wwwroot/llm_project_cost/index.ui | 52 +++++++ 15 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 scripts/test_llm_call_trace_io.py create mode 100644 wwwroot/llm_project_cost/index.ui diff --git a/.gitignore b/.gitignore index bd740be..662d6ee 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 9c909ce..156fd85 100644 --- a/README.md +++ b/README.md @@ -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/recharge;accounting_status 仅 SUCCEEDED 行有意义否则 NULL;**usages 是计费唯一事实源**——上游 usage 原文 JSON 全量不截断,token 计数与按量因子都在其中;req_tokens/resp_tokens 冗余列已删(m0021),统计查询走 JSON_EXTRACT;project_id 关联 sd_projects 按项目统计费用(非项目调用哨兵'0',查询不传=不区分项目,m0025);call_id 关联 llm_call_trace 上行/下行原文) | +| `llm_call_trace` | 调用原文追踪(2026-09-10 用户定夺):每次上游 HTTP 往返一行(chat/gen/submit/query),请求/响应原文脱敏落盘 filesroot/llm_trace/<日>/_.req/.resp.json(Authorization 等敏感 header 值替换 [REDACTED],URL 去 query);call_id 与 llm_usage.call_id 同值——按流水反查全部原文;列表页「查看IO」弹窗(llm_call_trace_io.dspy → env.llm_call_trace_io)展示上下文摘要+请求/响应 CodeEditor 三 Tab,realpath 前缀校验防路径穿越,机构隔离(org_id='0' 平台可看全部);保留 90 天(TRACE_RETENTION_DAYS),过期清理由记账 worker 循环每日调 cleanup_expired(accounting._maybe_cleanup_traces);文件不进 dbackup 库备份,需另行同步备份 | ## 调用门禁链 diff --git a/docs/design-spec.md b/docs/design-spec.md index dc706ca..5e63112 100644 --- a/docs/design-spec.md +++ b/docs/design-spec.md @@ -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//_.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_io(TabPanel 三页:上下文摘要/请求原文/响应原文,CodeEditor +readonly;realpath 前缀校验不逃逸 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 取值,先行收敛) diff --git a/json/llm_call_trace_list.json b/json/llm_call_trace_list.json index 97cbb44..ca57425 100644 --- a/json/llm_call_trace_list.json +++ b/json/llm_call_trace_list.json @@ -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')}}" } } ] diff --git a/models/llm_call_trace.json b/models/llm_call_trace.json index bb1d602..7bc5dba 100644 --- a/models/llm_call_trace.json +++ b/models/llm_call_trace.json @@ -31,7 +31,7 @@ }, { "name": "kind", - "title": "往返类型(chat对话/submit异步提交/query任务查询)", + "title": "往返类型(chat对话/gen同步生成/submit异步提交/query任务查询)", "type": "str", "length": 20 }, diff --git a/mysql.ddl.sql b/mysql.ddl.sql index 2dc0868..bb409ea 100644 --- a/mysql.ddl.sql +++ b/mysql.ddl.sql @@ -1,5 +1,6 @@ --- pipeline-llm 模块数据表(8 张,全部 llm_ 前缀,pipeline 库) +-- pipeline-llm 模块数据表(9 张,全部 llm_ 前缀,pipeline 库) -- 手写幂等 DDL(IF 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='模型调用原文追踪(上行下行)'; diff --git a/pipeline_llm/accounting.py b/pipeline_llm/accounting.py index d622df3..38ecabf 100644 --- a/pipeline_llm/accounting.py +++ b/pipeline_llm/accounting.py @@ -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 一致) diff --git a/pipeline_llm/init.py b/pipeline_llm/init.py index d80c96c..e794df5 100644 --- a/pipeline_llm/init.py +++ b/pipeline_llm/init.py @@ -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: diff --git a/pipeline_llm/trace.py b/pipeline_llm/trace.py index 0a1a065..301343a 100644 --- a/pipeline_llm/trace.py +++ b/pipeline_llm/trace.py @@ -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 等) diff --git a/scripts/load_path.py b/scripts/load_path.py index 4611a66..7deb2c2 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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}/**", ] diff --git a/scripts/test_llm_call_trace_io.py b/scripts/test_llm_call_trace_io.py new file mode 100644 index 0000000..62a8fce --- /dev/null +++ b/scripts/test_llm_call_trace_io.py @@ -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 三 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 '}]}}, 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 ') + 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'] = '' + r2 = await m.llm_call_trace_io({'id': 'tid001'}, '0') + s2 = find_widget(r2['options']['items'][0]['content'], 'Html')['options']['html'] + check('note