Skip to content

feat: add public multi-user runtime protections - #1

Merged
hubo1989 merged 1 commit into
mainfrom
codex/public-multi-user-email-registration
Jul 29, 2026
Merged

hubo1989 merged 1 commit into
mainfrom
codex/public-multi-user-email-registration

Conversation

@hubo1989

@hubo1989 hubo1989 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • add email registration, one-time verification codes, SMTP delivery, replay protection, and rate limits
  • add persistent token quota enforcement and sandbox fail-closed protections for untrusted users
  • add LiteLLM managed-PostgreSQL overlay for virtual keys, budgets, spend, and rate limits
  • update frontend registration flow, Compose examples, and deployment documentation

Validation

  • target backend regression tests: 220 passed
  • frontend TypeScript, i18n checks, ESLint, and production build passed
  • Docker Compose merge validation and git diff checks passed
  • full pytest collection remains blocked by the existing missing optional python-telegram-bot dependency

The local 教学产物/ directory was intentionally left out of this PR because it is a local teaching artifact, not part of the runtime change.

Summary by CodeRabbit

  • 新功能

    • 支持通过邮箱验证码注册账号,并提供发送、重发及安全限流。
    • 多用户可配置每日和每月 Token 配额,系统会在请求前后进行用量管理。
    • 检索结果可自动携带相关页面图片,增强多模态回答效果。
    • 新增可选 LiteLLM 网关及持久化用量追踪配置。
  • 改进

    • 加强沙箱隔离与权限控制,默认拒绝不满足隔离条件的执行请求。
    • 可视化页面加载失败时显示明确提示,并提前检查脚本语法。
    • 完善部署、邮件注册及安全配置文档。

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

本次变更加入邮件验证码注册、用户 token 配额账本、RAG 图片附件处理、HTML 脚本语法校验、沙箱隔离收紧,以及可选 LiteLLM Compose 部署配置和相关文档。

Changes

多用户认证与配额

