From ffa9dceca39f43119094cb1a626c32da20b8e51b Mon Sep 17 00:00:00 2001 From: fxbin Date: Mon, 28 Sep 2026 23:07:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(backend):=20=E6=A8=A1=E5=9E=8B=E5=90=8D?= =?UTF-8?q?=E5=90=AB=E6=96=9C=E6=9D=A0=E6=97=B6=E6=8C=89=20OpenAI=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E5=85=9C=E5=BA=95=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/llm/model_resolver.py | 43 +++++++++++++--- backend/tests/test_llm_models.py | 57 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/backend/app/services/llm/model_resolver.py b/backend/app/services/llm/model_resolver.py index 241de93f..f4b93767 100644 --- a/backend/app/services/llm/model_resolver.py +++ b/backend/app/services/llm/model_resolver.py @@ -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 @@ -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 @@ -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/" 会被解析成未知路由 - # (对可达端点也会打出错误路径)。选了"完全自定义"预设但只填裸模型名 - # 时,唯一可行的路由是 OpenAI 兼容网关;模型名自带 "/" 的已在上面原样返回。 + # litellm 没有 "custom" provider,"custom/" 会被解析成未知路由。 + # 选了「完全自定义」预设时唯一可行的路由是 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 diff --git a/backend/tests/test_llm_models.py b/backend/tests/test_llm_models.py index 3a9a6f60..ef5f8b5a 100644 --- a/backend/tests/test_llm_models.py +++ b/backend/tests/test_llm_models.py @@ -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 兼容网关。 From 7d4e453c4b13e2d76aef262e65531b22a1ecdae2 Mon Sep 17 00:00:00 2001 From: fxbin Date: Mon, 28 Sep 2026 23:07:21 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(backend):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=94=B9=E4=B8=BA=20ping=20=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E9=99=8D=E4=BD=8E=E8=80=97=E6=97=B6=E4=B8=8E=E6=88=90?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/llm_models.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/app/api/v1/llm_models.py b/backend/app/api/v1/llm_models.py index 52a7c0e8..373c1ff3 100644 --- a/backend/app/api/v1/llm_models.py +++ b/backend/app/api/v1/llm_models.py @@ -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() From 9b6552cda963c07541d4ff7b1576b4b5291e7816 Mon Sep 17 00:00:00 2001 From: fxbin Date: Mon, 28 Sep 2026 23:07:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20PR=20=E5=90=88=E5=B9=B6=E9=A1=BB?= =?UTF-8?q?=E7=BB=8F=E6=89=80=E6=9C=89=E8=80=85=E5=AE=A1=E6=A0=B8=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E7=9F=A9=E9=98=B5=E5=8E=BB=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + docs/quality/regression-matrix.md | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44ca8810..f2881231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ 正常路径:**有意义的变更必须有 issue → 分支 → PR → 检查绿 → 合并**,直推 main 不是常规路径。 +- **PR 必须经仓库所有者审核**:agent 可以创建分支、推送、开 PR、盯 CI,但**不得自行合并任何 PR**——即使五项检查全绿、即使对话中有过总括性授权。合并的唯一触发条件是所有者在对话中对该 PR 的明确批准(或所有者自行点击合并)。同理,直推 main、force-push、删除提交/历史改写等一切远端写入操作均需逐次明确批准。 - **分支命名**:`issue--`(如 `issue-4-cryptography-50`)。 - **PR 必须链接 issue** 并附 Worker 证据(本地验证命令 + 结果,见 PR 模板 Verification 段)。 - **Verifier 契约**:CI 五项检查(types / tests / lint / layering / security-scan)是机器 Verifier,全绿是合并且预存红已显式处置的前提;承载行为变更的 PR 还需在模板 Verifier verdict 段落记录独立复核结论(复核者不得是同一实现过程)。 diff --git a/docs/quality/regression-matrix.md b/docs/quality/regression-matrix.md index 9ca2669d..0d098130 100644 --- a/docs/quality/regression-matrix.md +++ b/docs/quality/regression-matrix.md @@ -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 评论里。 ## 一、运行安全守则(先读再跑) @@ -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 测试(修复前) @@ -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` 泄漏跨库事务 @@ -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 实际**:六项应全绿;实际三种独立根因 @@ -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` @@ -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`) @@ -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 --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`)。