mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix(agent): 规范化模型窗口 Profile (#6289)
This commit is contained in:
@@ -1479,6 +1479,7 @@ class MoviePilotAgent:
|
|||||||
self.is_background,
|
self.is_background,
|
||||||
settings.AI_AGENT_VERBOSE,
|
settings.AI_AGENT_VERBOSE,
|
||||||
settings.LLM_TEMPERATURE,
|
settings.LLM_TEMPERATURE,
|
||||||
|
settings.LLM_MAX_CONTEXT_TOKENS,
|
||||||
settings.LLM_MAX_TOOLS,
|
settings.LLM_MAX_TOOLS,
|
||||||
settings.LLM_MAX_ITERATIONS,
|
settings.LLM_MAX_ITERATIONS,
|
||||||
self._public_runtime_config_signature(runtime_config),
|
self._public_runtime_config_signature(runtime_config),
|
||||||
|
|||||||
+97
-22
@@ -500,6 +500,94 @@ def _patch_openai_responses_empty_output_support():
|
|||||||
class LLMHelper:
|
class LLMHelper:
|
||||||
"""LLM模型相关辅助功能"""
|
"""LLM模型相关辅助功能"""
|
||||||
|
|
||||||
|
_DEFAULT_MAX_INPUT_TOKENS = 256_000
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _positive_token_limit(value: Any) -> int | None:
|
||||||
|
"""只接受可直接作为模型窗口上限的正整数。"""
|
||||||
|
return value if type(value) is int and value > 0 else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _source_input_limit(cls, source: dict[str, Any]) -> int | None:
|
||||||
|
"""合并同一事实源的 input/context 上限,采用更严格的约束。"""
|
||||||
|
candidates = [
|
||||||
|
cls._positive_token_limit(source.get("input_tokens")),
|
||||||
|
cls._positive_token_limit(source.get("context_tokens")),
|
||||||
|
]
|
||||||
|
valid = [candidate for candidate in candidates if candidate is not None]
|
||||||
|
return min(valid) if valid else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_model_profile(
|
||||||
|
cls,
|
||||||
|
model_profile: Any,
|
||||||
|
runtime: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""把当前端点的窗口事实合并到 LangChain model profile。"""
|
||||||
|
profile = dict(model_profile) if isinstance(model_profile, dict) else {}
|
||||||
|
model_record = runtime.get("model_record") or {}
|
||||||
|
model_metadata = runtime.get("model_metadata") or {}
|
||||||
|
metadata_limit = model_metadata.get("limit") or {}
|
||||||
|
metadata_source = {
|
||||||
|
"input_tokens": metadata_limit.get("input"),
|
||||||
|
"context_tokens": metadata_limit.get("context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
record_input = cls._source_input_limit(model_record)
|
||||||
|
metadata_input = cls._source_input_limit(metadata_source)
|
||||||
|
profile_input = cls._positive_token_limit(profile.get("max_input_tokens"))
|
||||||
|
configured_k = cls._positive_token_limit(settings.LLM_MAX_CONTEXT_TOKENS)
|
||||||
|
configured_input = configured_k * 1000 if configured_k else None
|
||||||
|
|
||||||
|
endpoint_matched = runtime.get("model_profile_endpoint_matched") is True
|
||||||
|
|
||||||
|
if endpoint_matched:
|
||||||
|
max_input_tokens = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in (
|
||||||
|
record_input,
|
||||||
|
metadata_input,
|
||||||
|
profile_input,
|
||||||
|
configured_input,
|
||||||
|
)
|
||||||
|
if candidate is not None
|
||||||
|
),
|
||||||
|
cls._DEFAULT_MAX_INPUT_TOKENS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
constraints = [
|
||||||
|
candidate
|
||||||
|
for candidate in (
|
||||||
|
record_input,
|
||||||
|
metadata_input,
|
||||||
|
profile_input,
|
||||||
|
configured_input,
|
||||||
|
cls._DEFAULT_MAX_INPUT_TOKENS,
|
||||||
|
)
|
||||||
|
if candidate is not None
|
||||||
|
]
|
||||||
|
max_input_tokens = min(constraints)
|
||||||
|
profile["max_input_tokens"] = max_input_tokens
|
||||||
|
|
||||||
|
record_output = (
|
||||||
|
cls._positive_token_limit(model_record.get("output_tokens"))
|
||||||
|
if endpoint_matched
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
metadata_output = (
|
||||||
|
cls._positive_token_limit(metadata_limit.get("output"))
|
||||||
|
if endpoint_matched
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
profile_output = cls._positive_token_limit(profile.get("max_output_tokens"))
|
||||||
|
max_output_tokens = record_output or metadata_output or profile_output
|
||||||
|
if max_output_tokens is not None:
|
||||||
|
profile["max_output_tokens"] = max_output_tokens
|
||||||
|
else:
|
||||||
|
profile.pop("max_output_tokens", None)
|
||||||
|
return profile
|
||||||
|
|
||||||
_SUPPORTED_THINKING_LEVELS = frozenset(
|
_SUPPORTED_THINKING_LEVELS = frozenset(
|
||||||
{"off", "auto", "minimal", "low", "medium", "high", "max", "xhigh"}
|
{"off", "auto", "minimal", "low", "medium", "high", "max", "xhigh"}
|
||||||
)
|
)
|
||||||
@@ -1328,28 +1416,15 @@ class LLMHelper:
|
|||||||
**openai_model_kwargs,
|
**openai_model_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
|
model.profile = cls._normalize_model_profile(
|
||||||
model_profile = getattr(model, "profile", None)
|
model_profile=getattr(model, "profile", None),
|
||||||
if model_profile:
|
runtime=runtime,
|
||||||
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
|
)
|
||||||
logged_model_name = getattr(model, "model", None) or getattr(
|
# ChatBedrockConverse 等模型类没有 model 属性,模型名存放在 model_id。
|
||||||
model, "model_id", model_name
|
logged_model_name = getattr(model, "model", None) or getattr(
|
||||||
)
|
model, "model_id", model_name
|
||||||
logger.debug(f"使用LLM模型: {logged_model_name},Profile: {model_profile}")
|
)
|
||||||
else:
|
logger.debug(f"使用LLM模型: {logged_model_name},Profile: {model.profile}")
|
||||||
model_record = runtime.get("model_record") or {}
|
|
||||||
model_metadata = runtime.get("model_metadata") or {}
|
|
||||||
metadata_limit = model_metadata.get("limit") or {}
|
|
||||||
max_input_tokens = (
|
|
||||||
model_record.get("input_tokens")
|
|
||||||
or model_record.get("context_tokens")
|
|
||||||
or metadata_limit.get("input")
|
|
||||||
or metadata_limit.get("context")
|
|
||||||
or settings.LLM_MAX_CONTEXT_TOKENS * 1000
|
|
||||||
)
|
|
||||||
model.profile = {
|
|
||||||
"max_input_tokens": int(max_input_tokens),
|
|
||||||
}
|
|
||||||
|
|
||||||
cls._attach_runtime_metadata(model, runtime)
|
cls._attach_runtime_metadata(model, runtime)
|
||||||
cls._attach_server_tool_metadata(model, server_tool_resolution)
|
cls._attach_server_tool_metadata(model, server_tool_resolution)
|
||||||
|
|||||||
@@ -1447,6 +1447,33 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
|
|
||||||
return spec.models_dev_provider_id
|
return spec.models_dev_provider_id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_model_profile_endpoint_matched(
|
||||||
|
cls,
|
||||||
|
spec: ProviderSpec,
|
||||||
|
base_url: Optional[str],
|
||||||
|
base_url_preset_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""判断模型目录上限是否与当前 provider 端点具有明确对应关系。"""
|
||||||
|
if spec.id == "openai":
|
||||||
|
return False
|
||||||
|
|
||||||
|
preset = cls._resolve_provider_preset(spec, base_url, base_url_preset_id)
|
||||||
|
if preset:
|
||||||
|
effective_base_url = (
|
||||||
|
cls._sanitize_base_url(base_url)
|
||||||
|
or cls._default_base_url_for_provider(spec)
|
||||||
|
)
|
||||||
|
preset_base_url = cls._sanitize_base_url(preset.value)
|
||||||
|
return effective_base_url == preset_base_url
|
||||||
|
|
||||||
|
default_base_url = cls._default_base_url_for_provider(spec)
|
||||||
|
effective_base_url = cls._sanitize_base_url(base_url)
|
||||||
|
if not effective_base_url and not default_base_url:
|
||||||
|
return bool(spec.models_dev_provider_id)
|
||||||
|
effective_base_url = effective_base_url or default_base_url
|
||||||
|
return bool(default_base_url and effective_base_url == default_base_url)
|
||||||
|
|
||||||
def resolve_model_list_base_url(
|
def resolve_model_list_base_url(
|
||||||
self,
|
self,
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
@@ -3139,6 +3166,13 @@ class LLMProviderManager(metaclass=Singleton):
|
|||||||
"model_id": model,
|
"model_id": model,
|
||||||
"model_record": model_record,
|
"model_record": model_record,
|
||||||
"model_metadata": model_metadata,
|
"model_metadata": model_metadata,
|
||||||
|
"model_profile_endpoint_matched": (
|
||||||
|
self._is_model_profile_endpoint_matched(
|
||||||
|
spec,
|
||||||
|
base_url,
|
||||||
|
base_url_preset_id=normalized_base_url_preset_id,
|
||||||
|
)
|
||||||
|
),
|
||||||
"supports_prompt_cache": self._metadata_supports_prompt_cache(
|
"supports_prompt_cache": self._metadata_supports_prompt_cache(
|
||||||
model_metadata
|
model_metadata
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -630,7 +630,7 @@ class ConfigModel(BaseModel):
|
|||||||
LLM_USE_PROXY: bool = True
|
LLM_USE_PROXY: bool = True
|
||||||
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
|
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
|
||||||
LLM_BASE_URL_PRESET: Optional[str] = None
|
LLM_BASE_URL_PRESET: Optional[str] = None
|
||||||
# LLM最大上下文Token数量(K),仅在模型目录未提供规格时作为回退值
|
# LLM最大上下文Token数量(K),用于目录缺失回退和未匹配兼容端点的保守上限
|
||||||
LLM_MAX_CONTEXT_TOKENS: int = 256
|
LLM_MAX_CONTEXT_TOKENS: int = 256
|
||||||
# LLM OpenAI兼容接口请求User-Agent
|
# LLM OpenAI兼容接口请求User-Agent
|
||||||
LLM_USER_AGENT: Optional[str] = None
|
LLM_USER_AGENT: Optional[str] = None
|
||||||
|
|||||||
@@ -241,6 +241,30 @@ async def test_agent_bundle_signature_changes_with_temperature(monkeypatch) -> N
|
|||||||
assert updated_signature != initial_signature
|
assert updated_signature != initial_signature
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_agent_bundle_signature_changes_with_context_cap(monkeypatch) -> None:
|
||||||
|
"""有效窗口配置变化时应使会话内 Agent 图缓存失效。"""
|
||||||
|
agent = MoviePilotAgent(session_id="context-cap-change", user_id="user-1")
|
||||||
|
runtime_config = {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-test",
|
||||||
|
"api_key": "test-key",
|
||||||
|
"base_url": "https://llm.example.com/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
agent,
|
||||||
|
"_resolve_llm_runtime_config",
|
||||||
|
new=AsyncMock(return_value=runtime_config),
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(settings, "LLM_MAX_CONTEXT_TOKENS", 32)
|
||||||
|
initial_signature = await agent._agent_bundle_signature(streaming=False)
|
||||||
|
monkeypatch.setattr(settings, "LLM_MAX_CONTEXT_TOKENS", 64)
|
||||||
|
updated_signature = await agent._agent_bundle_signature(streaming=False)
|
||||||
|
|
||||||
|
assert updated_signature != initial_signature
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_agent_bundle_signature_changes_with_tool_catalog() -> None:
|
async def test_agent_bundle_signature_changes_with_tool_catalog() -> None:
|
||||||
"""工具目录 revision 必须参与会话内 Agent 图缓存签名。"""
|
"""工具目录 revision 必须参与会话内 Agent 图缓存签名。"""
|
||||||
|
|||||||
@@ -195,6 +195,25 @@ class _OfflineProviderManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _OfflineProviderError(RuntimeError):
|
||||||
|
"""离线 provider 替身使用的兼容异常类型。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_offline_auth_result(*_args, **_kwargs):
|
||||||
|
"""满足 LLM provider 包导出的最小 HTML renderer 契约。"""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider_module(manager_cls):
|
||||||
|
"""构造满足 ``app.agent.llm`` 包导入契约的 provider 替身。"""
|
||||||
|
provider_module = ModuleType("app.agent.llm.provider")
|
||||||
|
provider_module.LLMProviderManager = manager_cls
|
||||||
|
provider_module.LLMProviderError = _OfflineProviderError
|
||||||
|
provider_module.LLMProviderAuthError = _OfflineProviderError
|
||||||
|
provider_module.render_auth_result_html = _render_offline_auth_result
|
||||||
|
return provider_module
|
||||||
|
|
||||||
|
|
||||||
class LlmHelperTestCallTest(unittest.TestCase):
|
class LlmHelperTestCallTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""为每个用例默认注入离线 provider,确保 get_llm 不会真访问 models.dev。
|
"""为每个用例默认注入离线 provider,确保 get_llm 不会真访问 models.dev。
|
||||||
@@ -202,12 +221,202 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
需要校验特定 resolve_runtime 行为的用例,可在自身 patch.dict 中再覆盖
|
需要校验特定 resolve_runtime 行为的用例,可在自身 patch.dict 中再覆盖
|
||||||
``sys.modules['app.agent.llm.provider']``;用例结束后由 addCleanup 还原。
|
``sys.modules['app.agent.llm.provider']``;用例结束后由 addCleanup 还原。
|
||||||
"""
|
"""
|
||||||
provider_module = ModuleType("app.agent.llm.provider")
|
provider_module = _build_provider_module(_OfflineProviderManager)
|
||||||
provider_module.LLMProviderManager = _OfflineProviderManager
|
|
||||||
patcher = patch.dict(sys.modules, {"app.agent.llm.provider": provider_module})
|
patcher = patch.dict(sys.modules, {"app.agent.llm.provider": provider_module})
|
||||||
patcher.start()
|
patcher.start()
|
||||||
self.addCleanup(patcher.stop)
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_fills_partial_profile_from_provider_record(self):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={
|
||||||
|
"tool_calling": True,
|
||||||
|
"max_output_tokens": 8192,
|
||||||
|
},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "google",
|
||||||
|
"model_profile_endpoint_matched": True,
|
||||||
|
"model_record": {
|
||||||
|
"input_tokens": 64000,
|
||||||
|
"output_tokens": 4096,
|
||||||
|
},
|
||||||
|
"model_metadata": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 64000)
|
||||||
|
self.assertEqual(profile["max_output_tokens"], 4096)
|
||||||
|
self.assertTrue(profile["tool_calling"])
|
||||||
|
|
||||||
|
def test_normalize_model_profile_prefers_known_provider_context_limit(self):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={"max_input_tokens": 128000, "image_inputs": True},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "deepseek",
|
||||||
|
"model_profile_endpoint_matched": True,
|
||||||
|
"model_record": {
|
||||||
|
"input_tokens": 64000,
|
||||||
|
"context_tokens": 32768,
|
||||||
|
},
|
||||||
|
"model_metadata": {"limit": {"context": 64000}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 32768)
|
||||||
|
self.assertTrue(profile["image_inputs"])
|
||||||
|
|
||||||
|
def test_normalize_model_profile_caps_unmatched_known_provider_endpoint(self):
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 16):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={"max_input_tokens": 128000},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "deepseek",
|
||||||
|
"model_profile_endpoint_matched": False,
|
||||||
|
"model_record": {"context_tokens": 128000},
|
||||||
|
"model_metadata": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 16000)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_keeps_builtin_cap_for_unmatched_known_endpoint(self):
|
||||||
|
"""未匹配的已知 provider 端点不能由较大的用户配置放宽保守上限。"""
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 512):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={"max_input_tokens": 1000000},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "deepseek",
|
||||||
|
"model_profile_endpoint_matched": False,
|
||||||
|
"model_record": {"context_tokens": 1000000},
|
||||||
|
"model_metadata": {"limit": {"input": 1000000}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 256000)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_caps_generic_openai_endpoint(self):
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 32):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={"max_input_tokens": 128000},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "openai",
|
||||||
|
"model_record": {
|
||||||
|
"input_tokens": 128000,
|
||||||
|
"source": "models.dev-cache",
|
||||||
|
},
|
||||||
|
"model_metadata": {"limit": {"input": 128000}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 32000)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_keeps_builtin_cap_for_generic_endpoint(self):
|
||||||
|
"""通用兼容端点同时受用户配置和内建保守上限约束。"""
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 512):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={"max_input_tokens": 1000000},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "openai",
|
||||||
|
"model_record": {"input_tokens": 1000000},
|
||||||
|
"model_metadata": {"limit": {"context": 1000000}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 256000)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_keeps_smaller_generic_profile_limit(self):
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 256):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={
|
||||||
|
"max_input_tokens": 64000,
|
||||||
|
"max_output_tokens": 4096,
|
||||||
|
},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "openai",
|
||||||
|
"model_record": {
|
||||||
|
"context_tokens": 128000,
|
||||||
|
"output_tokens": 16384,
|
||||||
|
},
|
||||||
|
"model_metadata": {"limit": {"output": 8192}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 64000)
|
||||||
|
self.assertEqual(profile["max_output_tokens"], 4096)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_uses_builtin_cap_when_config_is_invalid(self):
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", -1):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={"max_input_tokens": 1000000},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "openai",
|
||||||
|
"model_record": {},
|
||||||
|
"model_metadata": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 256000)
|
||||||
|
|
||||||
|
def test_normalize_model_profile_rejects_invalid_limits_and_uses_default(self):
|
||||||
|
with patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 0):
|
||||||
|
profile = llm_module.LLMHelper._normalize_model_profile(
|
||||||
|
model_profile={
|
||||||
|
"max_input_tokens": False,
|
||||||
|
"max_output_tokens": "8192",
|
||||||
|
"structured_output": True,
|
||||||
|
},
|
||||||
|
runtime={
|
||||||
|
"provider_id": "google",
|
||||||
|
"model_profile_endpoint_matched": True,
|
||||||
|
"model_record": {
|
||||||
|
"input_tokens": True,
|
||||||
|
"context_tokens": -1,
|
||||||
|
"output_tokens": 0,
|
||||||
|
},
|
||||||
|
"model_metadata": {
|
||||||
|
"limit": {
|
||||||
|
"input": "64000",
|
||||||
|
"context": 0.0,
|
||||||
|
"output": -2,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile["max_input_tokens"], 256000)
|
||||||
|
self.assertNotIn("max_output_tokens", profile)
|
||||||
|
self.assertTrue(profile["structured_output"])
|
||||||
|
|
||||||
|
def test_get_llm_partial_profile_supports_fraction_summarization(self):
|
||||||
|
from langchain.agents.middleware import SummarizationMiddleware
|
||||||
|
|
||||||
|
class _FakeChatOpenAI:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.model = kwargs["model"]
|
||||||
|
self.profile = {"tool_calling": True}
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
sys.modules,
|
||||||
|
{"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)},
|
||||||
|
), patch.object(llm_module.settings, "LLM_MAX_CONTEXT_TOKENS", 32):
|
||||||
|
model = asyncio.run(
|
||||||
|
llm_module.LLMHelper.get_llm(
|
||||||
|
provider="openai",
|
||||||
|
model="custom-model",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url="https://custom.example.com/v1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
middleware = SummarizationMiddleware(
|
||||||
|
model=model,
|
||||||
|
trigger=("fraction", 0.85),
|
||||||
|
token_counter=lambda _messages: 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(model.profile["max_input_tokens"], 32000)
|
||||||
|
self.assertTrue(model.profile["tool_calling"])
|
||||||
|
self.assertIs(middleware.model, model)
|
||||||
|
|
||||||
def test_extract_text_content_ignores_non_text_blocks(self):
|
def test_extract_text_content_ignores_non_text_blocks(self):
|
||||||
content = [
|
content = [
|
||||||
{"type": "reasoning", "text": "internal"},
|
{"type": "reasoning", "text": "internal"},
|
||||||
@@ -606,8 +815,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
self.model = kwargs["model"]
|
self.model = kwargs["model"]
|
||||||
self.profile = None
|
self.profile = None
|
||||||
|
|
||||||
provider_module = ModuleType("app.agent.llm.provider")
|
provider_module = _build_provider_module(_FakeProviderManager)
|
||||||
provider_module.LLMProviderManager = _FakeProviderManager
|
|
||||||
openai_module = ModuleType("langchain_openai")
|
openai_module = ModuleType("langchain_openai")
|
||||||
openai_module.ChatOpenAI = _FakeChatOpenAI
|
openai_module.ChatOpenAI = _FakeChatOpenAI
|
||||||
|
|
||||||
@@ -670,8 +878,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
self.model = kwargs["model"]
|
self.model = kwargs["model"]
|
||||||
self.profile = None
|
self.profile = None
|
||||||
|
|
||||||
provider_module = ModuleType("app.agent.llm.provider")
|
provider_module = _build_provider_module(_FakeProviderManager)
|
||||||
provider_module.LLMProviderManager = _FakeProviderManager
|
|
||||||
anthropic_module = ModuleType("langchain_anthropic")
|
anthropic_module = ModuleType("langchain_anthropic")
|
||||||
anthropic_module.ChatAnthropic = _FakeChatAnthropic
|
anthropic_module.ChatAnthropic = _FakeChatAnthropic
|
||||||
|
|
||||||
@@ -785,8 +992,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
"model_metadata": {},
|
"model_metadata": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
provider_module = ModuleType("app.agent.llm.provider")
|
provider_module = _build_provider_module(_FakeProviderManager)
|
||||||
provider_module.LLMProviderManager = _FakeProviderManager
|
|
||||||
fake_openai_modules, _ = _build_fake_openai_modules()
|
fake_openai_modules, _ = _build_fake_openai_modules()
|
||||||
|
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
@@ -1035,8 +1241,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
|||||||
self.model = kwargs["model"]
|
self.model = kwargs["model"]
|
||||||
self.profile = None
|
self.profile = None
|
||||||
|
|
||||||
provider_module = ModuleType("app.agent.llm.provider")
|
provider_module = _build_provider_module(_FakeProviderManager)
|
||||||
provider_module.LLMProviderManager = _FakeProviderManager
|
|
||||||
|
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
sys.modules,
|
sys.modules,
|
||||||
|
|||||||
@@ -520,6 +520,77 @@ class LlmProviderRegistryTest(unittest.TestCase):
|
|||||||
self.assertEqual(runtime["provider_id"], "moonshot")
|
self.assertEqual(runtime["provider_id"], "moonshot")
|
||||||
self.assertEqual(runtime["runtime"], "anthropic_compatible")
|
self.assertEqual(runtime["runtime"], "anthropic_compatible")
|
||||||
self.assertEqual(runtime["base_url"], "https://api.kimi.com/coding")
|
self.assertEqual(runtime["base_url"], "https://api.kimi.com/coding")
|
||||||
|
self.assertTrue(runtime["model_profile_endpoint_matched"])
|
||||||
|
|
||||||
|
def test_resolve_runtime_marks_unmatched_custom_endpoint_for_profile_cap(self):
|
||||||
|
manager = LLMProviderManager()
|
||||||
|
|
||||||
|
runtime = asyncio.run(
|
||||||
|
manager.resolve_runtime(
|
||||||
|
provider_id="deepseek",
|
||||||
|
model="deepseek-chat",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url="https://proxy.example.com/v1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(runtime["model_profile_endpoint_matched"])
|
||||||
|
|
||||||
|
def test_resolve_runtime_rejects_preset_identity_for_different_url(self):
|
||||||
|
manager = LLMProviderManager()
|
||||||
|
|
||||||
|
runtime = asyncio.run(
|
||||||
|
manager.resolve_runtime(
|
||||||
|
provider_id="moonshot",
|
||||||
|
model="kimi-k2.5",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url="https://proxy.example.com/v1",
|
||||||
|
base_url_preset_id="moonshot-cn",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(runtime["model_profile_endpoint_matched"])
|
||||||
|
|
||||||
|
def test_resolve_runtime_rejects_preset_metadata_when_url_falls_back(self):
|
||||||
|
manager = LLMProviderManager()
|
||||||
|
|
||||||
|
runtime = asyncio.run(
|
||||||
|
manager.resolve_runtime(
|
||||||
|
provider_id="moonshot",
|
||||||
|
model="kimi-k2.5",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url_preset_id="moonshot-kimi-coding",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(runtime["model_profile_endpoint_matched"])
|
||||||
|
|
||||||
|
def test_resolve_runtime_never_trusts_generic_openai_model_metadata(self):
|
||||||
|
manager = LLMProviderManager()
|
||||||
|
|
||||||
|
runtime = asyncio.run(
|
||||||
|
manager.resolve_runtime(
|
||||||
|
provider_id="openai",
|
||||||
|
model="gpt-4o",
|
||||||
|
api_key="sk-test",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(runtime["model_profile_endpoint_matched"])
|
||||||
|
|
||||||
|
def test_resolve_runtime_matches_native_provider_without_base_url(self):
|
||||||
|
manager = LLMProviderManager()
|
||||||
|
|
||||||
|
runtime = asyncio.run(
|
||||||
|
manager.resolve_runtime(
|
||||||
|
provider_id="google",
|
||||||
|
model="gemini-2.5-flash",
|
||||||
|
api_key="sk-test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(runtime["model_profile_endpoint_matched"])
|
||||||
|
|
||||||
def test_resolve_model_list_strategy_prefers_kimi_for_coding_preset(self):
|
def test_resolve_model_list_strategy_prefers_kimi_for_coding_preset(self):
|
||||||
manager = LLMProviderManager()
|
manager = LLMProviderManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user