Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

正常路径:**有意义的变更必须有 issue → 分支 → PR → 检查绿 → 合并**,直推 main 不是常规路径。

- **PR 必须经仓库所有者审核**:agent 可以创建分支、推送、开 PR、盯 CI,但**不得自行合并任何 PR**——即使五项检查全绿、即使对话中有过总括性授权。合并的唯一触发条件是所有者在对话中对该 PR 的明确批准(或所有者自行点击合并)。同理,直推 main、force-push、删除提交/历史改写等一切远端写入操作均需逐次明确批准。
- **分支命名**:`issue-<number>-<short-slug>`(如 `issue-4-cryptography-50`)。
- **PR 必须链接 issue** 并附 Worker 证据(本地验证命令 + 结果,见 PR 模板 Verification 段)。
- **Verifier 契约**:CI 五项检查(types / tests / lint / layering / security-scan)是机器 Verifier,全绿是合并且预存红已显式处置的前提;承载行为变更的 PR 还需在模板 Verifier verdict 段落记录独立复核结论(复核者不得是同一实现过程)。
Expand Down
8 changes: 5 additions & 3 deletions backend/app/api/v1/llm_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,13 +678,15 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):

resolved_model = _resolve_litellm_model(model)

test_prompt = "请用一句话介绍你自己,包括你的模型名称。"
# 连通性探测语义(配置时验证路由/可达/鉴权/模型存在):提示词 ping +
# max_tokens=16 把输出上限压到短语级——成本趋近于零、推理型模型也来不及
# 长思考;思考占满 16 token 导致正文为空时,由下方 note 分支提示调大重试。
kwargs = _completion_kwargs(
model,
resolved_model,
[{"role": "user", "content": test_prompt}],
[{"role": "user", "content": "ping"}],
temperature=0.3,
max_tokens=200,
max_tokens=16,
)

start = time.monotonic()
Expand Down
43 changes: 35 additions & 8 deletions backend/app/services/llm/model_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
from functools import lru_cache
from pathlib import Path
from typing import Any, Protocol
from urllib.parse import urlsplit, urlunsplit
Expand All @@ -17,6 +18,24 @@
_rewrite_logged: set[str] = set()


@lru_cache(maxsize=1)
def _known_litellm_providers() -> frozenset[str]:
"""litellm 的 provider 注册表(StrEnum 成员可直接与 str 相等比较)。

用于区分「用户显式给出的 litellm 路由串」与「组织/模型命名的网关模型名」:
前者首段命中注册表(如 deepseek/),后者不命中(如 example-org/)。
"""
try:
import litellm

# 成员是 (str, Enum) 混入:str(member) 是 "LlmProviders.DEEPSEEK",
# 与请求串可比的是 .value("deepseek")。
return frozenset(str(p.value) if hasattr(p, "value") else str(p) for p in litellm.provider_list)
except Exception: # noqa: BLE001 — litellm 导入失败时退化为「无已知前缀」
logger.warning("litellm provider_list unavailable; slash model ids keep legacy passthrough")
return frozenset()


class ModelLike(Protocol):
provider: str
model_id: str
Expand Down Expand Up @@ -51,20 +70,28 @@ def resolve_litellm_model(model: ModelLike) -> str:
return explicit_model

model_id = _clean(model.model_id) or ""
if "/" in model_id:
return model_id

provider = _clean(params.get("litellm_provider") or litellm_params.get("custom_llm_provider") or model.provider)

if provider == "custom":
# litellm 没有 "custom" provider,"custom/<model>" 会被解析成未知路由
# (对可达端点也会打出错误路径)。选了"完全自定义"预设但只填裸模型名
# 时,唯一可行的路由是 OpenAI 兼容网关;模型名自带 "/" 的已在上面原样返回。
# litellm 没有 "custom" provider,"custom/<model>" 会被解析成未知路由。
# 选了「完全自定义」预设时唯一可行的路由是 OpenAI 兼容网关——模型名
# 含 "/"(如组织/模型命名 example-org/example-29b)也必须走该路由:
# 原样透传会被 litellm 把首段当 provider 解析而报 BadRequestError(#83)。
return f"openai/{model_id}"

if provider:
return f"{provider}/{model_id}"

