feat(api): LLM polling support (#37462)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
QuantumGhost
2026-06-17 23:34:33 +00:00
committed by GitHub
co-authored by autofix-ci[bot] WH-2099
parent 19838972dc
commit f0b34bdeb4
17 changed files with 704 additions and 46 deletions
@@ -7,6 +7,7 @@ from pytest_mock import MockerFixture
from core.plugin.endpoint.exc import EndpointSetupFailedError
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@@ -167,3 +168,10 @@ class TestBasePluginClientImpl:
with pytest.raises(expected):
client._handle_plugin_daemon_error("PluginInvokeError", message)
def test_handle_plugin_daemon_error_maps_unsupported_polling_to_typed_exception(self):
client = BasePluginClient()
message = json.dumps({"error_type": PluginLLMPollingUnsupportedError.__name__, "message": "m"})
with pytest.raises(PluginLLMPollingUnsupportedError):
client._handle_plugin_daemon_error("PluginInvokeError", message)
@@ -1,13 +1,17 @@
from __future__ import annotations
import io
import json
from types import SimpleNamespace
import pytest
from pytest_mock import MockerFixture
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl.exc import PluginInvokeError, PluginLLMPollingUnsupportedError
from core.plugin.impl.model import PluginModelClient
from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMPollingStatus, LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
class TestPluginModelClient:
@@ -183,6 +187,113 @@ class TestPluginModelClient:
)
)
def test_start_llm_polling(self, mocker: MockerFixture):
client = PluginModelClient()
polling_result = LLMPollingResult(
status=LLMPollingStatus.RUNNING,
plugin_state={"task_id": "poll-1"},
next_check_after_seconds=3,
)
request_mock = mocker.patch.object(
client,
"_request_with_plugin_daemon_response",
return_value=polling_result,
)
result = client.start_llm_polling(
tenant_id="tenant-1",
user_id="user-1",
plugin_id="org/plugin:1",
provider="provider-a",
model="gpt-test",
credentials={"api_key": "key"},
prompt_messages=[],
model_parameters={"temperature": 0.1},
tools=[],
stop=["STOP"],
json_schema={"type": "object"},
)
assert result == polling_result
call_kwargs = request_mock.call_args.kwargs
assert call_kwargs["path"] == "plugin/tenant-1/dispatch/model/polling/start"
assert call_kwargs["data"]["data"] == {
"provider": "provider-a",
"model_type": "llm",
"model": "gpt-test",
"credentials": {"api_key": "key"},
"prompt_messages": [],
"model_parameters": {"temperature": 0.1},
"tools": [],
"stop": ["STOP"],
"stream": False,
"json_schema": {"type": "object"},
}
def test_check_llm_polling(self, mocker: MockerFixture):
client = PluginModelClient()
polling_result = LLMPollingResult(
status=LLMPollingStatus.SUCCEEDED,
result=LLMResult(
model="gpt-test",
prompt_messages=[],
message=AssistantPromptMessage(content="done"),
usage=LLMUsage.empty_usage(),
),
)
request_mock = mocker.patch.object(
client,
"_request_with_plugin_daemon_response",
return_value=polling_result,
)
result = client.check_llm_polling(
tenant_id="tenant-1",
user_id="user-1",
plugin_id="org/plugin:1",
provider="provider-a",
model="gpt-test",
credentials={"api_key": "key"},
plugin_state={"task_id": "poll-1"},
)
assert result == polling_result
call_kwargs = request_mock.call_args.kwargs
assert call_kwargs["path"] == "plugin/tenant-1/dispatch/model/polling/check"
assert call_kwargs["data"]["data"] == {
"provider": "provider-a",
"model_type": "llm",
"model": "gpt-test",
"credentials": {"api_key": "key"},
"plugin_state": {"task_id": "poll-1"},
}
def test_start_llm_polling_maps_unsupported_polling_invoke_error(self, mocker: MockerFixture):
client = PluginModelClient()
mocker.patch.object(
client,
"_request_with_plugin_daemon_response",
side_effect=PluginInvokeError(
json.dumps(
{
"error_type": PluginLLMPollingUnsupportedError.__name__,
"message": "Model `gpt-test` does not support polling.",
}
)
),
)
with pytest.raises(PluginLLMPollingUnsupportedError):
client.start_llm_polling(
tenant_id="tenant-1",
user_id="user-1",
plugin_id="org/plugin:1",
provider="provider-a",
model="gpt-test",
credentials={"api_key": "key"},
prompt_messages=[],
)
def test_get_llm_num_tokens(self, mocker: MockerFixture):
client = PluginModelClient()
mocker.patch.object(
@@ -14,7 +14,13 @@ from core.plugin.impl.model_runtime import TENANT_SCOPE_SCHEMA_CACHE_USER_ID, Pl
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
from core.plugin.plugin_service import PluginService
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.llm_entities import (
LLMPollingResult,
LLMPollingStatus,
LLMResultChunk,
LLMResultChunkDelta,
LLMUsage,
)
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
@@ -282,6 +288,74 @@ class TestPluginModelRuntime:
stream=True,
)
def test_start_llm_polling_resolves_plugin_fields(self) -> None:
client = Mock(spec=PluginModelClient)
polling_result = LLMPollingResult(
status=LLMPollingStatus.RUNNING,
plugin_state={"task_id": "poll-1"},
next_check_after_seconds=2,
)
client.start_llm_polling.return_value = polling_result
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
result = runtime.start_llm_polling(
provider="langgenius/openai/openai",
model="gpt-4o-mini",
credentials={"api_key": "secret"},
model_parameters={"temperature": 0.2},
prompt_messages=[],
tools=None,
stop=("END",),
json_schema={"type": "object"},
)
assert result == polling_result
client.start_llm_polling.assert_called_once_with(
tenant_id="tenant",
user_id="user",
plugin_id="langgenius/openai",
provider="openai",
model="gpt-4o-mini",
credentials={"api_key": "secret"},
prompt_messages=[],
model_parameters={"temperature": 0.2},
tools=None,
stop=["END"],
json_schema={"type": "object"},
)
def test_check_llm_polling_resolves_plugin_fields(self) -> None:
client = Mock(spec=PluginModelClient)
polling_result = LLMPollingResult(
status=LLMPollingStatus.SUCCEEDED,
result=model_runtime_module.LLMResult(
model="gpt-4o-mini",
prompt_messages=[],
message=AssistantPromptMessage(content="done"),
usage=LLMUsage.empty_usage(),
),
)
client.check_llm_polling.return_value = polling_result
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
result = runtime.check_llm_polling(
provider="langgenius/openai/openai",
model="gpt-4o-mini",
credentials={"api_key": "secret"},
plugin_state={"task_id": "poll-1"},
)
assert result == polling_result
client.check_llm_polling.assert_called_once_with(
tenant_id="tenant",
user_id="user",
plugin_id="langgenius/openai",
provider="openai",
model="gpt-4o-mini",
credentials={"api_key": "secret"},
plugin_state={"task_id": "poll-1"},
)
def test_invoke_llm_rejects_per_call_user_override(self) -> None:
client = Mock(spec=PluginModelClient)
client.invoke_llm.return_value = sentinel.result