mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(runner): authorize contextual platform APIs
This commit is contained in:
@@ -604,12 +604,76 @@ def _normalize_platform_params(
|
|||||||
return dict(parameters)
|
return dict(parameters)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_platform_api_call(session, bot_uuid, action, params, context_tool=None):
|
||||||
|
"""Resolve an adapter call against Host-frozen tool grants and event targets."""
|
||||||
|
authorization = session.get('authorization') or {}
|
||||||
|
context = authorization.get('platform_context') or {}
|
||||||
|
if context_tool is None and (not bot_uuid or bot_uuid != authorization.get('bot_id')):
|
||||||
|
raise ValueError('Platform API bot is not authorized for this run')
|
||||||
|
if context_tool is not None and context_tool != 'event_reply':
|
||||||
|
raise ValueError('Unknown context platform API')
|
||||||
|
params = dict(params)
|
||||||
|
message = None
|
||||||
|
quote_origin = False
|
||||||
|
if action == 'send_message':
|
||||||
|
message = platform_message.MessageChain.model_validate(params.pop('message'))
|
||||||
|
if not message.root:
|
||||||
|
raise ValueError('Message must not be empty')
|
||||||
|
quote_origin = params.pop('quote_origin', False)
|
||||||
|
if not isinstance(quote_origin, bool):
|
||||||
|
raise ValueError('quote_origin must be a boolean')
|
||||||
|
if quote_origin and context_tool != 'event_reply':
|
||||||
|
raise ValueError('quote_origin requires the current event context')
|
||||||
|
# Only text is used for tool-schema validation. The original chain is delivered intact.
|
||||||
|
params['text'] = ''.join(c.text for c in message.root if isinstance(c, platform_message.Plain)) or '[message]'
|
||||||
|
granted = {
|
||||||
|
tool['tool_name']
|
||||||
|
for tool in authorization.get('resources', {}).get('tools', [])
|
||||||
|
if tool.get('source') == 'platform' and (not tool.get('operations') or 'call' in tool['operations'])
|
||||||
|
}
|
||||||
|
for definition in PLATFORM_TOOL_DEFINITIONS:
|
||||||
|
if definition.name not in granted or definition.api != action:
|
||||||
|
continue
|
||||||
|
if context_tool is not None and definition.name != context_tool:
|
||||||
|
continue
|
||||||
|
properties = definition.parameters.get('properties') or {}
|
||||||
|
tool_params = {key: value for key, value in params.items() if key in properties}
|
||||||
|
try:
|
||||||
|
normalized = _normalize_platform_params(definition, tool_params)
|
||||||
|
bound = _event_params(definition, context, normalized) if definition.scope == 'event' else normalized
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if context_tool is None and bound != params:
|
||||||
|
continue
|
||||||
|
if context_tool is not None and set(params) - set(properties):
|
||||||
|
raise ValueError('Unexpected context API parameters')
|
||||||
|
if message is not None and quote_origin:
|
||||||
|
target = (context.get('delivery') or {}).get('reply_target') or {}
|
||||||
|
if not target.get('message_id'):
|
||||||
|
raise ValueError('The current event has no message to quote')
|
||||||
|
message = platform_message.MessageChain(
|
||||||
|
[
|
||||||
|
platform_message.Quote(
|
||||||
|
id=target['message_id'],
|
||||||
|
group_id=target.get('group_id'),
|
||||||
|
sender_id=(context.get('actor') or {}).get('actor_id'),
|
||||||
|
target_id=target.get('target_id'),
|
||||||
|
origin=platform_message.MessageChain([]),
|
||||||
|
),
|
||||||
|
*message.root,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return definition.name, tool_params, message
|
||||||
|
raise ValueError(f'Platform API {action} or its target is not authorized for this run')
|
||||||
|
|
||||||
|
|
||||||
async def execute_platform_tool(
|
async def execute_platform_tool(
|
||||||
ap: typing.Any,
|
ap: typing.Any,
|
||||||
execution_context: typing.Any,
|
execution_context: typing.Any,
|
||||||
session: typing.Mapping[str, typing.Any],
|
session: typing.Mapping[str, typing.Any],
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
parameters: dict[str, typing.Any],
|
parameters: dict[str, typing.Any],
|
||||||
|
message_chain: platform_message.MessageChain | None = None,
|
||||||
) -> typing.Any:
|
) -> typing.Any:
|
||||||
definition = PLATFORM_TOOLS_BY_NAME.get(tool_name)
|
definition = PLATFORM_TOOLS_BY_NAME.get(tool_name)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
@@ -622,7 +686,10 @@ async def execute_platform_tool(
|
|||||||
normalized = _event_params(definition, context, normalized)
|
normalized = _event_params(definition, context, normalized)
|
||||||
# This flag is frozen by the Host from the synthetic debug envelope, not tool arguments.
|
# This flag is frozen by the Host from the synthetic debug envelope, not tool arguments.
|
||||||
if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True:
|
if delivery.get('surface') == 'webui' and (delivery.get('platform_capabilities') or {}).get('debug_mock') is True:
|
||||||
return _execute_mock_platform_tool(definition, context, normalized)
|
result = _execute_mock_platform_tool(definition, context, normalized)
|
||||||
|
if message_chain is not None:
|
||||||
|
result['parameters']['message'] = message_chain.model_dump(mode='json')
|
||||||
|
return result
|
||||||
bot_id = authorization.get('bot_id')
|
bot_id = authorization.get('bot_id')
|
||||||
if not bot_id:
|
if not bot_id:
|
||||||
raise ValueError('This run is not associated with a platform bot')
|
raise ValueError('This run is not associated with a platform bot')
|
||||||
@@ -638,9 +705,9 @@ async def execute_platform_tool(
|
|||||||
normalized = {
|
normalized = {
|
||||||
'target_type': _require_string(normalized, 'target_type'),
|
'target_type': _require_string(normalized, 'target_type'),
|
||||||
'target_id': _require_string(normalized, 'target_id'),
|
'target_id': _require_string(normalized, 'target_id'),
|
||||||
'message': platform_message.MessageChain(
|
'message': message_chain
|
||||||
[platform_message.Plain(text=_require_string(normalized, 'text'))]
|
if message_chain is not None
|
||||||
),
|
else platform_message.MessageChain([platform_message.Plain(text=_require_string(normalized, 'text'))]),
|
||||||
}
|
}
|
||||||
return await api_func(**normalized)
|
return await api_func(**normalized)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ from ..utils import constants
|
|||||||
from ..agent.runner.session_registry import get_session_registry
|
from ..agent.runner.session_registry import get_session_registry
|
||||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||||
from ..agent.runner import config_schema
|
from ..agent.runner import config_schema
|
||||||
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail
|
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call
|
||||||
from ..pipeline.pool import get_query_execution_context
|
from ..pipeline.pool import get_query_execution_context
|
||||||
|
|
||||||
|
|
||||||
@@ -1168,9 +1168,52 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def call_run_platform_api(data):
|
||||||
|
action_context, _ = await self._require_plugin_action_context()
|
||||||
|
session, error = await _validate_agent_run_session(
|
||||||
|
data['run_id'], data.get('caller_plugin_identity'), self.ap, 'call_platform_api'
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
tool_name, parameters, message = resolve_platform_api_call(
|
||||||
|
session, data.get('bot_uuid'), data['action'], data.get('params') or {}, data.get('context_tool')
|
||||||
|
)
|
||||||
|
session, error = await _validate_run_authorization(
|
||||||
|
data['run_id'], 'tool', tool_name, self.ap, data.get('caller_plugin_identity'), operation='call'
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
_, error = _validate_frozen_tool_source_identity(session, tool_name, self.ap)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
result = await execute_platform_tool(
|
||||||
|
self.ap,
|
||||||
|
self._execution_context(action_context),
|
||||||
|
session,
|
||||||
|
tool_name,
|
||||||
|
parameters,
|
||||||
|
message_chain=message,
|
||||||
|
)
|
||||||
|
return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)})
|
||||||
|
except (ValueError, KeyError, TypeError) as exc:
|
||||||
|
return handler.ActionResponse.error(message=str(exc))
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
|
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
|
||||||
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
|
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Send message"""
|
"""Send message"""
|
||||||
|
if data.get('run_id') is not None:
|
||||||
|
return await call_run_platform_api(
|
||||||
|
{
|
||||||
|
**data,
|
||||||
|
'action': 'send_message',
|
||||||
|
'params': {
|
||||||
|
'target_type': data['target_type'],
|
||||||
|
'target_id': data['target_id'],
|
||||||
|
'message': data['message_chain'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
action_context, _ = await self._require_plugin_action_context()
|
action_context, _ = await self._require_plugin_action_context()
|
||||||
execution_context = self._execution_context(action_context)
|
execution_context = self._execution_context(action_context)
|
||||||
bot_uuid = data['bot_uuid']
|
bot_uuid = data['bot_uuid']
|
||||||
@@ -1215,11 +1258,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
@self.action(PluginToRuntimeAction.CALL_PLATFORM_API)
|
@self.action(PluginToRuntimeAction.CALL_PLATFORM_API)
|
||||||
async def call_platform_api(data: dict[str, Any]) -> handler.ActionResponse:
|
async def call_platform_api(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Call a platform adapter API"""
|
"""Call a platform adapter API"""
|
||||||
|
if data.get('run_id') is not None:
|
||||||
|
return await call_run_platform_api(data)
|
||||||
|
action_context, _ = await self._require_plugin_action_context()
|
||||||
bot_uuid = data['bot_uuid']
|
bot_uuid = data['bot_uuid']
|
||||||
action = data['action']
|
action = data['action']
|
||||||
params = data.get('params') or {}
|
params = data.get('params') or {}
|
||||||
|
|
||||||
bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
bot = await self.ap.platform_mgr.get_bot_by_uuid(self._execution_context(action_context), bot_uuid)
|
||||||
if bot is None:
|
if bot is None:
|
||||||
return handler.ActionResponse.error(
|
return handler.ActionResponse.error(
|
||||||
message=f'Bot with bot_uuid {bot_uuid} not found',
|
message=f'Bot with bot_uuid {bot_uuid} not found',
|
||||||
@@ -2024,6 +2070,17 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
action_context, _ = await self._require_plugin_action_context()
|
action_context, _ = await self._require_plugin_action_context()
|
||||||
embedding_model_uuid = data['embedding_model_uuid']
|
embedding_model_uuid = data['embedding_model_uuid']
|
||||||
texts = data['texts']
|
texts = data['texts']
|
||||||
|
if data.get('run_id') is not None:
|
||||||
|
_, error = await _validate_run_authorization(
|
||||||
|
data['run_id'],
|
||||||
|
'model',
|
||||||
|
embedding_model_uuid,
|
||||||
|
self.ap,
|
||||||
|
data.get('caller_plugin_identity'),
|
||||||
|
operation='invoke',
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
if not await self._resource_exists(
|
if not await self._resource_exists(
|
||||||
persistence_model.EmbeddingModel,
|
persistence_model.EmbeddingModel,
|
||||||
|
|||||||
@@ -194,7 +194,9 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None:
|
|||||||
@self.handler(MemberJoinedEvent)
|
@self.handler(MemberJoinedEvent)
|
||||||
async def handle(ctx: RunnerContext):
|
async def handle(ctx: RunnerContext):
|
||||||
await ctx.log('Handling ' + str(ctx.platform_event.member.id))
|
await ctx.log('Handling ' + str(ctx.platform_event.member.id))
|
||||||
result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.platform_event.member.nickname or str(ctx.platform_event.member.id)))
|
tools = await ctx.get_available_tools()
|
||||||
|
assert any(tool['name'] == 'event_reply' for tool in tools)
|
||||||
|
result = await self.plugin.call_tool('event_reply', {'text': ctx.config['greeting'] + ', ' + (ctx.platform_event.member.nickname or str(ctx.platform_event.member.id))})
|
||||||
await ctx.log('Reply simulated: ' + str(result.get('mock')))
|
await ctx.log('Reply simulated: ' + str(result.get('mock')))
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -370,3 +370,79 @@ async def test_platform_action_rejects_parameters_outside_the_declared_schema()
|
|||||||
)
|
)
|
||||||
|
|
||||||
adapter.get_group_info.assert_not_awaited()
|
adapter.get_group_info.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def api_session(names):
|
||||||
|
event = _event('message.received')
|
||||||
|
event.delivery.surface = 'webui'
|
||||||
|
event.delivery.platform_capabilities['debug_mock'] = True
|
||||||
|
resources, _ = build_platform_tool_resources(event, names, ['call'])
|
||||||
|
return {
|
||||||
|
'authorization': {
|
||||||
|
'bot_id': 'bot-1',
|
||||||
|
'platform_context': freeze_platform_context(event),
|
||||||
|
'resources': {'tools': resources},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'bot,params',
|
||||||
|
[
|
||||||
|
('other-bot', {'group_id': 'group-1'}),
|
||||||
|
('bot-1', {'group_id': 'other-group'}),
|
||||||
|
('bot-1', {'group_id': 'group-1', 'unknown': 'field'}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_common_platform_api_cannot_expand_event_grant(bot, params):
|
||||||
|
from langbot.pkg.agent.runner.platform_tools import resolve_platform_api_call
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='not authorized'):
|
||||||
|
resolve_platform_api_call(api_session(['event_get_group']), bot, 'get_group_info', params)
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_platform_api_resolves_only_granted_call_operations():
|
||||||
|
from langbot.pkg.agent.runner.platform_tools import resolve_platform_api_call
|
||||||
|
|
||||||
|
session = api_session(['event_get_group'])
|
||||||
|
assert resolve_platform_api_call(session, 'bot-1', 'get_group_info', {'group_id': 'group-1'})[:2] == (
|
||||||
|
'event_get_group',
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
session['authorization']['resources']['tools'][0]['operations'] = ['detail']
|
||||||
|
with pytest.raises(ValueError, match='not authorized'):
|
||||||
|
resolve_platform_api_call(session, 'bot-1', 'get_group_info', {'group_id': 'group-1'})
|
||||||
|
session = api_session(['platform_get_group_info'])
|
||||||
|
assert (
|
||||||
|
resolve_platform_api_call(session, 'bot-1', 'get_group_info', {'group_id': 'other'})[0]
|
||||||
|
== 'platform_get_group_info'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rich_context_reply_preserves_chain_quote_and_mock():
|
||||||
|
from langbot.pkg.agent.runner.platform_tools import resolve_platform_api_call
|
||||||
|
|
||||||
|
chain = platform_message.MessageChain(
|
||||||
|
[platform_message.At(target='user-1'), platform_message.Plain(text='welcome')]
|
||||||
|
)
|
||||||
|
session = api_session(['event_reply'])
|
||||||
|
name, params, rich = resolve_platform_api_call(
|
||||||
|
session, None, 'send_message', {'message': chain.model_dump(), 'quote_origin': True}, 'event_reply'
|
||||||
|
)
|
||||||
|
assert rich.root[0].id == 'message-1'
|
||||||
|
assert rich.root[1:] == chain.root
|
||||||
|
ap = SimpleNamespace(
|
||||||
|
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(side_effect=AssertionError('real send')))
|
||||||
|
)
|
||||||
|
result = await execute_platform_tool(ap, object(), session, name, params, message_chain=rich)
|
||||||
|
assert result['mock'] is True
|
||||||
|
assert result['parameters']['target_id'] == 'group-1'
|
||||||
|
assert result['parameters']['message'][1]['type'] == 'At'
|
||||||
|
with pytest.raises(ValueError, match='Unexpected'):
|
||||||
|
resolve_platform_api_call(
|
||||||
|
session, None, 'send_message', {'message': chain.model_dump(), 'target_id': 'other'}, 'event_reply'
|
||||||
|
)
|
||||||
|
session['authorization']['resources']['tools'] = []
|
||||||
|
with pytest.raises(ValueError, match='not authorized'):
|
||||||
|
resolve_platform_api_call(session, None, 'send_message', {'message': chain.model_dump()}, 'event_reply')
|
||||||
|
|||||||
@@ -1766,3 +1766,101 @@ async def test_reply_stream_authorization_is_run_and_workspace_scoped(case):
|
|||||||
streams.apply.assert_not_awaited()
|
streams.apply.assert_not_awaited()
|
||||||
finally:
|
finally:
|
||||||
await registry.unregister(run_id)
|
await registry.unregister(run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('mode', ['allowed', 'disabled', 'wrong-target', 'wrong-plugin', 'expired', 'raw-send'])
|
||||||
|
async def test_public_platform_api_preserves_runner_authorization_and_mock(mode):
|
||||||
|
app = Mock()
|
||||||
|
app.logger = Mock()
|
||||||
|
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||||
|
from langbot.pkg.agent.runner.platform_tools import freeze_platform_context, build_platform_tool_resources
|
||||||
|
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||||
|
from langbot_plugin.api.entities.builtin.runner import AgentInput, DeliveryContext
|
||||||
|
|
||||||
|
event = AgentEventEnvelope(
|
||||||
|
event_id='api-event',
|
||||||
|
event_type='message.received',
|
||||||
|
source='webui',
|
||||||
|
bot_id='bot',
|
||||||
|
input=AgentInput(text='hello'),
|
||||||
|
delivery=DeliveryContext(
|
||||||
|
surface='webui',
|
||||||
|
reply_target={'target_type': 'group', 'target_id': 'group'},
|
||||||
|
platform_capabilities={'debug_mock': True, 'supported_apis': ['send_message']},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tools, _ = build_platform_tool_resources(event, [] if mode == 'disabled' else ['event_reply'], ['call'])
|
||||||
|
registry = get_session_registry()
|
||||||
|
run_id = 'public-platform-' + mode
|
||||||
|
await registry.register(
|
||||||
|
run_id=run_id,
|
||||||
|
runner_id='plugin:test-author/test-plugin/runner',
|
||||||
|
query_id=None,
|
||||||
|
plugin_identity='other/plugin' if mode == 'wrong-plugin' else 'test-author/test-plugin',
|
||||||
|
resources=make_agent_resources(tools=tools),
|
||||||
|
bot_id='bot',
|
||||||
|
platform_context=freeze_platform_context(event),
|
||||||
|
)
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
app.platform_mgr.get_bot_by_uuid = AsyncMock(side_effect=AssertionError('Mock must not send'))
|
||||||
|
data = {
|
||||||
|
'run_id': run_id,
|
||||||
|
'bot_uuid': 'bot',
|
||||||
|
'action': 'send_message',
|
||||||
|
'params': {
|
||||||
|
'target_type': 'group',
|
||||||
|
'target_id': 'other' if mode == 'wrong-target' else 'group',
|
||||||
|
'message': [{'type': 'Plain', 'text': 'hello'}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
action = PluginToRuntimeAction.CALL_PLATFORM_API
|
||||||
|
if mode == 'raw-send':
|
||||||
|
action = PluginToRuntimeAction.SEND_MESSAGE
|
||||||
|
data = {
|
||||||
|
'run_id': run_id,
|
||||||
|
'bot_uuid': 'bot',
|
||||||
|
'target_type': 'group',
|
||||||
|
'target_id': 'group',
|
||||||
|
'message_chain': data['params']['message'],
|
||||||
|
}
|
||||||
|
if mode == 'expired':
|
||||||
|
await registry.unregister(run_id)
|
||||||
|
try:
|
||||||
|
result = await runtime_handler.actions[action.value](data)
|
||||||
|
finally:
|
||||||
|
await registry.unregister(run_id)
|
||||||
|
if mode in ('allowed', 'raw-send'):
|
||||||
|
assert result.code == 0, result.message
|
||||||
|
assert result.data['result']['mock'] is True
|
||||||
|
else:
|
||||||
|
assert result.code != 0
|
||||||
|
app.platform_mgr.get_bot_by_uuid.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_api_rejects_model_outside_runner_grants():
|
||||||
|
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||||
|
|
||||||
|
app = Mock()
|
||||||
|
app.logger = Mock()
|
||||||
|
app.model_mgr.get_embedding_model_by_uuid = AsyncMock()
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
registry = get_session_registry()
|
||||||
|
run_id = 'embedding-ungranted'
|
||||||
|
await registry.register(
|
||||||
|
run_id=run_id,
|
||||||
|
runner_id='plugin:test-author/test-plugin/runner',
|
||||||
|
query_id=None,
|
||||||
|
plugin_identity='test-author/test-plugin',
|
||||||
|
resources=make_agent_resources(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_EMBEDDING.value](
|
||||||
|
{'run_id': run_id, 'embedding_model_uuid': 'outside-grants', 'texts': ['hello']}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await registry.unregister(run_id)
|
||||||
|
assert response.code != 0
|
||||||
|
assert 'not authorized' in response.message
|
||||||
|
app.model_mgr.get_embedding_model_by_uuid.assert_not_awaited()
|
||||||
|
|||||||
Reference in New Issue
Block a user