# 显式 provider 优先;模型名已带同前缀时去重,避免 openai/openai/...。
prefix = f"{provider}/"
return model_id if model_id.startswith(prefix) else f"{provider}/{model_id}"

# 无 provider 信息时的应用层兜底(#83):
# - 含 "/" 且首段命中 litellm 已知 provider → 视为用户显式路由串,原样返回;
# - 含 "/" 但首段未知(组织/模型命名,或 provider 拼写错误)→ 兜底 OpenAI
# 兼容路由。本应用的模型目录条目对这类命名几乎总是自定义网关(带
# api_base),litellm 的 openai/ 前缀会剥掉前缀并把剩余部分原样发给网关。
# - 裸模型名 → 原样返回(litellm 自身把无斜杠名称默认为 openai)。
if "/" in model_id and model_id.split("/", 1)[0] not in _known_litellm_providers():
return f"openai/{model_id}"
return model_id


Expand Down
57 changes: 57 additions & 0 deletions backend/tests/test_llm_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,63 @@ def test_shared_model_resolver_prefers_explicit_litellm_model():
assert resolve_litellm_model(model) == "openai/deepseek-v4-flash-free"


def test_shared_model_resolver_routes_slash_namespaced_custom_model_as_openai():
"""#83:组织/模型命名(example-org/example-29b)在 custom 预设下必须走
OpenAI 兼容路由,原样透传会被 litellm 当未知 provider 拒绝。"""
model = SimpleNamespace(
provider="custom",
model_id="example-org/example-29b",
api_base="https://api.example-gateway.test/v1",
)

assert resolve_litellm_model(model) == "openai/example-org/example-29b"


def test_shared_model_resolver_routes_slash_namespaced_model_with_explicit_provider():
"""显式 provider(如 openai 预设 + 自定义网关)时,组织/模型命名按该 provider 路由。"""
model = SimpleNamespace(
provider="openai",
model_id="example-org/example-29b",
api_base="https://api.example-gateway.test/v1",
)

assert resolve_litellm_model(model) == "openai/example-org/example-29b"


def test_shared_model_resolver_dedupes_provider_prefix():
"""模型名已带同 provider 前缀时不重复拼接。"""
model = SimpleNamespace(provider="openai", model_id="openai/gpt-4.1-mini", api_base=None)

assert resolve_litellm_model(model) == "openai/gpt-4.1-mini"


def test_shared_model_resolver_keeps_slash_model_id_with_known_provider_prefix():
"""无 provider 信息 + 首段命中 litellm 已知 provider:保持原样(显式路由串)。"""
model = SimpleNamespace(provider="", model_id="deepseek/deepseek-chat", api_base=None)

assert resolve_litellm_model(model) == "deepseek/deepseek-chat"


def test_shared_model_resolver_falls_back_to_openai_for_unknown_slash_prefix():
"""#83 应用层兜底:无 provider 信息 + 首段不是已知 provider(组织/模型命名,
或 provider 拼写错误)→ 走 OpenAI 兼容路由而非原样透传。"""
model = SimpleNamespace(
provider="",
model_id="example-org/example-29b",
api_base="https://api.example-gateway.test/v1",
)

assert resolve_litellm_model(model) == "openai/example-org/example-29b"


def test_shared_model_resolver_falls_back_to_openai_for_typoed_provider_prefix():
"""拼写错误的前缀(antrhopic)也走兜底:错误会表现为网关/OpenAI 的
model-not-found,而不是 litellm 的路由拒绝(应用层取舍,见 #83 评论)。"""
model = SimpleNamespace(provider="", model_id="antrhopic/claude-sonnet-4", api_base=None)

assert resolve_litellm_model(model) == "openai/antrhopic/claude-sonnet-4"


