mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-15 10:16:03 +00:00
Compare commits
7 Commits
fix/litell
...
codex/plug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4622f2e86a | ||
|
|
e9fe2f2d43 | ||
|
|
27be09ab15 | ||
|
|
1ef4507d9a | ||
|
|
2e7978317c | ||
|
|
b7d8332cb0 | ||
|
|
7fe3eedeea |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.1"
|
version = "4.10.2"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.10.1'
|
__version__ = '4.10.2'
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
constants.instance_id = new_id
|
constants.instance_id = new_id
|
||||||
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
||||||
|
|
||||||
|
# Instance creation timestamp: sourced from data/labels/instance_id.json.
|
||||||
|
# Instances created before this field existed (or supplied via
|
||||||
|
# system.instance_id) won't have it, so backfill with the current time
|
||||||
|
# and persist it via the dump below — from then on it stays stable.
|
||||||
|
instance_create_ts = ap.instance_id.data.get('instance_create_ts', 0)
|
||||||
|
if not isinstance(instance_create_ts, int) or instance_create_ts <= 0:
|
||||||
|
instance_create_ts = int(time.time())
|
||||||
|
ap.instance_id.data['instance_create_ts'] = instance_create_ts
|
||||||
|
constants.instance_create_ts = instance_create_ts
|
||||||
|
|
||||||
print(f'LangBot instance id: {constants.instance_id}')
|
print(f'LangBot instance id: {constants.instance_id}')
|
||||||
print(f'LangBot edition: {constants.edition}')
|
print(f'LangBot edition: {constants.edition}')
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
):
|
):
|
||||||
self.listeners.pop(event_type, None)
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""Delegate stream output check to ws_adapter."""
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.is_stream_output_supported()
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id: str | int, event: platform_events.MessageEvent) -> bool:
|
||||||
|
"""Delegate create_message_card to ws_adapter."""
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.create_message_card(message_id, event)
|
||||||
|
return False
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -197,10 +197,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
self,
|
self,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
task_context: taskmgr.TaskContext | None,
|
task_context: taskmgr.TaskContext | None,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None, str | None]:
|
||||||
"""Extract plugin identity and dependency metadata from a plugin package."""
|
"""Extract plugin identity and dependency metadata from a plugin package."""
|
||||||
plugin_author = None
|
plugin_author = None
|
||||||
plugin_name = None
|
plugin_name = None
|
||||||
|
plugin_version = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
@@ -209,6 +210,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
metadata = manifest.get('metadata', {})
|
metadata = manifest.get('metadata', {})
|
||||||
plugin_author = metadata.get('author')
|
plugin_author = metadata.get('author')
|
||||||
plugin_name = metadata.get('name')
|
plugin_name = metadata.get('name')
|
||||||
|
plugin_version = metadata.get('version')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -227,7 +229,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return plugin_author, plugin_name
|
return plugin_author, plugin_name, plugin_version
|
||||||
|
|
||||||
async def _install_mcp_from_marketplace(
|
async def _install_mcp_from_marketplace(
|
||||||
self,
|
self,
|
||||||
@@ -369,6 +371,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
):
|
):
|
||||||
plugin_author = install_info.get('plugin_author')
|
plugin_author = install_info.get('plugin_author')
|
||||||
plugin_name = install_info.get('plugin_name')
|
plugin_name = install_info.get('plugin_name')
|
||||||
|
plugin_file_transferred = False
|
||||||
|
|
||||||
if install_source == PluginInstallSource.MARKETPLACE:
|
if install_source == PluginInstallSource.MARKETPLACE:
|
||||||
# Handle marketplace plugin/mcp/skill installation
|
# Handle marketplace plugin/mcp/skill installation
|
||||||
@@ -463,9 +466,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
file_bytes = download_resp.content
|
||||||
self._inspect_plugin_package(file_bytes, task_context)
|
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(
|
||||||
|
file_bytes,
|
||||||
|
task_context,
|
||||||
|
)
|
||||||
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
if task_context is not None and plugin_version:
|
||||||
|
task_context.metadata['plugin_version'] = plugin_version
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
|
install_source = PluginInstallSource.LOCAL
|
||||||
|
plugin_file_transferred = True
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
# Continue to install via runtime
|
# Continue to install via runtime
|
||||||
else:
|
else:
|
||||||
@@ -481,12 +493,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
mcp_resp.raise_for_status()
|
mcp_resp.raise_for_status()
|
||||||
raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
|
raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
|
||||||
|
|
||||||
if install_source == PluginInstallSource.LOCAL:
|
if install_source == PluginInstallSource.LOCAL and not plugin_file_transferred:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
if task_context is not None and plugin_version:
|
||||||
|
task_context.metadata['plugin_version'] = plugin_version
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
@@ -523,9 +537,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
file_bytes = b''.join(chunks)
|
file_bytes = b''.join(chunks)
|
||||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
plugin_author, plugin_name, plugin_version = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if task_context is not None and plugin_author and plugin_name:
|
if task_context is not None and plugin_author and plugin_name:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
if task_context is not None and plugin_version:
|
||||||
|
task_context.metadata['plugin_version'] = plugin_version
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
|
|
||||||
|
LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
|
||||||
|
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
|
||||||
|
|
||||||
|
|
||||||
|
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
|
||||||
|
"""Store the latest provider usage on the query for upstream action handlers."""
|
||||||
|
if query is None or not usage_info:
|
||||||
|
return
|
||||||
|
if query.variables is None:
|
||||||
|
query.variables = {}
|
||||||
|
query.variables[LLM_USAGE_QUERY_VARIABLE] = dict(usage_info)
|
||||||
|
|
||||||
|
|
||||||
class RuntimeProvider:
|
class RuntimeProvider:
|
||||||
"""运行时模型提供商"""
|
"""运行时模型提供商"""
|
||||||
|
|
||||||
@@ -67,6 +80,7 @@ class RuntimeProvider:
|
|||||||
if isinstance(result, tuple):
|
if isinstance(result, tuple):
|
||||||
msg, usage_info = result
|
msg, usage_info = result
|
||||||
if usage_info:
|
if usage_info:
|
||||||
|
_store_llm_usage(query, usage_info)
|
||||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||||
output_tokens = usage_info.get('completion_tokens', 0)
|
output_tokens = usage_info.get('completion_tokens', 0)
|
||||||
return msg
|
return msg
|
||||||
@@ -146,11 +160,12 @@ class RuntimeProvider:
|
|||||||
if query:
|
if query:
|
||||||
if query.variables is None:
|
if query.variables is None:
|
||||||
query.variables = {}
|
query.variables = {}
|
||||||
if '_stream_usage' in query.variables:
|
if STREAM_USAGE_QUERY_VARIABLE in query.variables:
|
||||||
usage_info = query.variables['_stream_usage']
|
usage_info = query.variables[STREAM_USAGE_QUERY_VARIABLE]
|
||||||
|
_store_llm_usage(query, usage_info)
|
||||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||||
output_tokens = usage_info.get('completion_tokens', 0)
|
output_tokens = usage_info.get('completion_tokens', 0)
|
||||||
del query.variables['_stream_usage']
|
del query.variables[STREAM_USAGE_QUERY_VARIABLE]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = 'error'
|
status = 'error'
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
|
|||||||
@@ -262,32 +262,82 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
- dict with the same keys
|
- dict with the same keys
|
||||||
- missing ``total_tokens`` (derived from prompt + completion)
|
- missing ``total_tokens`` (derived from prompt + completion)
|
||||||
- ``None`` / partially-populated usage (defaults to 0)
|
- ``None`` / partially-populated usage (defaults to 0)
|
||||||
|
- provider-specific token details, including cache token counters
|
||||||
"""
|
"""
|
||||||
if usage is None:
|
|
||||||
return {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
|
||||||
|
|
||||||
def _get(key: str) -> typing.Any:
|
def _plain_value(value: typing.Any) -> typing.Any:
|
||||||
if isinstance(usage, dict):
|
if value is None:
|
||||||
return usage.get(key)
|
return None
|
||||||
return getattr(usage, key, None)
|
if isinstance(value, dict):
|
||||||
|
return {k: _plain_value(v) for k, v in value.items() if v is not None}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_plain_value(v) for v in value]
|
||||||
|
|
||||||
prompt_tokens = _get('prompt_tokens') or 0
|
model_dump = getattr(value, 'model_dump', None)
|
||||||
completion_tokens = _get('completion_tokens') or 0
|
if callable(model_dump):
|
||||||
total_tokens = _get('total_tokens') or 0
|
try:
|
||||||
|
dumped = model_dump()
|
||||||
|
if isinstance(dumped, dict):
|
||||||
|
return _plain_value(dumped)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _usage_dict(value: typing.Any) -> dict[str, typing.Any]:
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
plain = _plain_value(value)
|
||||||
|
if isinstance(plain, dict):
|
||||||
|
return plain
|
||||||
|
|
||||||
|
def _is_mock_attr(attr: typing.Any) -> bool:
|
||||||
|
return type(attr).__module__.startswith('unittest.mock')
|
||||||
|
|
||||||
|
data: dict[str, typing.Any] = {}
|
||||||
|
for key in (
|
||||||
|
'prompt_tokens',
|
||||||
|
'completion_tokens',
|
||||||
|
'total_tokens',
|
||||||
|
'prompt_tokens_details',
|
||||||
|
'completion_tokens_details',
|
||||||
|
'cache_creation_input_tokens',
|
||||||
|
'cache_read_input_tokens',
|
||||||
|
'input_token_details',
|
||||||
|
'output_token_details',
|
||||||
|
):
|
||||||
|
attr_value = getattr(value, key, None)
|
||||||
|
if attr_value is not None and not _is_mock_attr(attr_value):
|
||||||
|
data[key] = _plain_value(attr_value)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _to_int(value: typing.Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
normalized = _usage_dict(usage)
|
||||||
|
|
||||||
|
prompt_tokens = _to_int(normalized.get('prompt_tokens'))
|
||||||
|
completion_tokens = _to_int(normalized.get('completion_tokens'))
|
||||||
|
total_tokens = _to_int(normalized.get('total_tokens'))
|
||||||
|
|
||||||
# Some providers omit total_tokens in streaming usage; derive it.
|
# Some providers omit total_tokens in streaming usage; derive it.
|
||||||
if not total_tokens:
|
if not total_tokens:
|
||||||
total_tokens = prompt_tokens + completion_tokens
|
total_tokens = prompt_tokens + completion_tokens
|
||||||
|
|
||||||
return {
|
normalized['prompt_tokens'] = prompt_tokens
|
||||||
'prompt_tokens': int(prompt_tokens),
|
normalized['completion_tokens'] = completion_tokens
|
||||||
'completion_tokens': int(completion_tokens),
|
normalized['total_tokens'] = total_tokens
|
||||||
'total_tokens': int(total_tokens),
|
return normalized
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_usage(self, response) -> dict:
|
def _extract_usage(self, response) -> dict | None:
|
||||||
"""Extract usage info from a non-streaming LiteLLM response."""
|
"""Extract usage info from a non-streaming LiteLLM response."""
|
||||||
return self._normalize_usage(getattr(response, 'usage', None))
|
usage = getattr(response, 'usage', None)
|
||||||
|
if usage is None:
|
||||||
|
return None
|
||||||
|
return self._normalize_usage(usage)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _as_dict(value: typing.Any) -> dict:
|
def _as_dict(value: typing.Any) -> dict:
|
||||||
@@ -486,7 +536,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
if query is not None:
|
if query is not None:
|
||||||
if query.variables is None:
|
if query.variables is None:
|
||||||
query.variables = {}
|
query.variables = {}
|
||||||
query.variables['_stream_usage'] = usage_info
|
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage_info
|
||||||
|
|
||||||
if not hasattr(chunk, 'choices') or not chunk.choices:
|
if not hasattr(chunk, 'choices') or not chunk.choices:
|
||||||
continue
|
continue
|
||||||
|
|||||||
6
src/langbot/pkg/provider/tools/errors.py
Normal file
6
src/langbot/pkg/provider/tools/errors.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class ToolNotFoundError(ValueError):
|
||||||
|
"""Raised when a requested tool cannot be found in any active loader."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f'Tool not found: {name}')
|
||||||
@@ -4,12 +4,15 @@ import abc
|
|||||||
import typing
|
import typing
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ...core import app
|
from ...core import app
|
||||||
|
|
||||||
|
ToolLookupResult = resource_tool.LLMTool | ComponentManifest
|
||||||
|
|
||||||
|
|
||||||
preregistered_loaders: list[typing.Type[ToolLoader]] = []
|
preregistered_loaders: list[typing.Type[ToolLoader]] = []
|
||||||
|
|
||||||
@@ -43,6 +46,13 @@ class ToolLoader(abc.ABC):
|
|||||||
"""获取所有工具"""
|
"""获取所有工具"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def get_tool(self, name: str) -> ToolLookupResult | None:
|
||||||
|
"""Get one tool by name."""
|
||||||
|
for tool in await self.get_tools():
|
||||||
|
if tool.name == name:
|
||||||
|
return tool
|
||||||
|
return None
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def has_tool(self, name: str) -> bool:
|
async def has_tool(self, name: str) -> bool:
|
||||||
"""检查工具是否存在"""
|
"""检查工具是否存在"""
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_tool(self, name: str) -> resource_tool.LLMTool | None:
|
||||||
|
for session in self.sessions.values():
|
||||||
|
for function in session.get_tools():
|
||||||
|
if function.name == name:
|
||||||
|
return function
|
||||||
|
return None
|
||||||
|
|
||||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
"""执行工具调用"""
|
"""执行工具调用"""
|
||||||
for session in self.sessions.values():
|
for session in self.sessions.values():
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
|
from ..errors import ToolNotFoundError
|
||||||
from . import skill as skill_loader
|
from . import skill as skill_loader
|
||||||
|
|
||||||
EXEC_TOOL_NAME = 'exec'
|
EXEC_TOOL_NAME = 'exec'
|
||||||
@@ -90,7 +91,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
return await self._invoke_glob(parameters, query)
|
return await self._invoke_glob(parameters, query)
|
||||||
if name == GREP_TOOL_NAME:
|
if name == GREP_TOOL_NAME:
|
||||||
return await self._invoke_grep(parameters, query)
|
return await self._invoke_grep(parameters, query)
|
||||||
raise ValueError(f'未找到工具: {name}')
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
@@ -39,7 +40,7 @@ class PluginToolLoader(loader.ToolLoader):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _get_tool(self, name: str) -> resource_tool.LLMTool:
|
async def get_tool(self, name: str) -> ComponentManifest | None:
|
||||||
for tool in await self.ap.plugin_connector.list_tools():
|
for tool in await self.ap.plugin_connector.list_tools():
|
||||||
if tool.metadata.name == name:
|
if tool.metadata.name == name:
|
||||||
return tool
|
return tool
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from typing import TYPE_CHECKING
|
|||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
|
from . import loader as tool_loader
|
||||||
|
from .errors import ToolNotFoundError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ...core import app
|
from ...core import app
|
||||||
from langbot.pkg.provider.tools.loaders import (
|
from langbot.pkg.provider.tools.loaders import (
|
||||||
@@ -67,6 +70,20 @@ class ToolManager:
|
|||||||
|
|
||||||
return all_functions
|
return all_functions
|
||||||
|
|
||||||
|
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
|
||||||
|
"""Get tool by name from any active loader."""
|
||||||
|
for active_loader in (
|
||||||
|
self.native_tool_loader,
|
||||||
|
self.plugin_tool_loader,
|
||||||
|
self.mcp_tool_loader,
|
||||||
|
self.skill_tool_loader,
|
||||||
|
):
|
||||||
|
tool = await active_loader.get_tool(name)
|
||||||
|
if tool:
|
||||||
|
return tool
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||||
tools = []
|
tools = []
|
||||||
|
|
||||||
@@ -98,7 +115,7 @@ class ToolManager:
|
|||||||
if await self.skill_tool_loader.has_tool(name):
|
if await self.skill_tool_loader.has_tool(name):
|
||||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||||
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
|
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
|
||||||
raise ValueError(f'未找到工具: {name}')
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await self.native_tool_loader.shutdown()
|
await self.native_tool_loader.shutdown()
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
|||||||
'query_id': '',
|
'query_id': '',
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'instance_create_ts': constants.instance_create_ts,
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'features': features,
|
'features': features,
|
||||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -16,3 +16,11 @@ debug_mode = False
|
|||||||
edition = 'community'
|
edition = 'community'
|
||||||
|
|
||||||
instance_id = ''
|
instance_id = ''
|
||||||
|
|
||||||
|
instance_create_ts = 0
|
||||||
|
"""Unix timestamp (seconds) of when this instance was first created.
|
||||||
|
|
||||||
|
Sourced from ``data/labels/instance_id.json``. Backfilled to the current
|
||||||
|
time for instances created before this field existed, so it is always a
|
||||||
|
positive value once load_config has run.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -49,6 +49,30 @@ class TestExtractDepsMetadata:
|
|||||||
assert 'flask' in task_context.metadata['deps_list']
|
assert 'flask' in task_context.metadata['deps_list']
|
||||||
assert 'numpy' in task_context.metadata['deps_list']
|
assert 'numpy' in task_context.metadata['deps_list']
|
||||||
|
|
||||||
|
def test_extract_plugin_identity_includes_version(self):
|
||||||
|
"""Extract plugin identity and version from manifest.yaml."""
|
||||||
|
connector = self._create_connector()
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w') as zf:
|
||||||
|
zf.writestr(
|
||||||
|
'manifest.yaml',
|
||||||
|
'\n'.join(
|
||||||
|
[
|
||||||
|
'metadata:',
|
||||||
|
' author: langbot-team',
|
||||||
|
' name: LangRAG',
|
||||||
|
' version: 0.1.8',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert connector._inspect_plugin_package(zip_buffer.getvalue(), None) == (
|
||||||
|
'langbot-team',
|
||||||
|
'LangRAG',
|
||||||
|
'0.1.8',
|
||||||
|
)
|
||||||
|
|
||||||
def test_extract_deps_empty_requirements(self):
|
def test_extract_deps_empty_requirements(self):
|
||||||
"""Handle empty requirements.txt."""
|
"""Handle empty requirements.txt."""
|
||||||
connector = self._create_connector()
|
connector = self._create_connector()
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ class TestExtractUsage:
|
|||||||
assert result['prompt_tokens'] == 0
|
assert result['prompt_tokens'] == 0
|
||||||
assert result['completion_tokens'] == 0
|
assert result['completion_tokens'] == 0
|
||||||
|
|
||||||
|
def test_extract_usage_without_provider_usage(self):
|
||||||
|
"""Missing provider usage is not treated as authoritative zero usage."""
|
||||||
|
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||||
|
|
||||||
|
response = Mock()
|
||||||
|
response.usage = None
|
||||||
|
|
||||||
|
assert requester._extract_usage(response) is None
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeUsage:
|
class TestNormalizeUsage:
|
||||||
"""Test _normalize_usage helper covering real-world usage shapes"""
|
"""Test _normalize_usage helper covering real-world usage shapes"""
|
||||||
@@ -131,6 +140,22 @@ class TestNormalizeUsage:
|
|||||||
)
|
)
|
||||||
assert result == {'prompt_tokens': 12, 'completion_tokens': 8, 'total_tokens': 20}
|
assert result == {'prompt_tokens': 12, 'completion_tokens': 8, 'total_tokens': 20}
|
||||||
|
|
||||||
|
def test_preserves_token_details(self):
|
||||||
|
"""Provider token details such as cache counters are preserved."""
|
||||||
|
result = litellmchat.LiteLLMRequester._normalize_usage(
|
||||||
|
{
|
||||||
|
'prompt_tokens': 12,
|
||||||
|
'completion_tokens': 8,
|
||||||
|
'total_tokens': 20,
|
||||||
|
'prompt_tokens_details': {'cached_tokens': 7},
|
||||||
|
'completion_tokens_details': {'reasoning_tokens': 3},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['prompt_tokens'] == 12
|
||||||
|
assert result['prompt_tokens_details'] == {'cached_tokens': 7}
|
||||||
|
assert result['completion_tokens_details'] == {'reasoning_tokens': 3}
|
||||||
|
|
||||||
def test_missing_total_is_derived(self):
|
def test_missing_total_is_derived(self):
|
||||||
"""When total_tokens is absent/zero it is derived from prompt + completion"""
|
"""When total_tokens is absent/zero it is derived from prompt + completion"""
|
||||||
usage = Mock()
|
usage = Mock()
|
||||||
@@ -166,9 +191,7 @@ class TestInvokeLLMStreamUsage:
|
|||||||
if has_choice:
|
if has_choice:
|
||||||
choice = Mock()
|
choice = Mock()
|
||||||
delta = Mock()
|
delta = Mock()
|
||||||
delta.model_dump = Mock(
|
delta.model_dump = Mock(return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls})
|
||||||
return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls}
|
|
||||||
)
|
|
||||||
choice.delta = delta
|
choice.delta = delta
|
||||||
choice.finish_reason = finish_reason
|
choice.finish_reason = finish_reason
|
||||||
chunk.choices = [choice]
|
chunk.choices = [choice]
|
||||||
@@ -313,7 +336,8 @@ class TestInvokeLLMStreamUsage:
|
|||||||
|
|
||||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||||
collected = [
|
collected = [
|
||||||
chunk async for chunk in requester.invoke_llm_stream(
|
chunk
|
||||||
|
async for chunk in requester.invoke_llm_stream(
|
||||||
query=query,
|
query=query,
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -788,7 +812,9 @@ class TestInvokeRerank:
|
|||||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||||
# arerank must NOT be called on the openai-compatible path
|
# arerank must NOT be called on the openai-compatible path
|
||||||
with patch.object(
|
with patch.object(
|
||||||
litellmchat, 'arerank', new_callable=AsyncMock,
|
litellmchat,
|
||||||
|
'arerank',
|
||||||
|
new_callable=AsyncMock,
|
||||||
side_effect=AssertionError('arerank must not be used for openai-compatible provider'),
|
side_effect=AssertionError('arerank must not be used for openai-compatible provider'),
|
||||||
):
|
):
|
||||||
results = await requester.invoke_rerank(
|
results = await requester.invoke_rerank(
|
||||||
@@ -1068,8 +1094,7 @@ class TestScanModels:
|
|||||||
|
|
||||||
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
|
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
|
||||||
mock_supports_function_calling.side_effect = (
|
mock_supports_function_calling.side_effect = (
|
||||||
lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6'
|
lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' and custom_llm_provider is None
|
||||||
and custom_llm_provider is None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert requester._supports_function_calling('kimi-k2.6') is True
|
assert requester._supports_function_calling('kimi-k2.6') is True
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query):
|
async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query):
|
||||||
"""Test that execute_func_call raises ValueError when tool not found."""
|
"""Test that execute_func_call raises ToolNotFoundError when tool not found."""
|
||||||
toolmgr = get_toolmgr_module()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
|
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
|
||||||
@@ -236,7 +236,7 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
manager = toolmgr.ToolManager(mock_app)
|
manager = toolmgr.ToolManager(mock_app)
|
||||||
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='未找到工具'):
|
with pytest.raises(toolmgr.ToolNotFoundError, match='Tool not found: unknown_tool'):
|
||||||
await manager.execute_func_call('unknown_tool', {}, sample_query)
|
await manager.execute_func_call('unknown_tool', {}, sample_query)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class TestBuildHeartbeatPayload:
|
|||||||
|
|
||||||
assert payload['event_type'] == 'instance_heartbeat'
|
assert payload['event_type'] == 'instance_heartbeat'
|
||||||
assert payload['query_id'] == ''
|
assert payload['query_id'] == ''
|
||||||
|
assert 'instance_create_ts' in payload
|
||||||
assert 'timestamp' in payload
|
assert 'timestamp' in payload
|
||||||
f = payload['features']
|
f = payload['features']
|
||||||
assert f['database'] == 'postgresql'
|
assert f['database'] == 'postgresql'
|
||||||
|
|||||||
Reference in New Issue
Block a user