-
Notifications
You must be signed in to change notification settings - Fork 1
feat(workflow): JoyAI-VL-Interaction tool capability node #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| """JoyAI-VL-Interaction executor for OpenCLI Tool Capabilities. | ||
|
|
||
| Bridges the workflow runtime to a self-hosted JoyAI-VL-Interaction deployment | ||
| (https://github.com/jd-opensource/JoyAI-VL-Interaction) — JD's 8B real-time | ||
| video-language interaction model, served OpenAI-compatible via vLLM-Omni. | ||
|
|
||
| MVP scope: one synchronous interaction probe per tool call — send the node's | ||
| prompt plus media references (video URL / image frames) to the model's | ||
| chat-completions endpoint and emit the reply as an event.v1-style payload. | ||
| The always-on streaming mode (model watches a live feed and speaks up | ||
| unprompted) needs a persistent session and lands later on the same executor. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import time | ||
| import urllib.request | ||
| from datetime import UTC, datetime | ||
| from typing import Any | ||
|
|
||
| JOYAI_VL_INTERACTION_EXECUTOR = "joyai_vl_interaction" | ||
| JOYAI_VL_TOOL_CAPABILITY_ID = "tool.realtime.vl.interaction" | ||
| JOYAI_VL_DEFAULT_MODEL = "JoyAI-VL-Interaction-Preview" | ||
| JOYAI_VL_ENDPOINT_ENV = "JOYAI_VL_URL" | ||
|
|
||
|
|
||
| class JoyAIVLExecutionError(RuntimeError): | ||
| """Raised when the JoyAI-VL interaction executor cannot produce a reply.""" | ||
|
|
||
|
|
||
| def execute_joyai_vl_interaction(params: dict[str, Any]) -> dict[str, Any]: | ||
| """Run one vision-language interaction turn against a JoyAI-VL deployment.""" | ||
|
|
||
| endpoint = ( | ||
| _read_string(params.get("endpoint")) | ||
| or _read_string(params.get("endpointUrl")) | ||
| or _read_string(os.environ.get(JOYAI_VL_ENDPOINT_ENV)) | ||
| ) | ||
| if not endpoint: | ||
| raise JoyAIVLExecutionError( | ||
| f"JoyAI-VL endpoint is not configured: set {JOYAI_VL_ENDPOINT_ENV} " | ||
| "(vLLM-Omni base URL, e.g. http://joyai-vl:8000) or pass params.endpoint" | ||
| ) | ||
|
|
||
| model = _read_string(params.get("model")) or JOYAI_VL_DEFAULT_MODEL | ||
| prompt = _read_string(params.get("prompt")) or "描述当前画面正在发生什么, 如有需要人工注意的事件请指出." | ||
| video_url = _read_string(params.get("videoUrl")) or _read_string(params.get("video_url")) | ||
| image_urls = _read_string_list(params.get("imageUrls")) or _read_string_list( | ||
| params.get("image_urls") | ||
| ) | ||
| timeout_seconds = _read_timeout(params.get("timeoutSeconds")) | ||
|
|
||
| content: list[dict[str, Any]] = [] | ||
| if video_url: | ||
| content.append({"type": "video_url", "video_url": {"url": video_url}}) | ||
| for url in image_urls: | ||
| content.append({"type": "image_url", "image_url": {"url": url}}) | ||
| content.append({"type": "text", "text": prompt}) | ||
|
|
||
| request_body = { | ||
| "model": model, | ||
| "messages": [{"role": "user", "content": content}], | ||
| "stream": False, | ||
| } | ||
| max_tokens = _read_int(params.get("maxTokens")) | ||
| if max_tokens: | ||
| request_body["max_tokens"] = max_tokens | ||
|
|
||
| url = endpoint.rstrip("/") + "/v1/chat/completions" | ||
| opened_at = time.time() | ||
| request = urllib.request.Request( | ||
| url, | ||
| data=json.dumps(request_body).encode("utf-8"), | ||
| headers={ | ||
| "Content-Type": "application/json", | ||
| "User-Agent": "Mozilla/5.0 OpenCLI-Admin-joyai-vl-executor/0.1", | ||
| **_auth_header(params), | ||
| }, | ||
| method="POST", | ||
| ) | ||
|
|
||
| try: | ||
| with urllib.request.urlopen(request, timeout=timeout_seconds) as response: | ||
| payload = json.loads(response.read().decode("utf-8")) | ||
| except JoyAIVLExecutionError: | ||
| raise | ||
| except Exception as exc: # pragma: no cover - exercised by live smoke failures. | ||
| raise JoyAIVLExecutionError(f"JoyAI-VL request failed: {exc}") from exc | ||
|
|
||
| choices = payload.get("choices") or [] | ||
| if not choices: | ||
| raise JoyAIVLExecutionError(f"JoyAI-VL returned no choices: {payload}") | ||
| message = choices[0].get("message") or {} | ||
| reply = message.get("content") | ||
| if not isinstance(reply, str) or not reply.strip(): | ||
| raise JoyAIVLExecutionError(f"JoyAI-VL returned an empty reply: {payload}") | ||
|
|
||
| duration_ms = round((time.time() - opened_at) * 1000) | ||
| return { | ||
| "schema": "event.vl.interaction.v1", | ||
| "source": "joyai-vl", | ||
| "eventType": "vl.interaction", | ||
| "observedAt": datetime.now(tz=UTC).isoformat(), | ||
| "model": payload.get("model") or model, | ||
| "reply": reply, | ||
| "media": { | ||
| "videoUrl": video_url, | ||
| "imageCount": len(image_urls), | ||
| }, | ||
| "request": { | ||
| "url": url, | ||
| "prompt": prompt, | ||
| "durationMs": duration_ms, | ||
| }, | ||
| "usage": payload.get("usage") or {}, | ||
| } | ||
|
|
||
|
|
||
| def _auth_header(params: dict[str, Any]) -> dict[str, str]: | ||
| api_key = _read_string(params.get("apiKey")) or _read_string( | ||
| os.environ.get("JOYAI_VL_API_KEY") | ||
| ) | ||
| return {"Authorization": f"Bearer {api_key}"} if api_key else {} | ||
|
|
||
|
|
||
| def _read_string(value: object) -> str | None: | ||
| return value.strip() if isinstance(value, str) and value.strip() else None | ||
|
|
||
|
|
||
| def _read_string_list(value: object) -> list[str]: | ||
| if not isinstance(value, list): | ||
| return [] | ||
| return [item.strip() for item in value if isinstance(item, str) and item.strip()] | ||
|
|
||
|
|
||
| def _read_int(value: object) -> int | None: | ||
| try: | ||
| return int(value) if value is not None else None | ||
| except (TypeError, ValueError): | ||
| return None | ||
|
|
||
|
|
||
| def _read_timeout(value: object) -> float: | ||
| if isinstance(value, int | float) and value > 0: | ||
| return float(value) | ||
| return 30.0 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| """JoyAI-VL interaction tool executor: config guard, request shape, reply parsing.""" | ||
|
|
||
| import io | ||
| import json | ||
|
|
||
| import pytest | ||
|
|
||
| from backend.workflow.joyai_vl_executor import ( | ||
| JOYAI_VL_ENDPOINT_ENV, | ||
| JOYAI_VL_TOOL_CAPABILITY_ID, | ||
| JoyAIVLExecutionError, | ||
| execute_joyai_vl_interaction, | ||
| ) | ||
| from backend.workflow.tool_capabilities import ( | ||
| list_workflow_tool_capabilities, | ||
| resolve_workflow_tool_capability, | ||
| ) | ||
|
|
||
|
|
||
| def test_unconfigured_endpoint_raises_actionable_error(monkeypatch): | ||
| monkeypatch.delenv(JOYAI_VL_ENDPOINT_ENV, raising=False) | ||
| with pytest.raises(JoyAIVLExecutionError, match=JOYAI_VL_ENDPOINT_ENV): | ||
| execute_joyai_vl_interaction({}) | ||
|
|
||
|
|
||
| def test_success_turn_builds_openai_media_request_and_parses_reply(monkeypatch): | ||
| captured: dict = {} | ||
|
|
||
| class _FakeResponse(io.BytesIO): | ||
| def __enter__(self): | ||
| return self | ||
|
|
||
| def __exit__(self, *args): | ||
| return False | ||
|
|
||
| def fake_urlopen(request, timeout): | ||
| captured["url"] = request.full_url | ||
| captured["timeout"] = timeout | ||
| captured["body"] = json.loads(request.data.decode("utf-8")) | ||
| return _FakeResponse( | ||
| json.dumps( | ||
| { | ||
| "model": "JoyAI-VL-Interaction-Preview", | ||
| "choices": [{"message": {"role": "assistant", "content": "锅要溢出来了"}}], | ||
| "usage": {"total_tokens": 42}, | ||
| } | ||
| ).encode("utf-8") | ||
| ) | ||
|
|
||
| monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) | ||
|
|
||
| output = execute_joyai_vl_interaction( | ||
| { | ||
| "endpoint": "http://joyai-vl:8000/", | ||
| "prompt": "看着炉子", | ||
| "videoUrl": "http://media/stove.mp4", | ||
| "imageUrls": ["http://media/frame1.jpg"], | ||
| "timeoutSeconds": 5, | ||
| } | ||
| ) | ||
|
|
||
| assert captured["url"] == "http://joyai-vl:8000/v1/chat/completions" | ||
| assert captured["timeout"] == 5.0 | ||
| content = captured["body"]["messages"][0]["content"] | ||
| assert {"type": "video_url", "video_url": {"url": "http://media/stove.mp4"}} in content | ||
| assert {"type": "image_url", "image_url": {"url": "http://media/frame1.jpg"}} in content | ||
| assert content[-1] == {"type": "text", "text": "看着炉子"} | ||
|
|
||
| assert output["schema"] == "event.vl.interaction.v1" | ||
| assert output["source"] == "joyai-vl" | ||
| assert output["reply"] == "锅要溢出来了" | ||
| assert output["media"] == {"videoUrl": "http://media/stove.mp4", "imageCount": 1} | ||
| assert output["usage"] == {"total_tokens": 42} | ||
|
|
||
|
|
||
| def test_empty_choices_raises(monkeypatch): | ||
| class _FakeResponse(io.BytesIO): | ||
| def __enter__(self): | ||
| return self | ||
|
|
||
| def __exit__(self, *args): | ||
| return False | ||
|
|
||
| monkeypatch.setattr( | ||
| "urllib.request.urlopen", | ||
| lambda request, timeout: _FakeResponse(json.dumps({"choices": []}).encode("utf-8")), | ||
| ) | ||
| with pytest.raises(JoyAIVLExecutionError, match="no choices"): | ||
| execute_joyai_vl_interaction({"endpoint": "http://joyai-vl:8000"}) | ||
|
|
||
|
|
||
| def test_tool_capability_is_registered_and_resolvable(): | ||
| tool = resolve_workflow_tool_capability(JOYAI_VL_TOOL_CAPABILITY_ID) | ||
| assert tool is not None | ||
| assert tool.executor.mode == "joyai_vl_interaction" | ||
| assert tool.status == "runnable" | ||
| assert [port.type for port in tool.inputPorts] == ["event[]"] | ||
| assert [port.type for port in tool.outputPorts] == ["event[]"] | ||
| assert JOYAI_VL_TOOL_CAPABILITY_ID in { | ||
| t.id for t in list_workflow_tool_capabilities().tools | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The HTTP request and response parsing logic has several potential failure points that could raise unhandled exceptions (such as
AttributeError,TypeError, orurllib.error.HTTPError) outside of thetry...exceptblock. If any of these occur, they will not be caught asJoyAIVLExecutionError, causing the workflow run to crash instead of gracefully degrading to an error event as intended.Additionally, when an
HTTPErroroccurs, the response body often contains detailed validation or error messages from the vLLM server (e.g., model not found, invalid inputs). We can capture and read this error body to provide much more actionable error messages.We should wrap both the HTTP request and the entire response parsing logic in a single unified
try...exceptblock, perform strict type checking on the returned JSON payload, and extract the error body fromHTTPErrorif available.