def test_shared_model_resolver_routes_bare_custom_provider_as_openai_compatible():
# "完全自定义"预设落库的 provider=custom + 裸模型名:litellm 没有 custom
# provider,唯一可行路由是 OpenAI 兼容网关。
Expand Down
23 changes: 12 additions & 11 deletions docs/quality/regression-matrix.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 质量回归矩阵与缺陷清单(#5)

> 建立日期:2026-09-27(基于 main @ 7203847 的证据核查);同日独立复核修正了 §一 OAuth 命令、§三 第 1/5 行命令与证据口径、D-5 公告计数口径。
> 建立日期:2026-09-27(基于 main @ 7203847 的证据核查);同日独立复核修正了 §一 OAuth 命令、§三 第 1/5 行命令与证据口径、依赖漏洞条目的公告计数口径。
> (历史说明:早期条目使用过 D-1~D-10 编号,已退役;已关闭 issue/PR 评论中的旧编号即指本节对应条目。)
> 本文件是 [Issue #5](https://github.com/fxbin/TopicEye/issues/5) 要求的「单一缺陷/复现矩阵」,后续缺陷与回归条目**只在此追加**,避免散落在聊天记录和 issue 评论里。

## 一、运行安全守则(先读再跑)
Expand All @@ -19,7 +20,7 @@

每条按 #5 要求的十个维度登记;复现与修复细节以 issue 正文为准,此处只留索引和当前状态。

### D-1 管理员权限测试泄漏真实 DuckDB attach — #22 ✅ 已修复关闭(2026-09-27)
### 管理员权限测试泄漏真实 DuckDB attach — #22 ✅ 已修复关闭(2026-09-27)
- **症状**:权限测试通过后,下一个测试在 fixture setup 阶段挂起(exit 124)
- **环境/前提**:PG 套件,`clean_tables` autouse
- **复现**:连续跑 `tests/test_admin_api_permissions.py` 与后续任意 PG 测试(修复前)
Expand All @@ -29,7 +30,7 @@
- **回归测试**:`pytest tests/test_admin_api_permissions.py`(7 passed @ 2026-09-27)
- **owner**:#22(修复 PR #29,`FakeAnalytics` 注入)

### D-2 收藏测试触发真实后台向量重建 — #27 ✅ 已修复关闭(2026-09-27)
### 收藏测试触发真实后台向量重建 — #27 ✅ 已修复关闭(2026-09-27)
- **症状**:收藏测试后、content pipeline 测试前 fixture 挂起 180s+
- **复现**:连续跑 `test_content_favorite_api.py` → `test_content_pipeline.py`(修复前)
- **期望 vs 实际**:API 测试不启动生产后台任务;实际 `trigger_vector_rebuild` 泄漏跨库事务
Expand All @@ -38,7 +39,7 @@
- **回归测试**:连续执行 `pytest tests/test_content_favorite_api.py tests/test_content_pipeline.py`(15 passed @ 2026-09-27)
- **owner**:#27(修复 PR #33 测试隔离 + PR #56 生产侧任务追踪/优雅关闭)

### D-3 分析恢复 / inflight 去重 / post-sync drain 回归簇 — #35 ✅ 已修复关闭(2026-09-27)
### 分析恢复 / inflight 去重 / post-sync drain 回归簇 — #35 ✅ 已修复关闭(2026-09-27)
- **症状**:六项测试失败(权限契约过时 / 旧同步后处理函数引用 / DB 连接跨事件循环复用)
- **复现**:`pytest tests/test_analysis_recovery.py tests/test_analysis_jobs_persistence.py tests/test_analysis_notification_permissions.py`
- **期望 vs 实际**:六项应全绿;实际三种独立根因
Expand All @@ -47,7 +48,7 @@
- **回归测试**:同上复现命令(47 passed @ 2026-09-27)
- **owner**:#35(修复 PR #38 / #43 / #47;组合验证 PR #48 已关闭未合并,由新鲜运行取代)

### D-4 DuckDB Today Picks 查询契约回归 — #45 ✅ 已修复关闭(2026-09-27)
### DuckDB Today Picks 查询契约回归 — #45 ✅ 已修复关闭(2026-09-27)
- **症状**:四项 Today Picks 查询测试失败
- **根因**:测试 fixture 临时表缺 `content_type` 列(契约重构后未跟上),非生产 SQL 回归
- **复现**:`pytest tests/test_duckdb_service.py -k today_picks`
Expand All @@ -56,7 +57,7 @@
- **回归测试**:同上(4 passed @ 2026-09-27)
- **owner**:#45(修复 PR #54)

### D-5 依赖安全漏洞 — #4 ✅ 已修复关闭(2026-09-27)
### 依赖安全漏洞 — #4 ✅ 已修复关闭(2026-09-27)
- **症状**:pip-audit / npm audit 报漏洞
- **环境**:uv.lock 锁定生产依赖 + 前端 package-lock(2026-09-27 修复后复扫)
- **期望 vs 实际**:无未处置高危漏洞;修复后前后端均 0 公告(后端 `No known vulnerabilities found`,前端 `found 0 vulnerabilities`)
Expand All @@ -66,25 +67,25 @@
- **owner**:#4(前端 #66:`npm audit fix` + vitest 5.0.2 + @types/node 22;后端 #67:cryptography 50.0.1;#68:fastapi 0.141.1 + starlette 1.7.0)
- **日志/证据**:`uvx pip-audit -r <uv export --frozen 产物> --no-deps --disable-pip`;修复前口径(后端 12 条唯一公告 + 前端 6 条)见 #4 评论存档

### D-6 OAuth 回调经 URL fragment 传完整凭证 — #63 ✅ 已修复关闭(2026-09-27)
### OAuth 回调经 URL fragment 传完整凭证 — #63 ✅ 已修复关闭(2026-09-27)
- 安全收窄项:fragment 不再含 access token,仅带 provider/expires_at;凭证只经 HttpOnly cookie;前端回调页不消费任何凭证参数(旧 #token= 链接落地但不消费)。
- 回归测试:`tests_oauth_patch/test_oauth_fix.py` GitHub/Google 双 mock 断言 fragment 无 token。owner:#63(修复 PR #80;真实 provider E2E 待人工回归)。

### D-7 管理员第三方账号手动绑定流程缺失 — #64 ✅ 已修复关闭(2026-09-27)
### 管理员第三方账号手动绑定流程缺失 — #64 ✅ 已修复关闭(2026-09-27)
- 已实现 step-up 绑定流程:`POST /auth/oauth/{provider}/bind/start`(重输密码→绑定意图入 session,TTL 10min)+ 回调绑定分支(会话本人校验、未验证邮箱拒绝、双冲突防护);绑定成功不建新登录会话。
- 回归测试:`tests_oauth_patch/test_oauth_bind.py` 六场景(含会话不匹配与无意图回退)。owner:#64(修复 PR #81;设置页 UI 与解绑端点为后续)。

### D-8 /health/ready 判定门恒真 — #71 ✅ 已修复关闭(2026-09-27)
### /health/ready 判定门恒真 — #71 ✅ 已修复关闭(2026-09-27)
- 症状:ready 的 oltp 判定读自纯元数据 dict(`database_diagnostics` 不做连通性探测),恒为 True,`not_ready` 分支不可达;OLTP 不可达仍返回 ready。
- 边界:health 路由 / 部署路由层;复现性 always。
- 回归测试:`tests/test_health_endpoints.py` 四态路由级(正常 / 调度器禁用 / DuckDB 降级 / OLTP 不可达 503 + /health 别名)。owner:#71(Parent #6,修复 PR #75)。

### D-9 三处无主 create_task — #72 ✅ 已修复关闭(2026-09-27)
### 三处无主 create_task — #72 ✅ 已修复关闭(2026-09-27)
- 症状:`scheduler.py:1204/1205` 启动 rescan/恢复任务与 `daily_reports.py:294` 日报后台——无引用、无异常收集、不受停机管理;日报 `mark_error` 失败被裸 `except: pass` 吞掉可永卡 GENERATING。
- 边界:scheduler 启动路径 / daily_reports / lifespan 停机序。
- 回归测试:`tests/test_task_registry.py`(异常记日志注销 / drain 取消清空 / 超时告警不抛出)。owner:#72(Parent #6,修复 PR #76:`app/core/task_registry.py` 收编三处 + mark_error 留痕)。

### D-10 优雅停机链中断点 — #73 ✅ 已修复关闭(2026-09-27)
### 优雅停机链中断点 — #73 ✅ 已修复关闭(2026-09-27)
- 症状:`_cache_warmup_task` 非 CancelledError 异常会中断后续全部清理步骤;jieba 预热 await 无超时且 `to_thread` 不可取消,可挂死停机;整体停机无 deadline。
- 回归测试:`tests/test_shutdown_prewarm.py`(异常不外抛且留痕 / jieba 超时不挂死 / 正常与已取消路径)。owner:#73(Parent #6,修复 PR #77:`_shutdown_prewarm_tasks`)。

Expand Down
Loading