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() 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 兼容网关。