Layer / File(s) Summary
邮箱验证注册流程
deeptutor/api/routers/auth.py, deeptutor/services/email_verification.py, deeptutor/services/auth.py, deeptutor/multi_user/*, web/app/(auth)/*, web/lib/auth.ts, tests/api/*, tests/services/test_email_verification.py
注册改为邮箱验证码流程,验证码支持限流、冷却、过期、一次性消费和 SMTP 投递;首个已验证账号可成为管理员。
Token 配额账本与调用门控
deeptutor/multi_user/token_quota.py, deeptutor/core/agentic/client.py, deeptutor/services/llm/factory.py, web/features/multi-user/*, tests/multi_user/*
新增按用户、按周期的 SQLite token 配额预留与结算,并接入同步、流式 LLM 调用和授权编辑界面。

RAG 图片与可视化校验

Layer / File(s) Summary
RAG 页面图片附件
deeptutor/services/rag/pipelines/llamaindex/*, deeptutor/agents/chat/*, tests/agents/chat/*, tests/services/rag/*
文档解析保留页面文本和图片路径;聊天流程校验路径、大小和格式,去重后以 Base64 多模态附件注入消息。
可视化脚本与加载校验
deeptutor/agents/visualize/*, deeptutor/agents/visualize/utils.py, web/components/visualize/*, web/locales/*, tests/agents/visualize/*
HTML 生成规则限制字符串写法,本地使用 Node 检查内联 JavaScript,并在 iframe 加载失败时显示错误状态。

沙箱与可选 LiteLLM 部署

Layer / File(s) Summary
沙箱隔离与工作区边界
deeptutor/services/sandbox/*, deeptutor/services/config/runtime_settings.py, docker-compose.yml, Dockerfile.runner, tests/services/sandbox/*
runner 默认仅允许管理员任务工作区;普通用户在不满足隔离策略时拒绝执行,Compose 不再挂载全量用户工作区。
SMTP 与 LiteLLM Compose 配置
compose.yaml, docker-compose*.yml, compose.litellm.yaml, ops/litellm/*, .env.example
Compose 服务新增 SMTP 环境变量;新增可选 LiteLLM 服务、健康检查、内部网络、环境变量和只读配置挂载。
部署与运行说明
README.md, CONTAINERIZATION.md
文档更新邮件注册、token 配额、LiteLLM 数据边界、Office skills 及沙箱权限说明。

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant AgenticChatPipeline
  participant LlamaIndexPipeline
  participant RuntimeData
  User->>AgenticChatPipeline: submit query
  AgenticChatPipeline->>LlamaIndexPipeline: retrieve sources
  LlamaIndexPipeline-->>AgenticChatPipeline: sources with image_paths
  AgenticChatPipeline->>RuntimeData: validate and read images
  RuntimeData-->>AgenticChatPipeline: image bytes
  AgenticChatPipeline-->>User: multimodal LLM message
Loading

Poem

兔子挥爪把新码发,
配额账本稳稳落家。
图片跳进消息篮,
沙箱关门守边界,
LiteLLM 在旁边眨眼,
新流程跑得更漂亮。

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning 描述只给了摘要和验证结果,未按模板提供 Description、Related Issues、Module(s) Affected、Checklist 和 Additional Notes。 请按仓库模板补全各节标题与内容,尤其是相关 Issues、受影响模块、检查清单和补充说明。
Docstring Coverage ⚠️ Warning Docstring coverage is 23.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 标题简洁且准确概括了本次新增的多用户运行时保护、注册验证和配额/沙箱限制。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/public-multi-user-email-registration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/app/(auth)/register/page.tsx (1)

106-119: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

改邮箱/口令后未重置 codeSent,且重发冷却硬编码 60 秒。

  1. 验证码挑战在后端与"邮箱 + 当次口令哈希"绑定。用户已发码后修改邮箱或口令,codeSent 仍为 true,提交会命中后端 400「验证码无效」,用户无法从提示中理解原因。
  2. setCountdown(60) 与后端可配置的 verification_resend_cooldown_seconds(30~3600)不一致,冷却被调大时前端会提前放开按钮并撞 429。
🔁 建议在关键字段变更时清空挑战状态
+  function resetChallenge() {
+    setCodeSent(false);
+    setCode("");
+    setNotice("");
+  }
+
@@
               value={email}
-              onChange={(e) => setEmail(e.target.value)}
+              onChange={(e) => {
+                setEmail(e.target.value);
+                resetChallenge();
+              }}

Also applies to: 171-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/`(auth)/register/page.tsx around lines 106 - 119,
更新注册表单中的邮箱和口令变更处理(包括 email 输入的 setEmail 以及对应的口令更新逻辑):每次关键字段变化时清空
codeSent,并重置相关验证码挑战状态,避免继续提交旧验证码。将重发冷却的 setCountdown(60) 改为使用后端
verification_resend_cooldown_seconds 配置值,确保按钮状态与服务端冷却时间一致。
🧹 Nitpick comments (17)
tests/services/config/test_runtime_settings.py (1)

125-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

补齐邮箱验证相关环境变量的断言。

当前仅断言了自注册、是否需要邮箱验证和验证码 TTL;新增的冷却时间、最大尝试次数以及邮箱/IP 每小时限流配置仍未覆盖。建议同时断言这些键,避免 render_environment() 的映射回归漏测。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/services/config/test_runtime_settings.py` around lines 125 - 127,
Extend the environment assertions in the runtime settings test alongside
DEEPTUTOR_EMAIL_VERIFICATION_REQUIRED and
DEEPTUTOR_VERIFICATION_CODE_TTL_MINUTES to cover the new verification cooldown,
maximum-attempts, and email/IP hourly rate-limit keys, asserting each rendered
value matches its configured setting.
docker-compose.litellm.yml (1)

13-13: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

不要在部署 overlay 中使用 main-latest

生产部署应固定到经过审核的版本或 immutable digest,否则镜像内容会随上游变更,导致不可复现部署和未经验证的运行时升级。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.litellm.yml` at line 13, 更新 docker-compose.litellm.yml 中的
LITELLM_IMAGE 默认值,移除可变的 main-latest 标签,改用经过审核的固定版本标签或 immutable
digest;保留通过环境变量覆盖镜像的能力。
deeptutor/services/rag/pipelines/llamaindex/document_loader.py (2)

83-91: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

回退分支的 image_paths 未做 resolve(),与按页分支不一致。

_page_image_paths_from_blocks 写入的是 str(resolved),而这里直接用 str(source.path)。若 asset_dir 是相对路径,下游 _attach_rag_imagesresolve() 会基于进程 CWD 解析,可能落到运行时数据目录之外而被静默丢弃。建议统一写入绝对路径。

♻️ 统一为绝对路径
                     extra_metadata={
-                        "image_paths": [str(source.path) for source in extracted_images],
+                        "image_paths": [str(source.path.resolve()) for source in extracted_images],
                     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/services/rag/pipelines/llamaindex/document_loader.py` around lines
83 - 91, Update the fallback branch in the document-loading flow to store
resolved absolute image paths in the `image_paths` metadata, matching
`_page_image_paths_from_blocks`. Resolve each `source.path` before converting it
to a string, while leaving the existing `_append_if_nonempty` behavior
unchanged.

445-458: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

建议无条件排除 image_paths,并避免两个字段共享同一列表对象。

按页分支在无图页会写入 "image_paths": [],此时未加入排除列表,空值仍会进入 embed/LLM 元数据文本。另外同一 list 实例同时传给两个 excluded_* 参数,若上游对其做原地修改会相互影响。

♻️ 建议调整
-            excluded_metadata_keys = []
-            if metadata.get("image_paths"):
-                # Keep paths available for post-retrieval Vision attachments,
-                # but do not serialize long cache paths into the text used by
-                # LlamaIndex's embed/LLM metadata token budget.
-                excluded_metadata_keys.append("image_paths")
+            # Keep paths available for post-retrieval Vision attachments, but
+            # do not serialize long cache paths into the text used by
+            # LlamaIndex's embed/LLM metadata token budget.
+            excluded = ["image_paths"] if "image_paths" in metadata else []
             documents.append(
                 Document(
                     text=text,
                     metadata=metadata,
-                    excluded_embed_metadata_keys=excluded_metadata_keys,
-                    excluded_llm_metadata_keys=excluded_metadata_keys,
+                    excluded_embed_metadata_keys=list(excluded),
+                    excluded_llm_metadata_keys=list(excluded),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/services/rag/pipelines/llamaindex/document_loader.py` around lines
445 - 458, Update the Document construction in the document-loading flow to
always exclude the "image_paths" metadata key, including when its value is an
empty list. Provide separate list instances to excluded_embed_metadata_keys and
excluded_llm_metadata_keys so mutations to one cannot affect the other.
tests/agents/chat/test_rag_image_attachments.py (1)

11-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

建议补充上限与后缀白名单的用例。

当前覆盖了去重与目录逃逸两条主路径,但 RAG_IMAGE_MAX_ATTACHMENTS 截断、RAG_IMAGE_MAX_BYTES 超限跳过、以及非图片后缀(如 .svg/.pdf)被拒这三条分支尚无断言。需要我补一组用例吗?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/agents/chat/test_rag_image_attachments.py` around lines 11 - 66, 在现有
RAG 图片附件测试中补充用例,覆盖 RAG_IMAGE_MAX_ATTACHMENTS 达到上限时截断、超过 RAG_IMAGE_MAX_BYTES
的文件被跳过,以及 .svg、.pdf 等非白名单后缀被拒绝;分别断言返回的新增附件数量和 context.attachments 内容符合预期。
web/components/visualize/VisualizationViewer.tsx (1)

172-182: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

用整段 HTML 作 key,并在每次 onLoad 重置高度会引入跳动。

key={prepared} 把完整 HTML 字符串放进 React 的 key 比较路径,内容较大时开销无谓;srcDoc 变化本身已会重新加载文档,如需强制重建可改用短哈希。另外 setHeight(560) 会在每次加载后把高度打回默认值,随后才被 iframe 上报的真实高度覆盖,用户可见一次高度跳变。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/components/visualize/VisualizationViewer.tsx` around lines 172 - 182,
Update the iframe rendering in VisualizationViewer so key no longer uses the
full prepared HTML string; use a short stable hash only if remounting is
required, otherwise remove the key. In the onLoad handler, stop unconditionally
resetting height to 560, while preserving load-error clearing and subsequent
iframe-reported height behavior.
deeptutor/agents/visualize/utils.py (1)

62-86: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

将 HTML JavaScript 同步校验放到异步请求路径之外

validate_visualizationVisualizeCapability.runreviewing 阶段被调用,其中 HTML 路径会为每个非空 <script> 执行一次同步 subprocess.run(["node", "--check"], timeout=3)。恶意生成的多脚本 HTML 会阻塞事件循环最多 3s × 脚本数。建议包一层 asyncio.to_thread,并对校验脚本总数与总耗时设置上限;subprocess 这里固定 argv + stdin 输入,不属于典型 subprocess-from-request CWE-78。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/agents/visualize/utils.py` around lines 62 - 86, Update
_validate_inline_javascript and its validate_visualization call path so Node
syntax checks run via asyncio.to_thread rather than blocking the event loop.
Enforce limits on the total number of scripts checked and the aggregate
validation duration, while preserving the current error-detail behavior and
fixed argv/stdin subprocess invocation.

Source: Linters/SAST tools

deeptutor/services/auth.py (1)

295-307: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

每次令牌校验都会同步读取 users.json

decode_token 处于所有已认证请求的热路径上,新增的 _load_users() 会在事件循环线程里做磁盘 JSON 解析(并可能触发 _write_users 归一化回写)。禁用/撤销校验本身是对的,建议为用户存储加一层按 mtime 失效的短 TTL 缓存。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/services/auth.py` around lines 295 - 307, 为 decode_token
中的用户状态校验增加基于 users.json mtime 的短 TTL 缓存,避免每次认证请求都同步读取和解析文件。更新 _load_users
或其调用路径,使缓存仅在文件 mtime 变化或 TTL 过期时重新加载,并保留 disabled 与 email_verified 校验逻辑及必要的
_write_users 归一化行为。
deeptutor/api/routers/auth.py (2)

556-560: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

email_verification_required=False 会让注册整体返回 503,字段等价于"关闭注册"。

配置项名字暗示"是否强制邮箱验证",实际取 False 时两个端点都不可用,管理员可能误以为能得到免验证注册。建议在该配置的文档/UI 说明中明确它只允许为 True,或直接改用 self_registration_enabled 单一开关表达。

Also applies to: 630-634

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/api/routers/auth.py` around lines 556 - 560, 明确
email_verification_required 配置在相关注册端点中的实际语义:更新配置文档/UI,说明设为 False 会禁用公开注册并返回
503,而不是提供免验证注册;同时确保 startLine 556 和 630 附近的说明保持一致。不要改变现有注册禁用行为。

584-591: 🔒 Security & Privacy | 🔵 Trivial | ⚖️ Poor tradeoff

响应耗时差可用于账号枚举。

已注册地址走的是零成本分支,未注册地址要执行 bcrypt 哈希并等待 SMTP 投递,两者响应时间量级差异明显,抵消了统一文案带来的防枚举效果。建议把发信改为后台任务(返回不等待投递),或对两条分支施加统一的最小响应时间。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/api/routers/auth.py` around lines 584 - 591, 调整注册处理流程,避免已注册与未注册邮箱因
bcrypt 和 SMTP 投递产生明显响应时差:让 send_verification_email 通过后台任务执行而不阻塞响应,并在包含
check_registration_rate_limit、hash_password 和 issue_challenge
的完整流程上施加统一的最小响应时间,确保两条分支都满足该耗时下限。
deeptutor/services/email_verification.py (2)

276-293: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

过期挑战(含 bcrypt 口令哈希)只在下一次签发时才被清理。

长期没有新注册请求时,pending_registrations 中已过期的记录会无限期保留口令哈希。建议在 consume_challenge(或一个轻量的定期清理入口)中同样执行 DELETE FROM pending_registrations WHERE expires_at <= ?,缩短敏感数据留存窗口。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/services/email_verification.py` around lines 276 - 293, 在
consume_challenge 中加入基于当前时间执行的 pending_registrations 过期记录清理,复用现有的 expires_at
条件和参数化查询;确保即使没有新的签发请求,过期挑战及其口令哈希也会被及时删除,并保持现有挑战消费流程不变。

387-414: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

SMTP 配置绕过了集中式运行时设置,且 TLS/SSL 冲突被静默当作"未配置"。

其余部署参数统一由 RuntimeSettingsService 归一化并导出(该模块文档明确"进程环境覆盖集中在此处理"),这里却直接读 os.getenv,管理端无法查看/校验 SMTP 状态。此外 configureduse_tls and use_ssl 时返回 False,路由只会回 503「暂不可用」,运维看不到真实原因——建议至少在这种冲突配置下打一条 error 日志。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/services/email_verification.py` around lines 387 - 414, 更新
smtp_config,使 SMTP 配置通过 RuntimeSettingsService 的集中式运行时设置读取和归一化,而不是直接调用
os.getenv;保留现有端口、超时和布尔值校验行为。针对 use_tls 与 use_ssl 同时启用的冲突配置,在配置校验或 configured
判定路径中记录明确的 error 日志,并保留现有不可用状态。
tests/api/test_auth_registration.py (1)

113-127: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

建议改用 monkeypatch 并补一个 429 用例。

fixture 已经用 monkeypatch 打桩了 send_verification_email,这里再手工赋值/恢复会与之叠加(恢复的是桩而非真实实现),换成 monkeypatch.setattr 更清晰。另外冷却/限流映射到 429 + Retry-After 是这条链路的关键分支,目前没有路由级断言。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/api/test_auth_registration.py` around lines 113 - 127, Update the
existing-address test around auth_router.request_registration_code to use the
pytest monkeypatch fixture’s set attribute instead of manually assigning and
restoring verification_module.send_verification_email. Add a route-level test
covering the cooldown/rate-limit branch, asserting HTTP 429 and the expected
Retry-After header.
deeptutor/multi_user/token_quota.py (2)

293-298: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

booked 表达式恒等于 actual,条件分支可以删掉。

max(requested, actual) 仅在 actual > requested 时求值,此时结果就是 actual,因此整个三元表达式等价于 booked = actual。保留冗余分支会让读者误以为存在别的分支语义。

♻️ 建议简化
-            requested = int(reservation["requested_tokens"])
-            actual = max(0, int(actual_tokens))
-            # The reservation includes an upper bound. If a provider reports a
-            # larger value than that bound, book the larger value so accounting
-            # never silently undercounts a call.
-            booked = max(requested, actual) if actual > requested else actual
+            requested = int(reservation["requested_tokens"])
+            # Book exactly what the provider reported, even when it exceeds the
+            # reserved upper bound, so accounting never undercounts a call.
+            booked = max(0, int(actual_tokens))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/multi_user/token_quota.py` around lines 293 - 298, 简化 token
配额预订逻辑:在处理 requested_tokens 和 actual_tokens 的代码中,将 booked 的条件表达式直接改为使用
actual,保留现有的非负值处理及其余会计行为不变。

127-162: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

每次预留/结算都会新建连接并重跑建表脚本。

_connect() 在每次 reservefinalize 中执行 PRAGMA journal_mode = WAL 和完整的 executescript DDL,也就是每次 LLM 调用至少两次连接建立 + 两次 DDL。journal_mode 切换需要短暂的数据库级锁,在并发请求下会加剧 BEGIN IMMEDIATE 的竞争。建议把 schema 初始化与 WAL 设置收敛为进程内一次性动作(用 _schema_lock 保护的 _initialized 标记),连接创建仍保持每次新建。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/multi_user/token_quota.py` around lines 127 - 162, Update _connect
so schema initialization and PRAGMA journal_mode=WAL run only once per process,
guarded by _schema_lock and a shared _initialized flag; keep creating a fresh
SQLite connection for each call and retain per-connection settings such as
row_factory and busy_timeout. Ensure concurrent callers cannot skip
initialization, while preserving the existing unavailable-error handling.
tests/multi_user/test_token_quota.py (1)

77-134: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

建议补充流式中断与失败路径的用例。

当前流式用例只覆盖“完整迭代到 StopAsyncIteration”。请补充:消费者提前 break 后未 close()、以及流中途抛异常这两种情形,断言 reserved_tokens 已归还且 consumed_tokens 不为 0——这正是本 PR 中最容易漏账的两条路径(详见 deeptutor/core/agentic/client.pydeeptutor/services/llm/factory.py 的相关评论)。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/multi_user/test_token_quota.py` around lines 77 - 134, 在现有
test_stream_reservation_is_finalized_after_iteration 测试附近补充两个流式场景:消费者提前 break
且不调用 close(),以及 FakeStream 迭代过程中抛出异常;分别验证 token_usage 中 reserved_tokens 已归还为
0,并确认 consumed_tokens 已记录为非 0。复用现有 FakeCompletions、as_user 和临时数据库设置,覆盖
_TokenQuotaCompletions 的中断与失败清理路径。
deeptutor/multi_user/grants.py (1)

83-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

建议复用 token_quota._normalize_limit 而非重复夹断逻辑。

上限 10_000_000_000 已在 deeptutor/multi_user/token_quota.py 中定义为 MAX_QUOTA_TOKENS,两处独立维护容易漂移。

♻️ 建议重构
-from .token_quota import default_token_quota
+from .token_quota import _normalize_limit as normalize_quota_limit, default_token_quota
     raw_quota = payload.get("token_quota")
     if isinstance(raw_quota, dict):
         quota_defaults = default_token_quota()
-        normalized_quota: dict[str, int] = {}
-        for key, default in quota_defaults.items():
-            try:
-                value = int(raw_quota.get(key, default))
-            except (TypeError, ValueError):
-                value = default
-            normalized_quota[key] = max(0, min(value, 10_000_000_000))
-        base["token_quota"] = normalized_quota
+        base["token_quota"] = {
+            key: normalize_quota_limit(raw_quota.get(key, default))
+            for key, default in quota_defaults.items()
+        }

注:若采纳,建议在 token_quota.py 中将该函数改为公开名(如 normalize_limit)以避免跨模块引用私有符号。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/multi_user/grants.py` around lines 83 - 93, Update the token quota
normalization in the payload handling flow to reuse token_quota’s existing
_normalize_limit logic instead of duplicating the inline clamp and hard-coded
maximum. Expose it as normalize_limit if needed for cross-module use, and apply
it when assigning normalized_quota values while preserving the current type
conversion and fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deeptutor/agents/chat/agentic_pipeline.py`:
- Around line 481-516: 将附件数量限制从单次调用调整为整轮对话总量:在 _attach_rag_images 中结合
context.attachments 当前数量计算剩余容量,使本轮新增附件与已有附件合计不超过
RAG_IMAGE_MAX_ATTACHMENTS。保留现有去重、文件校验及 context.attachments.extend
行为,并确保达到总上限后不再添加附件。

In `@deeptutor/agents/visualize/utils.py`:
- Around line 68-86: Update the inline script validation flow around the script
extraction loop to parse each script’s type attribute, skip non-JavaScript types
such as application/json and text/template, validate classic scripts with the
existing Node check, and validate module scripts with --check and
--input-type=module. Preserve the current timeout, error handling, and
diagnostic behavior for supported JavaScript scripts.

In `@deeptutor/core/agentic/client.py`:
- Around line 139-147: Update _wrap_token_quota to always return a
_TokenQuotaClient instead of reading current_user_quota_policy during client
construction. Move the current-user quota decision to the
chat.completions.create interception path, where reserve_current_user_tokens()
is invoked only when the active request has a bounded non-admin policy, while
preserving unrestricted behavior otherwise.
- Around line 212-232: Update _TokenQuotaStream to proxy unhandled attributes,
such as response, to the wrapped _stream, and implement the async
context-manager protocol with __aenter__ and __aexit__. Preserve the existing
iteration and close behavior while ensuring async with usage delegates correctly
to the underlying stream.

In `@deeptutor/multi_user/token_quota.py`:
- Around line 182-276: Update TokenQuotaManager.reserve in
deeptutor/multi_user/token_quota.py (lines 182-276) to reclaim timed-out active
reservations before checking availability: mark them expired and decrement
reserved_tokens for their daily and monthly usage rows. Update _TokenQuotaStream
in deeptutor/core/agentic/client.py (lines 234-258) to add __aexit__ and ensure
object destruction or incomplete iteration invokes _finalize, covering consumers
that exit early or break; both sites require direct changes.
- Around line 262-267: 修复 reserve 与 finalize 中捕获 OSError/sqlite3.Error 的处理,确保
BEGIN IMMEDIATE 失败且事务未开启时,ROLLBACK 不会覆盖原始异常。更新两处 connection.execute("ROLLBACK")
调用,使回滚失败被安全忽略或仅在事务已开启时执行,并继续将原始异常包装为 TokenQuotaUnavailable;TokenQuotaExceeded
分支的现有行为保持不变。

In `@deeptutor/services/auth.py`:
- Around line 397-402: 用户查找未兼容历史大小写混合的邮箱键。抽取共用的规范化用户查找函数,先执行精确键匹配,未命中时遍历用户键并按
casefold() 比较;在 deeptutor/services/auth.py 的 authenticate(397-402)和
deeptutor/api/routers/auth.py 的
get_user_info(email)(573-581)中复用该函数,确保登录与账号存在性检查使用一致的查找逻辑。

In `@deeptutor/services/email_verification.py`:
- Around line 434-440: Update the email content in the verification-message flow
to use the configured verification_max_attempts value instead of the hardcoded 5
in both the Chinese and English attempt-limit text. Reuse the existing runtime
settings access used by the surrounding email verification logic, while
preserving the current message formatting and remaining_minutes behavior.

In `@deeptutor/services/llm/factory.py`:
- Around line 574-583: Update the streaming exception/finalization flow around
quota_lease.release() and the finally block so each lease is settled exactly
once in finally using the observed usage total, falling back to
quota_reservation when unavailable. Remove the exception-path release call and
preserve the existing error mapping and queue termination behavior.

In `@deeptutor/services/rag/pipelines/llamaindex/document_loader.py`:
- Around line 174-184: Update _page_image_paths_from_blocks to resolve relative
image paths using root / candidate first, preserving subdirectories such as
images/, then validate containment and file type; only fall back to root /
candidate.name when that lookup fails. In
tests/services/rag/test_llamaindex_document_loader.py lines 146-176, place
figure-1.png under asset_dir/images/ and retain assertions covering the nested
MinerU layout.

In `@docker-compose.ghcr.yml`:
- Around line 55-63: 为包含 DEEPTUTOR_SMTP_* 环境变量的服务更新 volumes 配置,像主 Compose 一样挂载
./data:/app/data,确保 data/system、data/users 和 data/partners 等完整数据树在容器重建后持久化。

In `@docker-compose.litellm.yml`:
- Around line 20-25: Update the LITELLM_DATABASE_URL and LITELLM_MASTER_KEY
entries in the Compose environment configuration to use required-variable
expansion with an appropriate required message, so deployment fails immediately
when either value is missing; leave the optional upstream configuration defaults
unchanged.

In `@ops/litellm/README.md`:
- Around line 10-23: Update the README instructions around virtual-key
generation and admin LLM profile configuration so the shared admin profile uses
a service/admin-scoped key without a user_id. Keep user-scoped keys tied to
individual user requests or grants, and explicitly preserve the requirement that
the master key is never exposed in user grants or browser responses.

In `@tests/agents/visualize/test_utils.py`:
- Around line 1-22: Update the tests around validate_visualization to detect
whether the node executable is available and explicitly skip the
inline-JavaScript validation tests when it is missing, while retaining their
existing assertions when Node is installed. Use the test framework’s skip
mechanism and apply it to both
test_html_validation_rejects_inline_javascript_syntax_errors and
test_html_validation_accepts_valid_inline_javascript.

In `@web/components/visualize/VisualizationViewer.tsx`:
- Around line 183-189: Replace the iframe onError-based failure handling in
VisualizationViewer with a timeout-driven bridge health check after onLoad: wait
for the expected dt:visualize-height or error report, then set loadError to the
existing “Failed to load visualization” message when no valid report arrives.
Update the related load/error state and cleanup logic while preserving the
current failure UI.

In `@web/features/multi-user/components/GrantEditor.tsx`:
- Around line 290-300: 更新 setTokenQuota 以单独处理空输入,避免 Number("") 将配额立即设为
0(无限制);空值应保留当前 token_quota[key](或使用 NaN 表示未完成输入),非空值继续执行现有的有限数值、非负及向下取整逻辑。

In `@web/locales/zh/app.json`:
- Line 2227: Update the Chinese translation value for the “Failed to load
visualization” localization key to use the established “可视化” terminology instead
of “演示”, while preserving the existing “加载失败” meaning.

---

Outside diff comments:
In `@web/app/`(auth)/register/page.tsx:
- Around line 106-119: 更新注册表单中的邮箱和口令变更处理(包括 email 输入的 setEmail
以及对应的口令更新逻辑):每次关键字段变化时清空 codeSent,并重置相关验证码挑战状态,避免继续提交旧验证码。将重发冷却的
setCountdown(60) 改为使用后端 verification_resend_cooldown_seconds
配置值,确保按钮状态与服务端冷却时间一致。

---

Nitpick comments:
In `@deeptutor/agents/visualize/utils.py`:
- Around line 62-86: Update _validate_inline_javascript and its
validate_visualization call path so Node syntax checks run via asyncio.to_thread
rather than blocking the event loop. Enforce limits on the total number of
scripts checked and the aggregate validation duration, while preserving the
current error-detail behavior and fixed argv/stdin subprocess invocation.

In `@deeptutor/api/routers/auth.py`:
- Around line 556-560: 明确 email_verification_required
配置在相关注册端点中的实际语义:更新配置文档/UI,说明设为 False 会禁用公开注册并返回 503,而不是提供免验证注册;同时确保 startLine
556 和 630 附近的说明保持一致。不要改变现有注册禁用行为。
- Around line 584-591: 调整注册处理流程,避免已注册与未注册邮箱因 bcrypt 和 SMTP 投递产生明显响应时差:让
send_verification_email 通过后台任务执行而不阻塞响应,并在包含
check_registration_rate_limit、hash_password 和 issue_challenge
的完整流程上施加统一的最小响应时间,确保两条分支都满足该耗时下限。

In `@deeptutor/multi_user/grants.py`:
- Around line 83-93: Update the token quota normalization in the payload
handling flow to reuse token_quota’s existing _normalize_limit logic instead of
duplicating the inline clamp and hard-coded maximum. Expose it as
normalize_limit if needed for cross-module use, and apply it when assigning
normalized_quota values while preserving the current type conversion and
fallback behavior.

In `@deeptutor/multi_user/token_quota.py`:
- Around line 293-298: 简化 token 配额预订逻辑:在处理 requested_tokens 和 actual_tokens
的代码中,将 booked 的条件表达式直接改为使用 actual,保留现有的非负值处理及其余会计行为不变。
- Around line 127-162: Update _connect so schema initialization and PRAGMA
journal_mode=WAL run only once per process, guarded by _schema_lock and a shared
_initialized flag; keep creating a fresh SQLite connection for each call and
retain per-connection settings such as row_factory and busy_timeout. Ensure
concurrent callers cannot skip initialization, while preserving the existing
unavailable-error handling.

In `@deeptutor/services/auth.py`:
- Around line 295-307: 为 decode_token 中的用户状态校验增加基于 users.json mtime 的短 TTL
缓存,避免每次认证请求都同步读取和解析文件。更新 _load_users 或其调用路径,使缓存仅在文件 mtime 变化或 TTL 过期时重新加载,并保留
disabled 与 email_verified 校验逻辑及必要的 _write_users 归一化行为。

In `@deeptutor/services/email_verification.py`:
- Around line 276-293: 在 consume_challenge 中加入基于当前时间执行的 pending_registrations
过期记录清理,复用现有的 expires_at 条件和参数化查询;确保即使没有新的签发请求,过期挑战及其口令哈希也会被及时删除,并保持现有挑战消费流程不变。
- Around line 387-414: 更新 smtp_config,使 SMTP 配置通过 RuntimeSettingsService
的集中式运行时设置读取和归一化,而不是直接调用 os.getenv;保留现有端口、超时和布尔值校验行为。针对 use_tls 与 use_ssl
同时启用的冲突配置,在配置校验或 configured 判定路径中记录明确的 error 日志,并保留现有不可用状态。

In `@deeptutor/services/rag/pipelines/llamaindex/document_loader.py`:
- Around line 83-91: Update the fallback branch in the document-loading flow to
store resolved absolute image paths in the `image_paths` metadata, matching
`_page_image_paths_from_blocks`. Resolve each `source.path` before converting it
to a string, while leaving the existing `_append_if_nonempty` behavior
unchanged.
- Around line 445-458: Update the Document construction in the document-loading
flow to always exclude the "image_paths" metadata key, including when its value
is an empty list. Provide separate list instances to
excluded_embed_metadata_keys and excluded_llm_metadata_keys so mutations to one
cannot affect the other.

In `@docker-compose.litellm.yml`:
- Line 13: 更新 docker-compose.litellm.yml 中的 LITELLM_IMAGE 默认值,移除可变的 main-latest
标签,改用经过审核的固定版本标签或 immutable digest;保留通过环境变量覆盖镜像的能力。

In `@tests/agents/chat/test_rag_image_attachments.py`:
- Around line 11-66: 在现有 RAG 图片附件测试中补充用例,覆盖 RAG_IMAGE_MAX_ATTACHMENTS 达到上限时截断、超过
RAG_IMAGE_MAX_BYTES 的文件被跳过,以及 .svg、.pdf 等非白名单后缀被拒绝;分别断言返回的新增附件数量和
context.attachments 内容符合预期。

In `@tests/api/test_auth_registration.py`:
- Around line 113-127: Update the existing-address test around
auth_router.request_registration_code to use the pytest monkeypatch fixture’s
set attribute instead of manually assigning and restoring
verification_module.send_verification_email. Add a route-level test covering the
cooldown/rate-limit branch, asserting HTTP 429 and the expected Retry-After
header.

In `@tests/multi_user/test_token_quota.py`:
- Around line 77-134: 在现有 test_stream_reservation_is_finalized_after_iteration
测试附近补充两个流式场景:消费者提前 break 且不调用 close(),以及 FakeStream 迭代过程中抛出异常;分别验证 token_usage 中
reserved_tokens 已归还为 0,并确认 consumed_tokens 已记录为非 0。复用现有 FakeCompletions、as_user
和临时数据库设置,覆盖 _TokenQuotaCompletions 的中断与失败清理路径。

In `@tests/services/config/test_runtime_settings.py`:
- Around line 125-127: Extend the environment assertions in the runtime settings
test alongside DEEPTUTOR_EMAIL_VERIFICATION_REQUIRED and
DEEPTUTOR_VERIFICATION_CODE_TTL_MINUTES to cover the new verification cooldown,
maximum-attempts, and email/IP hourly rate-limit keys, asserting each rendered
value matches its configured setting.

In `@web/components/visualize/VisualizationViewer.tsx`:
- Around line 172-182: Update the iframe rendering in VisualizationViewer so key
no longer uses the full prepared HTML string; use a short stable hash only if
remounting is required, otherwise remove the key. In the onLoad handler, stop
unconditionally resetting height to 560, while preserving load-error clearing
and subsequent iframe-reported height behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 931d9141-025f-47cf-9dc8-006cd4ac5a14

📥 Commits

Reviewing files that changed from the base of the PR and between 731410e and c5809a2.

📒 Files selected for processing (47)
  • .env.example
  • CONTAINERIZATION.md
  • Dockerfile.runner
  • README.md
  • compose.litellm.yaml
  • compose.yaml
  • deeptutor/agents/chat/agent_loop.py
  • deeptutor/agents/chat/agentic_pipeline.py
  • deeptutor/agents/visualize/prompts/en/code_generator_agent.yaml
  • deeptutor/agents/visualize/prompts/zh/code_generator_agent.yaml
  • deeptutor/agents/visualize/utils.py
  • deeptutor/api/routers/auth.py
  • deeptutor/core/agentic/client.py
  • deeptutor/multi_user/grants.py
  • deeptutor/multi_user/identity.py
  • deeptutor/multi_user/models.py
  • deeptutor/multi_user/token_quota.py
  • deeptutor/services/auth.py
  • deeptutor/services/config/runtime_settings.py
  • deeptutor/services/email_verification.py
  • deeptutor/services/llm/factory.py
  • deeptutor/services/rag/pipelines/llamaindex/document_loader.py
  • deeptutor/services/rag/pipelines/llamaindex/pipeline.py
  • deeptutor/services/sandbox/runner/server.py
  • deeptutor/services/sandbox/service.py
  • docker-compose.ghcr.yml
  • docker-compose.litellm.yml
  • docker-compose.yml
  • ops/litellm/README.md
  • ops/litellm/config.yaml
  • tests/agents/chat/test_rag_image_attachments.py
  • tests/agents/visualize/test_utils.py
  • tests/api/test_auth_registration.py
  • tests/multi_user/test_token_quota.py
  • tests/multi_user/test_tool_access.py
  • tests/services/config/test_runtime_settings.py
  • tests/services/rag/test_llamaindex_document_loader.py
  • tests/services/sandbox/test_sandbox.py
  • tests/services/test_email_verification.py
  • web/app/(auth)/login/page.tsx
  • web/app/(auth)/register/page.tsx
  • web/components/visualize/VisualizationViewer.tsx
  • web/features/multi-user/components/GrantEditor.tsx
  • web/features/multi-user/types.ts
  • web/lib/auth.ts
  • web/locales/en/app.json
  • web/locales/zh/app.json

Comment on lines +481 to +516
for raw_path in candidate_paths:
if len(added) >= RAG_IMAGE_MAX_ATTACHMENTS:
break
try:
path = Path(raw_path).expanduser().resolve()
if not path.is_relative_to(runtime_root):
logger.warning("skipping RAG image outside runtime data root: %s", path)
continue
if path.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
continue
if not path.is_file() or path.stat().st_size > RAG_IMAGE_MAX_BYTES:
logger.warning("skipping missing or oversized RAG image: %s", path)
continue
path_key = str(path)
if path_key in known_paths:
continue
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
attachment_id = "rag-image-" + hashlib.sha256(path_key.encode()).hexdigest()[:16]
added.append(
Attachment(
type="image",
base64=encoded,
filename=path.name,
mime_type=mime_type,
id=attachment_id,
)
)
known_paths.add(path_key)
except (OSError, ValueError) as exc:
logger.warning("failed to load retrieved RAG image %s: %s", raw_path, exc)

if added:
context.attachments.extend(added)
context.metadata["_rag_image_paths"] = sorted(known_paths)
return added

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

每轮上限只约束单次调用,跨轮附件可无界累积。

RAG_IMAGE_MAX_ATTACHMENTS 只限制单次 _attach_rag_images 的新增数量,而 agent_loop.py 在每一轮工具分发后都会调用一次。若模型持续检索到新页面图片,context.attachments 每轮最多再增 6 张(每张上限 8MB),并且默认路径 _prepare_messages_with_attachments(messages, context) 会把 context.attachments 全量注入,造成请求体、内存与 token 成本随轮数膨胀。建议以整轮对话的总附件数为封顶。

♻️ 建议按整轮总量封顶
         added: list[Attachment] = []
+        existing_rag_images = len(known_paths)
         candidate_paths: list[str] = []
@@
         for raw_path in candidate_paths:
-            if len(added) >= RAG_IMAGE_MAX_ATTACHMENTS:
+            if existing_rag_images + len(added) >= RAG_IMAGE_MAX_ATTACHMENTS:
                 break
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for raw_path in candidate_paths:
if len(added) >= RAG_IMAGE_MAX_ATTACHMENTS:
break
try:
path = Path(raw_path).expanduser().resolve()
if not path.is_relative_to(runtime_root):
logger.warning("skipping RAG image outside runtime data root: %s", path)
continue
if path.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
continue
if not path.is_file() or path.stat().st_size > RAG_IMAGE_MAX_BYTES:
logger.warning("skipping missing or oversized RAG image: %s", path)
continue
path_key = str(path)
if path_key in known_paths:
continue
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
attachment_id = "rag-image-" + hashlib.sha256(path_key.encode()).hexdigest()[:16]
added.append(
Attachment(
type="image",
base64=encoded,
filename=path.name,
mime_type=mime_type,
id=attachment_id,
)
)
known_paths.add(path_key)
except (OSError, ValueError) as exc:
logger.warning("failed to load retrieved RAG image %s: %s", raw_path, exc)
if added:
context.attachments.extend(added)
context.metadata["_rag_image_paths"] = sorted(known_paths)
return added
added: list[Attachment] = []
existing_rag_images = len(known_paths)
candidate_paths: list[str] = []
for raw_path in candidate_paths:
if existing_rag_images + len(added) >= RAG_IMAGE_MAX_ATTACHMENTS:
break
try:
path = Path(raw_path).expanduser().resolve()
if not path.is_relative_to(runtime_root):
logger.warning("skipping RAG image outside runtime data root: %s", path)
continue
if path.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
continue
if not path.is_file() or path.stat().st_size > RAG_IMAGE_MAX_BYTES:
logger.warning("skipping missing or oversized RAG image: %s", path)
continue
path_key = str(path)
if path_key in known_paths:
continue
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
attachment_id = "rag-image-" + hashlib.sha256(path_key.encode()).hexdigest()[:16]
added.append(
Attachment(
type="image",
base64=encoded,
filename=path.name,
mime_type=mime_type,
id=attachment_id,
)
)
known_paths.add(path_key)
except (OSError, ValueError) as exc:
logger.warning("failed to load retrieved RAG image %s: %s", raw_path, exc)
if added:
context.attachments.extend(added)
context.metadata["_rag_image_paths"] = sorted(known_paths)
return added
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/agents/chat/agentic_pipeline.py` around lines 481 - 516,
将附件数量限制从单次调用调整为整轮对话总量:在 _attach_rag_images 中结合 context.attachments
当前数量计算剩余容量,使本轮新增附件与已有附件合计不超过 RAG_IMAGE_MAX_ATTACHMENTS。保留现有去重、文件校验及
context.attachments.extend 行为,并确保达到总上限后不再添加附件。

Comment on lines +68 to +86
scripts = re.findall(r"<script(?:\s[^>]*)?>([\s\S]*?)</script>", html, re.IGNORECASE)
for script in scripts:
if not script.strip():
continue
try:
result = subprocess.run(
[node, "--check"],
input=script,
text=True,
capture_output=True,
timeout=3,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return True, ""
if result.returncode != 0:
detail = (result.stderr or result.stdout or "JavaScript syntax error").strip()
return False, f"Inline JavaScript syntax error: {detail[-500:]}"
return True, ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

node --check read from stdin --input-type module support

💡 Result:

Node.js supports checking syntax from stdin using the --check (or -c) flag by passing - as the filename [1][2]. Regarding the combination of --check with --input-type=module: 1. The --check flag performs a syntax check without executing the code [1][2]. 2. While --input-type=module is used to instruct Node.js to interpret input (from --eval or stdin) as an ES module, this flag is specifically designed for execution [3][4]. 3. The --check flag and --input-type flag are generally intended to be used for different purposes (syntax validation vs. execution configuration) [1][3]. 4. Notably, TypeScript documentation explicitly states that "TypeScript syntax is unsupported in... --check" [5]. If you attempt to use them together, the syntax check will generally process the input based on the default behavior for stdin, as --check typically exits before the execution-related module type configuration fully applies to module-specific runtime features [1][3]. In practice, if you need to validate code that relies on ES module syntax, you should typically use a linting tool or a dedicated parser rather than relying on --check with --input-type=module, as --check is a basic syntax validator [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== node availability/version =="
if command -v node >/dev/null 2>&1; then
  node --version
else
  echo "node not available"
fi

echo "== locate target file =="
fd -a 'utils.py$' . | sed 's#^\./##' | grep -E 'deeptutor/agents/visualize/utils.py|utils.py' || true

echo "== target snippet =="
if [ -f deeptutor/agents/visualize/utils.py ]; then
  nl -ba deeptutor/agents/visualize/utils.py | sed -n '1,120p'
else
  echo "target file not found"
fi

echo "== node --check inputs =="
node_exists=$(command -v node || true)
if [ -n "${node_exists}" ]; then
  cat >/tmp/node_check_probe.js <<'JS'
const {execFileSync} = require('node:child_process');
const inputs = [
  ['classic', 'import x from "x";'],
  ['classic', 'export default 1;'],
  ['module', 'import x from "x";'],
  ['module', 'export default 1;'],
  ['application/json', '{"ok": true}'],
  ['text/template', '<div>foo</div>'],
];
for (const [type, code] of inputs) {
  let cmd = ['--check'];
  if (type === 'module') cmd.push('--input-type=module');
  let exit = 1; let stderr = '', stdout = '';
  try {
    const out = execFileSync(process.argv[0], [...cmd], {input: code, encoding: 'utf8'});
    exit = 0; stdout = out.stdout || ''; stderr = out.stderr || '';
  } catch (e) {
    stderr += (e.stderr || '') + (e.stdout || '');
    exit = e.status || 1;
  }
  console.log(JSON.stringify({type, code, exit, stderr: stderr.substring(0, 300), stdout: stdout.substring(0, 200)}));
}
JS
  node /tmp/node_check_probe.js
fi

Repository: hubo1989/DeepTutor

Length of output: 857


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file lines 1-130 =="
awk '{printf "%5d\t%s\n", NR, $0}' deeptutor/agents/visualize/utils.py | sed -n '1,130p'

echo "== Node syntax probe =="
node - <<'JS'
const {execFileSync} = require('node:child_process');
const inputs = [
  ['classic', 'import x from "x";'],
  ['classic', 'export default 1;'],
  ['module', 'import x from "x";'],
  ['module', 'export default 1;'],
  ['application/json', '{"ok": true}'],
  ['text/template', '<div>foo</div>'],
];
for (const [type, code] of inputs) {
  let cmd = ['--check'];
  if (type === 'module') cmd.push('--input-type=module');
  let exit = 1; let stderr = '', stdout = '';
  try {
    const out = execFileSync(process.argv[0], [...cmd], {input: code, encoding: 'utf8'});
    exit = 0; stdout = out.stdout || ''; stderr = out.stderr || '';
  } catch (e) {
    stderr += (e.stderr || '') + (e.stdout || '');
    exit = e.status || 1;
  }
  console.log(`${type}\t${exit}\t${stderr.substring(0, 200).replace(/\n/g, ' ')}`);
}
JS

echo "== related tests/use mentions =="
rg -n "check_js_syntax|JavaScript syntax error|node --check|type=\"module\"|application/json|text/template|<script" deeptutor tests || true

Repository: hubo1989/DeepTutor

Length of output: 12710


🌐 Web query:

Node.js CLI --check stdin input-type=module documentation checkSyntax stdin

💡 Result:

In Node.js, the --check (or -c) command-line flag is used to syntax check a script without executing it [1][2]. When you wish to perform this check on code provided via standard input (stdin), you can use the - flag as the filename argument [3][4]. To verify the syntax of code piped from stdin as an ES module, you can combine the --check flag with the --input-type=module flag [4][5]. Example command structure: echo "import { EventEmitter } from 'events'; export const x = 1;" | node --check --input-type=module - Key Points: - The --check flag parses the code and checks for syntax errors, exiting with a non-zero status code if errors are found, without running the actual code [1][3][6]. - The - argument tells Node.js to read the script from stdin [3][4]. - The --input-type=module flag forces Node.js to interpret the incoming stdin content as an ES module [4][5]. This is useful because it ensures that features like import and export are correctly parsed during the syntax check [7][5]. - Without --input-type=module, Node.js may attempt to detect the module system, or default to CommonJS, which could lead to syntax errors if ES module syntax is present [4][7][5].

Citations:


区分 JS 与非 JS/模块的 <script> 块。

当前正则会把所有 </script> 都送入 node --check,导致 <script type="module"> 中的 import/export,以及 <script type="application/json">type="text/template" 等非 JS 内容被误判为语法错误。建议解析 type 属性:跳过非标准脚本;对经典脚本使用 --check,对 module 使用 --check --input-type=module

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 72-79: Command coming from incoming request
Context: subprocess.run(
[node, "--check"],
input=script,
text=True,
capture_output=True,
timeout=3,
check=False,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/agents/visualize/utils.py` around lines 68 - 86, Update the inline
script validation flow around the script extraction loop to parse each script’s
type attribute, skip non-JavaScript types such as application/json and
text/template, validate classic scripts with the existing Node check, and
validate module scripts with --check and --input-type=module. Preserve the
current timeout, error handling, and diagnostic behavior for supported
JavaScript scripts.

Comment on lines +139 to +147
def _wrap_token_quota(client: Any) -> Any:
"""Wrap every generated client so all capability calls share one gate."""
from deeptutor.multi_user.token_quota import current_user_quota_policy

# Preserve the native SDK/adapter shape for the single-user and admin
# paths. Only a bounded non-admin grant needs the interception layer.
if current_user_quota_policy() is None:
return client
return _TokenQuotaClient(client)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) build_openai_client 的返回值是否被缓存/跨请求复用
rg -nP -C6 '\bbuild_openai_client\s*\(' --type=py

# 2) get_current_user 在无上下文时的行为
ast-grep run --pattern 'def get_current_user($$$) { $$$ }' --lang python deeptutor/multi_user/context.py
rg -nP -C8 'def get_current_user' deeptutor/multi_user/context.py

Repository: hubo1989/DeepTutor

Length of output: 156


🏁 Script executed:

#!/bin/bash
set +e

echo "== candidate files =="
fd -a 'client.py|token_quota.py|context.py|agentic_pipeline.py' . | sed 's#^\./##'

echo "== build_openai_client references =="
rg -n -C 8 'build_openai_client' . || true

echo "== get_current_user refs =="
rg -n -C 8 'get_current_user|current_user_quota_policy|_wrap_token_quota|_TokenQuotaClient|_TokenQuotaStream' . || true

Repository: hubo1989/DeepTutor

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deeptutor/core/agentic/client.py relevant section =="
sed -n '1,290p' deeptutor/core/agentic/client.py | cat -n

echo "== deeptutor/agents/chat/agentic_pipeline.py _client_config/usages relevant section =="
sed -n '1,100p' deeptutor/agents/chat/agentic_pipeline.py | cat -n
sed -n '310,335p' deeptutor/agents/chat/agentic_pipeline.py | cat -n
sed -n '1330,1350p' deeptutor/agents/chat/agentic_pipeline.py | cat -n

echo "== deeptutor/multi_user/token_quota.py relevant section =="
sed -n '1,320p' deeptutor/multi_user/token_quota.py | cat -n

echo "== deeptutor/multi_user/context.py relevant section =="
sed -n '1,180p' deeptutor/multi_user/context.py | cat -n

echo "== focused references in production code =="
rg -n '\brun\(|async with|build_openai_client\(|self\._client_config|_client_config\s*=' --type=py | rg 'deeptutor/(agents|capabilities)/|test_[^/]+\.py' || true

Repository: hubo1989/DeepTutor

Length of output: 34411


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== current_user_quota_policy definitions/usages =="
rg -n -C 12 'def current_user_quota_policy|current_user_quota_policy\(' --type=py | rg 'deeptutor/(multi_user|core|agents|capabilities)/|test_|\.md$' || true

echo "== local_admin_user/CurrentUser definitions =="
sed -n '1,220p' deeptutor/multi_user/models.py | cat -n

echo "== pipeline/config allocation surrounding _client_config =="
rg -n -C 12 'AgenticChatPipeline\(|AgenticResearch|Question|llm_config|_client_config|client_config|build_openai_client' deeptutor --type=py | rg 'deeptutor/(agents|capabilities|core|services|runtime)/' | head -n 240

echo "== async context manager/Stream access usages =="
rg -n -C 4 'async with .*chat\.completions|chat\.completions\.create\(|\.response|\.text_stream|\.close\(\)|aclose\(' --type=py | rg 'deeptutor/(agents|capabilities|core)/|test_' | head -n 240 || true

Repository: hubo1989/DeepTutor

Length of output: 207


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all Python refs: current_user_quota_policy/get_current_user/build_openai_client =="
rg -n -C 5 'current_user_quota_policy|get_current_user\(|get_current_user_or_none|build_openai_client|_client_config\s*=' --type=py | head -n 300

echo "== files mentioning quota/current-user context =="
rg -l 'quota|quota_policy|current_user|local_admin_user' . --glob '*.py' | sed -n '1,120p'

echo "== inspect token_quota remainder =="
wc -l deeptutor/multi_user/token_quota.py
sed -n '320,520p' deeptutor/multi_user/token_quota.py | cat -n

Repository: hubo1989/DeepTutor

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== current_user_quota_policy literal across all files =="
rg -n -C 8 'current_user_quota_policy' . || true

echo "== current_user_quota_policy as variable/attribute across all files =="
rg -n -C 5 'current_user_quota|user_quota_policy' . || true

echo "== inspect token_quota.py line count and remaining relevant section =="
wc -l deeptutor/multi_user/token_quota.py
sed -n '320,620p' deeptutor/multi_user/token_quota.py | cat -n

echo "== inspect client imports/top-level =="
sed -n '1,160p' deeptutor/core/agentic/client.py | cat -n

echo "== inspect token_quota imports/top-level =="
sed -n '1,80p' deeptutor/multi_user/token_quota.py | cat -n

Repository: hubo1989/DeepTutor

Length of output: 20655


配额门控不要依赖构建时刻的用户上下文。

build_openai_client()deeptutor/agents/chat/agentic_pipeline.py:1345 这种路径中直接由 self._client_config 构建,而 _wrap_token_quota 会立即读取 current_user_quota_policy()。一旦构建发生在 admin/无上下文环境,受限用户后续复用该 pipeline/adapter 就会绕过拦截;如果在请求上下文外抛出异常还会让客户端构建失败。通常应总是返回 _TokenQuotaClient,在 chat.completions.create 时再按当前用户决定是否 reserve_current_user_tokens()

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/core/agentic/client.py` around lines 139 - 147, Update
_wrap_token_quota to always return a _TokenQuotaClient instead of reading
current_user_quota_policy during client construction. Move the current-user
quota decision to the chat.completions.create interception path, where
reserve_current_user_tokens() is invoked only when the active request has a
bounded non-admin policy, while preserving unrestricted behavior otherwise.

Comment on lines +212 to +232
class _TokenQuotaStream:
def __init__(
self,
stream: Any,
*,
lease: Any,
requested_tokens: int,
prompt_estimate: int,
output_estimate: int,
) -> None:
self._stream = stream
self._lease = lease
self._requested_tokens = requested_tokens
self._prompt_estimate = prompt_estimate
self._output_estimate = output_estimate
self._usage_total = 0
self._output_chars = 0
self._finalized = False

def __aiter__(self) -> "_TokenQuotaStream":
return self

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 检查是否存在以 async with / .response 等方式消费 chat.completions.create(stream=True) 的调用点
rg -nP -C6 'async with .*completions\.create' --type=py
rg -nP -C6 'completions\.create\(' --type=py | rg -n 'stream'

Repository: hubo1989/DeepTutor

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -u

echo "== files =="
git ls-files | sed -n '1,120p'

echo "== client outline =="
ast-grep outline deeptutor/core/agentic/client.py || true

echo "== relevant client section =="
sed -n '1,340p' deeptutor/core/agentic/client.py | cat -n

echo "== search TokenQuota and wrapper usages =="
rg -n -C5 'TokenQuota|TokenQuotaStream|_TokenQuotaStream|quota' deeptutor/core/agentic/client.py --type=py

echo "== search OpenAI completions/create stream usage =="
rg -n -C4 'chat\.completions|openai\.chat|create\(|stream\s*=\s*True|AsyncStream|AsyncClient|AsyncOpenAI|response|\.close\('\ --type=py | sed -n '1,240p'

Repository: hubo1989/DeepTutor

Length of output: 25898


🏁 Script executed:

#!/bin/bash
set -u

echo "== rest of client.py =="
sed -n '340,560p' deeptutor/core/agentic/client.py | cat -n

echo "== all create/usage/response/stream references in repo =="
rg -n -C3 '\b(create\(|stream|AsyncStream|\.response|\.close\(|chat\.completions|completions\.create)\b' --type=py | sed -n '1,260p'

echo "== dependency/version for openai =="
git ls-files | rg -n 'pyproject\.toml|requirements.*txt|poetry\.lock|uv\.lock|ruff\.toml' | sed -n '1,80p'
for f in $(git ls-files | rg 'pyproject\.toml|requirements.*txt|poetry\.lock|uv\.lock|ruff\.toml'); do
  echo "--- $f"
  rg -n 'openai|httpx|pydantic' "$f"
done

Repository: hubo1989/DeepTutor

Length of output: 9321


🌐 Web query:

OpenAI Python AsyncStream stream.response close async with __aenter__ __aexit__

💡 Result:

In the OpenAI Python library, the AsyncStream class (and related streaming wrappers like AsyncChatCompletionStream) is designed to manage its own lifecycle [1][2]. It implements the asynchronous context manager protocol (aenter and aexit) to ensure that the underlying network connection is closed properly [1][2]. When you use an async stream with the async with syntax, aexit automatically calls the close method, which in turn calls await response.aclose on the underlying HTTP response [1][2]. This ensures the connection is released and returned to the connection pool [1][2]. Key technical details: - Automatic Cleanup: If you read the stream to completion (e.g., iterating through all chunks until the end), the library generally manages closing the response [1][2]. However, if you break out of the loop early, the aexit handler (or your explicit call to close/aclose) is essential to prevent resource leaks [1][3]. - Context Manager: Using async with is the recommended pattern [1][2]. async with client.chat.completions.create(...) as stream: async for chunk in stream:... - Manual Closing: The AsyncStream class provides a close method [1][2]. Recent versions of the library also include an aclose method to comply with standard PEP 525 async generator cleanup conventions, ensuring compatibility with various instrumentation libraries [4][5]. - Important Note on Connection Pooling: In older versions of the library, failing to close the stream explicitly could lead to connection pool exhaustion [6]. While modern versions handle this more robustly via the context manager and the finally blocks inside the iterator logic, using the async with context manager remains the safest and most idiomatic way to handle streaming responses [1][2].

Citations:


🌐 Web query:

site:github.com/openai/openai-python AsyncStream __getattr__ response close aclose

💡 Result:

The AttributeError: 'AsyncStream' object has no attribute 'aclose' typically arises when using the OpenAI Python SDK because the AsyncStream class previously exposed only a close method and lacked the aclose method required by the standard Python asynchronous cleanup convention (PEP 525) [1][2]. This issue frequently occurred when instrumentation libraries or custom wrappers attempted to close the stream using await stream.aclose, which is the standard expected interface for asynchronous context managers and iterators [1][2]. To resolve this: 1. Update your OpenAI Python SDK: This issue was addressed in recent versions of the library by adding aclose as an alias for close on the AsyncStream class to ensure compliance with PEP 525 [1][2]. 2. Verify Compatibility: If you are encountering this error on an older version, upgrading to the latest version of the openai package is the recommended fix [2]. In the internal implementation of the SDK, AsyncStream manages the underlying network response [3][4]. When an asynchronous context manager (such as AsyncChatCompletionStreamManager) is exited, it calls the stream's close method, which in turn calls aclose on the underlying response object to properly release the connection [1][3][5]. Adding the aclose method to AsyncStream allows external tools that expect this standard async interface to function correctly without triggering an AttributeError [1][2].

Citations:


_TokenQuotaStream 补齐底层流对象代理和上下文管理协议。

多用户配额路径会把原生 AsyncChatCompletionStream 替换为包装对象,但包装只实现了 __aiter__/__anext__close();外层按 async with ... 或者访问 .response 等原生成员消费流时会直接变成 AttributeError。补齐属性代理以及 __aenter__/__aexit__,避免受限用户路径下非遍历式流消费失效。

🛡️ 示例补丁
     def __aiter__(self) -> "_TokenQuotaStream":
         return self
+
+    def __getattr__(self, name: str) -> Any:
+        return getattr(self._stream, name)
+
+    async def __aenter__(self) -> "_TokenQuotaStream":
+        return self
+
+    async def __aexit__(self, *exc_info: Any) -> None:
+        await self.close()

</ details>

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class _TokenQuotaStream:
def __init__(
self,
stream: Any,
*,
lease: Any,
requested_tokens: int,
prompt_estimate: int,
output_estimate: int,
) -> None:
self._stream = stream
self._lease = lease
self._requested_tokens = requested_tokens
self._prompt_estimate = prompt_estimate
self._output_estimate = output_estimate
self._usage_total = 0
self._output_chars = 0
self._finalized = False
def __aiter__(self) -> "_TokenQuotaStream":
return self
class _TokenQuotaStream:
def __init__(
self,
stream: Any,
*,
lease: Any,
requested_tokens: int,
prompt_estimate: int,
output_estimate: int,
) -> None:
self._stream = stream
self._lease = lease
self._requested_tokens = requested_tokens
self._prompt_estimate = prompt_estimate
self._output_estimate = output_estimate
self._usage_total = 0
self._output_chars = 0
self._finalized = False
def __aiter__(self) -> "_TokenQuotaStream":
return self
def __getattr__(self, name: str) -> Any:
return getattr(self._stream, name)
async def __aenter__(self) -> "_TokenQuotaStream":
return self
async def __aexit__(self, *exc_info: Any) -> None:
await self.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/core/agentic/client.py` around lines 212 - 232, Update
_TokenQuotaStream to proxy unhandled attributes, such as response, to the
wrapped _stream, and implement the async context-manager protocol with
__aenter__ and __aexit__. Preserve the existing iteration and close behavior
while ensuring async with usage delegates correctly to the underlying stream.

Comment on lines +182 to +276
def reserve(
self,
user_id: str,
requested_tokens: int,
policy: TokenQuotaPolicy,
*,
prompt_tokens_estimate: int = 0,
output_tokens_estimate: int = 0,
) -> TokenQuotaLease | None:
requested = max(0, int(requested_tokens))
policy = policy.normalized()
if requested == 0 or not policy.bounded:
return None

daily_key, monthly_key = _period_keys()
now = datetime.now(timezone.utc).isoformat()
reservation_id = uuid.uuid4().hex
connection = self._connect()
try:
connection.execute("BEGIN IMMEDIATE")
self._ensure_period_row(
connection,
user_id=user_id,
period="daily",
period_key=daily_key,
now=now,
)
self._ensure_period_row(
connection,
user_id=user_id,
period="monthly",
period_key=monthly_key,
now=now,
)
rows = {
row["period"]: row
for row in connection.execute(
"""
SELECT period, consumed_tokens, reserved_tokens
FROM token_usage
WHERE user_id = ? AND period_key IN (?, ?)
""",
(user_id, daily_key, monthly_key),
)
}
for period, limit in (
("daily", policy.daily_tokens),
("monthly", policy.monthly_tokens),
):
if limit <= 0:
continue
row = rows[period]
used = int(row["consumed_tokens"]) + int(row["reserved_tokens"])
if used + requested > limit:
raise TokenQuotaExceeded(
period=period,
limit=limit,
used=used,
requested=requested,
)

for period, period_key in (("daily", daily_key), ("monthly", monthly_key)):
connection.execute(
"""
UPDATE token_usage
SET reserved_tokens = reserved_tokens + ?, updated_at = ?
WHERE user_id = ? AND period = ? AND period_key = ?
""",
(requested, now, user_id, period, period_key),
)
connection.execute(
"""
INSERT INTO token_reservations
(reservation_id, user_id, daily_key, monthly_key,
requested_tokens, state, created_at)
VALUES (?, ?, ?, ?, ?, 'active', ?)
""",
(reservation_id, user_id, daily_key, monthly_key, requested, now),
)
connection.execute("COMMIT")
except TokenQuotaExceeded:
connection.execute("ROLLBACK")
raise
except (OSError, sqlite3.Error) as exc:
connection.execute("ROLLBACK")
raise TokenQuotaUnavailable(f"token quota reservation failed: {exc}") from exc
finally:
connection.close()
return TokenQuotaLease(
self,
reservation_id,
requested,
max(0, int(prompt_tokens_estimate)),
max(0, int(output_tokens_estimate)),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

租约生命周期完全依赖调用方显式结束,缺少兜底回收,用户额度会被静默锁死。 账本只在 finalize/release 时归还 reserved_tokens,而流式包装只在迭代到底、异常或显式 close() 时才结算——任何提前退出或进程中断都会留下永久 active 的预留。

  • deeptutor/multi_user/token_quota.py#L182-L276:在 reserve 内顺带回收超时的 active 预留(标记为 expired 并从对应周期行的 reserved_tokens 中扣减),作为所有调用路径的兜底。
  • deeptutor/core/agentic/client.py#L234-L258:为 _TokenQuotaStream 增加 __aexit__ 并在对象销毁/未完成迭代时确保 _finalize 被调用,避免消费者 break 后租约悬挂。
📍 Affects 2 files
  • deeptutor/multi_user/token_quota.py#L182-L276 (this comment)
  • deeptutor/core/agentic/client.py#L234-L258
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deeptutor/multi_user/token_quota.py` around lines 182 - 276, Update
TokenQuotaManager.reserve in deeptutor/multi_user/token_quota.py (lines 182-276)
to reclaim timed-out active reservations before checking availability: mark them
expired and decrement reserved_tokens for their daily and monthly usage rows.
Update _TokenQuotaStream in deeptutor/core/agentic/client.py (lines 234-258) to
add __aexit__ and ensure object destruction or incomplete iteration invokes
_finalize, covering consumers that exit early or break; both sites require
direct changes.

Comment thread ops/litellm/README.md
Comment on lines +10 to +23
After the proxy is healthy, create a virtual key from the proxy network using
the LiteLLM master key. For a user-scoped key, use the DeepTutor user id as
`user_id` and keep the model name at `deeptutor-default`:

```bash
curl -sS -X POST http://litellm:4000/key/generate \
-H "Authorization: Bearer ${LITELLM_MASTER_KEY}" \
-H 'Content-Type: application/json' \
-d '{"models":["deeptutor-default"],"user_id":"u_<user-id>","max_budget":1,"budget_duration":"30d"}'
```

Put the returned virtual key in the admin LLM profile, with base URL
`http://litellm:4000/v1` and model `deeptutor-default`. Do not put a master key
in a user grant or browser response.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files =="
git ls-files | sed -n '1,200p'

echo "== target file =="
if [ -f ops/litellm/README.md ]; then
  nl -ba ops/litellm/README.md | sed -n '1,120p'
else
  echo "ops/litellm/README.md not found"
fi

echo "== search for litellm/admin/profile/user-scoped =="
rg -n "LITELLM|litellm|master key|virtual key|admin LLM|LLM profile|profile|user_id|deeptutor-default" . -S --glob '!**/.git/**' | sed -n '1,240p'

Repository: hubo1989/DeepTutor

Length of output: 8186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file with line numbers =="
awk '{printf "%5d\t%s\n", NR, $0}' ops/litellm/README.md | sed -n '1,80p'

echo "== relevant usages around LiteLLM/admin/profile =="
python3 - <<'PY'
from pathlib import Path
import re

terms = ["LITELLM","litellm","master.key","virtual.key","litellm:4000","deeptutor-default","user_id"]
for p in Path(".").rglob("*"):
    if ".git" in p.parts or p.is_dir():
        continue
    try:
        data = p.read_text(encoding="utf-8", errors="ignore")
    except Exception:
        continue
    if any(t in data for t in terms):
        lines = data.splitlines()
        hits = [(i+1, line.strip()) for i,line in enumerate(lines) if any(t in line for t in terms)]
        if hits:
            print(f"\n--- {p} ---")
            for row in hits[:12]:
                print(f"{row[0]:5d}\t{row[1]}")
PY

echo "== outline admin settings files =="
for f in $(git ls-files | rg "(^|/)settings|(^|/)admin|(^|/)profile|(^|/)grant|config|model" 2>/dev/null | head -200); do
  echo "--- $f ---"
  head -80 "$f"
done

Repository: hubo1989/DeepTutor

Length of output: 31013


🌐 Web query:

LiteLLM key/generate user_id virtual key budget spend attribution admin profile usage

💡 Result:

In LiteLLM, proper budget and spend attribution for virtual keys is achieved by explicitly associating keys with a user_id [1][2][3]. Key Points on user_id and Budget Attribution: - Auto-Attribution and Security: To prevent "orphan" keys (where spend is not attributed to any user and budget limits are bypassed), LiteLLM auto-assigns the caller's user_id to new keys generated by non-admin users if a user_id is not explicitly provided [4]. Proxy admins retain the ability to generate keys with any or no user_id [4]. - Spend Tracking: Spend for a virtual key is automatically tracked in the LiteLLM_VerificationTokenTable [2]. If the key has an attached user_id or team_id, the spend is also reflected in the LiteLLM_UserTable or LiteLLM_TeamTable, respectively [2][5]. - Budget Enforcement: When a key is associated with a user_id, LiteLLM enforces that user's personal budget alongside any team or key-level budgets [1][6]. Requests are blocked if any budget level on the path (key, user, or team) is exceeded [6]. - Admin Usage Tracking: Admins can track and filter usage by user_id using the Admin UI or dedicated endpoints [3][7][8]. The Admin UI allows filtering spend data and activity logs by specific users [7][8]. - Important Warning: End-users providing their own user parameter in request bodies can inadvertently lead to spend being attributed to that self-declared user ID rather than the owner of the API key [3]. To ensure accurate billing and limit enforcement for backend services, you must explicitly set the user_id when generating the API key for that user and consistently use that key for their requests [3]. For management tasks, such as generating keys or viewing analytics, LiteLLM uses role-based access controls [6]. Non-admin users are restricted to viewing data only for their own user_id [7][9]. Admin-scoped keys or sessions have visibility into global usage and are required to perform administrative actions like elevating budgets [6][7][9].

Citations:


不要在共享 admin LLM profile 中复用带 user_id 的 user-scoped key。

user_id 会把 key 的 spend/budget 归属到指定的 DeepTutor 用户;生成后直接放入 admin profile 时,如果该 profile 被不同用户/ grant 按相同模型使用,后续 LiteLLM 请求会按 key 附着的 user_id 记账,导致计费和预算限制不再跟随实际操作用户。改为生成 service/admin key,或让用户请求自行选择对应 user_id 的 key。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ops/litellm/README.md` around lines 10 - 23, Update the README instructions
around virtual-key generation and admin LLM profile configuration so the shared
admin profile uses a service/admin-scoped key without a user_id. Keep
user-scoped keys tied to individual user requests or grants, and explicitly
preserve the requirement that the master key is never exposed in user grants or
browser responses.

Comment on lines +1 to +22
from deeptutor.agents.visualize.utils import validate_visualization


def test_html_validation_rejects_inline_javascript_syntax_errors() -> None:
html = """<!doctype html><html><body><script>
document.body.innerHTML = '<p>A'B</p>';
</script></body></html>"""

ok, error = validate_visualization(html, "html")

assert not ok
assert "Inline JavaScript syntax error" in error


def test_html_validation_accepts_valid_inline_javascript() -> None:
html = """<!doctype html><html><body><script>
document.body.innerHTML = \"<p>A'B</p>\";
</script></body></html>"""

ok, error = validate_visualization(html, "html")

assert ok, error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

测试隐式依赖本机 node,缺失时第一个用例会失败。

_validate_inline_javascriptshutil.which("node") 为空时直接返回 (True, ""),于是 assert not ok 在没有 Node 的环境(多数纯 Python CI 镜像)中必然失败。请显式跳过。

💚 建议的修复
+import shutil
+
+import pytest
+
 from deeptutor.agents.visualize.utils import validate_visualization
 
+pytestmark = pytest.mark.skipif(
+    shutil.which("node") is None,
+    reason="inline JavaScript validation requires the node binary",
+)
+
 
 def test_html_validation_rejects_inline_javascript_syntax_errors() -> None:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from deeptutor.agents.visualize.utils import validate_visualization
def test_html_validation_rejects_inline_javascript_syntax_errors() -> None:
html = """<!doctype html><html><body><script>
document.body.innerHTML = '<p>A'B</p>';
</script></body></html>"""
ok, error = validate_visualization(html, "html")
assert not ok
assert "Inline JavaScript syntax error" in error
def test_html_validation_accepts_valid_inline_javascript() -> None:
html = """<!doctype html><html><body><script>
document.body.innerHTML = \"<p>A'B</p>\";
</script></body></html>"""
ok, error = validate_visualization(html, "html")
assert ok, error
import shutil
import pytest
from deeptutor.agents.visualize.utils import validate_visualization
pytestmark = pytest.mark.skipif(
shutil.which("node") is None,
reason="inline JavaScript validation requires the node binary",
)
def test_html_validation_rejects_inline_javascript_syntax_errors() -> None:
html = """<!doctype html><html><body><script>
document.body.innerHTML = '<p>A'B</p>';
</script></body></html>"""
ok, error = validate_visualization(html, "html")
assert not ok
assert "Inline JavaScript syntax error" in error
def test_html_validation_accepts_valid_inline_javascript() -> None:
html = """<!doctype html><html><body><script>
document.body.innerHTML = \"<p>A'B</p>\";
</script></body></html>"""
ok, error = validate_visualization(html, "html")
assert ok, error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/agents/visualize/test_utils.py` around lines 1 - 22, Update the tests
around validate_visualization to detect whether the node executable is available
and explicitly skip the inline-JavaScript validation tests when it is missing,
while retaining their existing assertions when Node is installed. Use the test
framework’s skip mechanism and apply it to both
test_html_validation_rejects_inline_javascript_syntax_errors and
test_html_validation_accepts_valid_inline_javascript.

Comment on lines +183 to +189
onError={() => setLoadError(t("Failed to load visualization"))}
/>
{loadError && (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-600 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-400">
{loadError}
</div>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 确认新增文案已进入两份 locale
rg -n 'Failed to load visualization' web/locales

Repository: hubo1989/DeepTutor

Length of output: 324


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'VisualizationViewer.tsx' . | sed 's#^\./##'

echo "== outline =="
ast-grep outline web/components/visualize/VisualizationViewer.tsx --view compact 2>/dev/null || true

echo "== relevant lines =="
cat -n web/components/visualize/VisualizationViewer.tsx | sed -n '1,260p'

echo "== visualization postMessage usages =="
rg -n "dt:visualize-height|visualize-height|onError|srcDoc|iframe.*height|setLoadError|loadError" web -S

Repository: hubo1989/DeepTutor

Length of output: 18761


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== iframe-html preview around height handler =="
cat -n web/lib/iframe-html.ts | sed -n '1,140p'

echo "== prepareIframeHtml definition/usage context =="
rg -n "function prepareIframeHtml|const prepareIframeHtml|export .*prepareIframeHtml|prepareIframeHtml\\(" web/lib/iframe-html.ts web/components/visualize/VisualizationViewer.tsx -S

echo "== relevant visualization UI code tail =="
cat -n web/components/visualize/VisualizationViewer.tsx | sed -n '260,480p'

echo "== iframe error/onload handler docs via local source if available =="
rg -n "onLoad|onError|srcDoc|iframe" web/components/visualize/VisualizationViewer.tsx

Repository: hubo1989/DeepTutor

Length of output: 17098


将 iframe 失败状态改为基于 bridge 的超时判定。

HtmlRenderer 使用 srcDoc 内联渲染,srcDoc 的解析失败只会走 onLoadonError 基本不会触发;脚本抛出异常或页面高度上报缺失时 loadError 也不会进入失败 UI。Failed to load visualization 文案已存在,需要把这行 UI 改为 onLoad 后期待 dt:visualize-height/错误上报的超时逻辑。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/components/visualize/VisualizationViewer.tsx` around lines 183 - 189,
Replace the iframe onError-based failure handling in VisualizationViewer with a
timeout-driven bridge health check after onLoad: wait for the expected
dt:visualize-height or error report, then set loadError to the existing “Failed
to load visualization” message when no valid report arrives. Update the related
load/error state and cleanup logic while preserving the current failure UI.

Comment on lines +290 to +300
function setTokenQuota(
key: "daily_tokens" | "monthly_tokens",
value: string,
) {
const parsed = Number(value);
const nextValue = Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0;
setGrant((current) => ({
...current,
token_quota: { ...current.token_quota, [key]: nextValue },
}));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

清空输入框会被解释为 0,即“无限制”。

Number("") 返回 0,所以用户为了改数字而清空输入框的瞬间,配额就变成了 0(按本区块说明即“无限制”)。若此时点击保存,受限用户会拿到无上限额度。建议对空值保留原值(或用 NaN 显式区分)。

🛡️ 建议修复
   function setTokenQuota(
     key: "daily_tokens" | "monthly_tokens",
     value: string,
   ) {
-    const parsed = Number(value);
-    const nextValue = Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0;
-    setGrant((current) => ({
-      ...current,
-      token_quota: { ...current.token_quota, [key]: nextValue },
-    }));
+    const trimmed = value.trim();
+    const parsed = Number(trimmed);
+    setGrant((current) => {
+      if (trimmed === "" || !Number.isFinite(parsed)) return current;
+      return {
+        ...current,
+        token_quota: {
+          ...current.token_quota,
+          [key]: Math.max(0, Math.floor(parsed)),
+        },
+      };
+    });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function setTokenQuota(
key: "daily_tokens" | "monthly_tokens",
value: string,
) {
const parsed = Number(value);
const nextValue = Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0;
setGrant((current) => ({
...current,
token_quota: { ...current.token_quota, [key]: nextValue },
}));
}
function setTokenQuota(
key: "daily_tokens" | "monthly_tokens",
value: string,
) {
const trimmed = value.trim();
const parsed = Number(trimmed);
setGrant((current) => {
if (trimmed === "" || !Number.isFinite(parsed)) return current;
return {
...current,
token_quota: {
...current.token_quota,
[key]: Math.max(0, Math.floor(parsed)),
},
};
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/features/multi-user/components/GrantEditor.tsx` around lines 290 - 300,
更新 setTokenQuota 以单独处理空输入,避免 Number("") 将配额立即设为 0(无限制);空值应保留当前
token_quota[key](或使用 NaN 表示未完成输入),非空值继续执行现有的有限数值、非负及向下取整逻辑。

Comment thread web/locales/zh/app.json
"Failed to load GeoGebra": "加载 GeoGebra 失败",
"Loading GeoGebra...": "正在加载 GeoGebra...",
"HTML visualization": "HTML 可视化",
"Failed to load visualization": "演示加载失败",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

译文与可视化模块术语不一致。

visualization 在其他文案中通常译为“可视化”,此处译作“演示加载失败”会让用户困惑。

🌐 建议调整
-  "Failed to load visualization": "演示加载失败",
+  "Failed to load visualization": "可视化加载失败",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"Failed to load visualization": "演示加载失败",
"Failed to load visualization": "可视化加载失败",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/locales/zh/app.json` at line 2227, Update the Chinese translation value
for the “Failed to load visualization” localization key to use the established
“可视化” terminology instead of “演示”, while preserving the existing “加载失败” meaning.

@hubo1989
hubo1989 merged commit e05ffb7 into main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant