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
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
Loading