dify-player 0.3.7__tar.gz → 0.3.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {dify_player-0.3.7 → dify_player-0.3.9}/PKG-INFO +1 -1
  2. {dify_player-0.3.7 → dify_player-0.3.9}/README.md +7 -1
  3. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/__init__.py +1 -1
  4. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/exceptions.py +21 -0
  5. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/models.py +8 -3
  6. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/__init__.py +3 -1
  7. dify_player-0.3.9/dify_player/nodes/llm_groq_chat.py +52 -0
  8. dify_player-0.3.7/dify_player/nodes/llm_groq_chat.py → dify_player-0.3.9/dify_player/nodes/llm_openai_compatible_chat.py +135 -31
  9. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/plan_loader.py +6 -6
  10. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/runtime.py +24 -4
  11. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player.egg-info/PKG-INFO +1 -1
  12. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player.egg-info/SOURCES.txt +1 -0
  13. {dify_player-0.3.7 → dify_player-0.3.9}/pyproject.toml +1 -1
  14. {dify_player-0.3.7 → dify_player-0.3.9}/MANIFEST.in +0 -0
  15. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/__main__.py +0 -0
  16. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/cli.py +0 -0
  17. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/__init__.py +0 -0
  18. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/graph_parser.py +0 -0
  19. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/http_body_converter.py +0 -0
  20. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/__init__.py +0 -0
  21. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/assigner.py +0 -0
  22. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/code.py +0 -0
  23. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/end.py +0 -0
  24. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/http_request.py +0 -0
  25. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/if_else.py +0 -0
  26. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/llm.py +0 -0
  27. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/loop.py +0 -0
  28. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/start.py +0 -0
  29. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/template_transform.py +0 -0
  30. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/variable_aggregator.py +0 -0
  31. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/plan_serializer.py +0 -0
  32. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/reference_converter.py +0 -0
  33. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/workflow_loader.py +0 -0
  34. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_importer/workflow_normalizer.py +0 -0
  35. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/dify_workflow_importer.py +0 -0
  36. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/event_logger.py +0 -0
  37. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/input_resolver.py +0 -0
  38. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/llm_cache.py +0 -0
  39. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/assigner.py +0 -0
  40. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/code.py +0 -0
  41. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/end.py +0 -0
  42. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/http.py +0 -0
  43. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/if_else.py +0 -0
  44. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/llm_azure_chat.py +0 -0
  45. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/llm_xai_chat.py +0 -0
  46. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/start.py +0 -0
  47. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/template.py +0 -0
  48. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/nodes/variable_aggregator.py +0 -0
  49. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/provider_config.py +0 -0
  50. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/value_renderer.py +0 -0
  51. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/workflow_engine.py +0 -0
  52. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player/workflow_executor.py +0 -0
  53. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player.egg-info/dependency_links.txt +0 -0
  54. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player.egg-info/requires.txt +0 -0
  55. {dify_player-0.3.7 → dify_player-0.3.9}/dify_player.egg-info/top_level.txt +0 -0
  56. {dify_player-0.3.7 → dify_player-0.3.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dify-player
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Minimal workflow runner for hand-authored Dify-like plans.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: Jinja2<4,>=3.1
@@ -99,7 +99,7 @@ async def run_workflow(payload: dict) -> dict:
99
99
 
100
100
  ### Provider Config
101
101
 
102
- Azure OpenAI Groq の接続設定は、実行単位で `provider_config` として渡せます。
102
+ Azure OpenAI、OpenAI-compatible endpoint、Groq の接続設定は、実行単位で `provider_config` として渡せます。
103
103
  優先順位は `provider_config`、`DIFY_PLAYER_*` 環境変数、既存互換の環境変数の順です。
104
104
 
105
105
  ```python
@@ -110,6 +110,8 @@ result = await engine.run_compiled_plan(
110
110
  "azure_openai_endpoint": "https://example.openai.azure.com",
111
111
  "azure_openai_api_key": "...",
112
112
  "azure_openai_api_version": "2024-10-21",
113
+ "openai_compatible_api_key": "...",
114
+ "openai_compatible_api_base_url": "http://localhost:8000/v1",
113
115
  "groq_api_key": "...",
114
116
  "groq_api_base_url": "https://api.groq.com/openai/v1",
115
117
  },
@@ -120,9 +122,13 @@ result = await engine.run_compiled_plan(
120
122
 
121
123
  - Azure OpenAI: `DIFY_PLAYER_AZURE_OPENAI_ENDPOINT`, `DIFY_PLAYER_AZURE_OPENAI_API_KEY`, `DIFY_PLAYER_AZURE_OPENAI_API_VERSION`
122
124
  - Azure OpenAI 既存互換: `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_API_VERSION`
125
+ - OpenAI-compatible: `DIFY_PLAYER_OPENAI_COMPATIBLE_API_KEY`, `DIFY_PLAYER_OPENAI_COMPATIBLE_API_BASE_URL`
126
+ - OpenAI-compatible 既存互換: `OPENAI_COMPATIBLE_API_KEY`, `OPENAI_COMPATIBLE_API_BASE_URL`
123
127
  - Groq: `DIFY_PLAYER_GROQ_API_KEY`, `DIFY_PLAYER_GROQ_API_BASE_URL`
124
128
  - Groq 既存互換: `GROQ_API_KEY`, `GROQ_API_BASE_URL`
125
129
 
130
+ 新規の OpenAI-compatible plan では `llm_openai_compatible_chat` を使ってください。`llm_groq_chat` は既存 plan 互換の alias として残しており、Groq の既定 base URL を引き続き使います。
131
+
126
132
  ### LLM Cache Mode
127
133
 
128
134
  workflow 改善中だけ LLM 応答を再利用したい場合は、`llm_cache=True` と cache store を渡します。
@@ -5,4 +5,4 @@ from dify_player.workflow_engine import WorkflowEngine
5
5
 
6
6
  __all__ = ["__version__", "LLMCacheStore", "NullLLMCacheStore", "WorkflowEngine"]
7
7
 
8
- __version__ = "0.3.7"
8
+ __version__ = "0.3.9"
@@ -84,3 +84,24 @@ class GroqLLMStructuredOutputError(RuntimeError):
84
84
  def __init__(self, message: str, *, attempts: list[dict[str, str]]) -> None:
85
85
  super().__init__(message)
86
86
  self.attempts = attempts
87
+
88
+
89
+ class OpenAICompatibleLLMRequestFailedError(RuntimeError):
90
+ """Raised when an OpenAI-compatible LLM request fails before receiving a response."""
91
+
92
+
93
+ class OpenAICompatibleLLMBadStatusError(RuntimeError):
94
+ """Raised when an OpenAI-compatible LLM request returns a 4xx/5xx response."""
95
+
96
+ def __init__(self, message: str, *, status_code: int, response_body: str | None = None) -> None:
97
+ super().__init__(message)
98
+ self.status_code = status_code
99
+ self.response_body = response_body
100
+
101
+
102
+ class OpenAICompatibleLLMStructuredOutputError(RuntimeError):
103
+ """Raised when OpenAI-compatible structured output remains invalid after repair retries."""
104
+
105
+ def __init__(self, message: str, *, attempts: list[dict[str, str]]) -> None:
106
+ super().__init__(message)
107
+ self.attempts = attempts
@@ -3,13 +3,18 @@ from __future__ import annotations
3
3
  from dataclasses import asdict, dataclass, field, is_dataclass
4
4
  from typing import Any
5
5
 
6
+ LLM_CHAT_NODE_KINDS = {
7
+ "llm_azure_chat",
8
+ "llm_xai_chat",
9
+ "llm_groq_chat",
10
+ "llm_openai_compatible_chat",
11
+ }
12
+
6
13
  ALLOWED_NODE_KINDS = {
7
14
  "start",
8
15
  "template",
9
16
  "http",
10
- "llm_azure_chat",
11
- "llm_xai_chat",
12
- "llm_groq_chat",
17
+ *LLM_CHAT_NODE_KINDS,
13
18
  "if_else",
14
19
  "variable_aggregator",
15
20
  "code",
@@ -15,6 +15,7 @@ from dify_player.nodes.http import run as run_http
15
15
  from dify_player.nodes.if_else import run as run_if_else
16
16
  from dify_player.nodes.llm_azure_chat import run as run_llm_azure_chat
17
17
  from dify_player.nodes.llm_groq_chat import run as run_llm_groq_chat
18
+ from dify_player.nodes.llm_openai_compatible_chat import run as run_llm_openai_compatible_chat
18
19
  from dify_player.nodes.llm_xai_chat import run as run_llm_xai_chat
19
20
  from dify_player.nodes.start import run as run_start
20
21
  from dify_player.nodes.template import run as run_template
@@ -29,6 +30,7 @@ _EXECUTORS: dict[str, NodeExecutor] = {
29
30
  "llm_azure_chat": run_llm_azure_chat,
30
31
  "llm_xai_chat": run_llm_xai_chat,
31
32
  "llm_groq_chat": run_llm_groq_chat,
33
+ "llm_openai_compatible_chat": run_llm_openai_compatible_chat,
32
34
  "if_else": run_if_else,
33
35
  "variable_aggregator": run_variable_aggregator,
34
36
  "code": run_code,
@@ -56,6 +58,6 @@ async def run_node(
56
58
  "resolved_inputs": resolved_inputs,
57
59
  "http_client": http_client,
58
60
  }
59
- if node.kind in {"llm_azure_chat", "llm_groq_chat"}:
61
+ if node.kind in {"llm_azure_chat", "llm_groq_chat", "llm_openai_compatible_chat"}:
60
62
  kwargs["provider_config"] = provider_config
61
63
  return await executor(**kwargs)
@@ -0,0 +1,52 @@
1
+ """LLM node for Groq and other OpenAI-compatible Chat Completions endpoints.
2
+
3
+ Although named ``llm_groq_chat`` for historical reasons, this node speaks the
4
+ plain OpenAI ``/chat/completions`` protocol, so it can target any
5
+ OpenAI-compatible server (Groq, on-prem vLLM / TGI, etc.) simply by overriding
6
+ ``groq_api_base_url`` / ``DIFY_PLAYER_GROQ_API_BASE_URL``. Structured output is
7
+ requested with the OpenAI-standard ``response_format: {"type": "json_schema",
8
+ ...}`` shape, which recent vLLM builds accept, so OSS / on-prem deployments
9
+ reuse this node unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from dify_player.exceptions import GroqLLMBadStatusError, GroqLLMRequestFailedError, GroqLLMStructuredOutputError
19
+ from dify_player.models import Node
20
+ from dify_player.nodes.llm_openai_compatible_chat import run_openai_compatible_chat
21
+ from dify_player.provider_config import ProviderConfig
22
+
23
+ _DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
24
+
25
+
26
+ async def run(
27
+ *,
28
+ node: Node,
29
+ workflow_inputs: dict[str, Any],
30
+ node_outputs: dict[str, dict[str, Any]],
31
+ resolved_inputs: dict[str, Any],
32
+ http_client: httpx.AsyncClient | None = None,
33
+ provider_config: ProviderConfig | None = None,
34
+ ) -> dict[str, Any]:
35
+ return await run_openai_compatible_chat(
36
+ node=node,
37
+ workflow_inputs=workflow_inputs,
38
+ node_outputs=node_outputs,
39
+ resolved_inputs=resolved_inputs,
40
+ http_client=http_client,
41
+ provider_config=provider_config,
42
+ api_key_config_key="groq_api_key",
43
+ api_key_env_names=("DIFY_PLAYER_GROQ_API_KEY", "GROQ_API_KEY"),
44
+ base_url_config_key="groq_api_base_url",
45
+ base_url_env_names=("DIFY_PLAYER_GROQ_API_BASE_URL", "GROQ_API_BASE_URL"),
46
+ base_url_default=_DEFAULT_BASE_URL,
47
+ provider_name="groq",
48
+ bad_status_error_cls=GroqLLMBadStatusError,
49
+ request_failed_error_cls=GroqLLMRequestFailedError,
50
+ structured_output_error_cls=GroqLLMStructuredOutputError,
51
+ json_validate_failed_default_message="Groq json_validate_failed",
52
+ )
@@ -5,7 +5,11 @@ from typing import Any
5
5
 
6
6
  import httpx
7
7
 
8
- from dify_player.exceptions import GroqLLMBadStatusError, GroqLLMRequestFailedError, GroqLLMStructuredOutputError
8
+ from dify_player.exceptions import (
9
+ OpenAICompatibleLLMBadStatusError,
10
+ OpenAICompatibleLLMRequestFailedError,
11
+ OpenAICompatibleLLMStructuredOutputError,
12
+ )
9
13
  from dify_player.models import Node
10
14
  from dify_player.nodes.llm_azure_chat import (
11
15
  _build_bad_status_message,
@@ -21,7 +25,6 @@ from dify_player.nodes.llm_azure_chat import (
21
25
  from dify_player.provider_config import ProviderConfig, resolve_provider_config_value
22
26
  from dify_player.value_renderer import render_value
23
27
 
24
- _DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
25
28
  _STRUCTURED_OUTPUT_REPAIR_RETRIES = 1
26
29
 
27
30
 
@@ -33,22 +36,69 @@ async def run(
33
36
  resolved_inputs: dict[str, Any],
34
37
  http_client: httpx.AsyncClient | None = None,
35
38
  provider_config: ProviderConfig | None = None,
39
+ ) -> dict[str, Any]:
40
+ return await run_openai_compatible_chat(
41
+ node=node,
42
+ workflow_inputs=workflow_inputs,
43
+ node_outputs=node_outputs,
44
+ resolved_inputs=resolved_inputs,
45
+ http_client=http_client,
46
+ provider_config=provider_config,
47
+ api_key_config_key="openai_compatible_api_key",
48
+ api_key_env_names=(
49
+ "DIFY_PLAYER_OPENAI_COMPATIBLE_API_KEY",
50
+ "OPENAI_COMPATIBLE_API_KEY",
51
+ ),
52
+ base_url_config_key="openai_compatible_api_base_url",
53
+ base_url_env_names=(
54
+ "DIFY_PLAYER_OPENAI_COMPATIBLE_API_BASE_URL",
55
+ "OPENAI_COMPATIBLE_API_BASE_URL",
56
+ ),
57
+ base_url_default=None,
58
+ provider_name="openai_compatible",
59
+ bad_status_error_cls=OpenAICompatibleLLMBadStatusError,
60
+ request_failed_error_cls=OpenAICompatibleLLMRequestFailedError,
61
+ structured_output_error_cls=OpenAICompatibleLLMStructuredOutputError,
62
+ json_validate_failed_default_message=(
63
+ "OpenAI-compatible json_validate_failed"
64
+ ),
65
+ )
66
+
67
+
68
+ async def run_openai_compatible_chat(
69
+ *,
70
+ node: Node,
71
+ workflow_inputs: dict[str, Any],
72
+ node_outputs: dict[str, dict[str, Any]],
73
+ resolved_inputs: dict[str, Any],
74
+ http_client: httpx.AsyncClient | None,
75
+ provider_config: ProviderConfig | None,
76
+ api_key_config_key: str,
77
+ api_key_env_names: tuple[str, ...],
78
+ base_url_config_key: str,
79
+ base_url_env_names: tuple[str, ...],
80
+ base_url_default: str | None,
81
+ provider_name: str,
82
+ bad_status_error_cls: type[OpenAICompatibleLLMBadStatusError],
83
+ request_failed_error_cls: type[OpenAICompatibleLLMRequestFailedError],
84
+ structured_output_error_cls: type[OpenAICompatibleLLMStructuredOutputError],
85
+ json_validate_failed_default_message: str,
36
86
  ) -> dict[str, Any]:
37
87
  _ = resolved_inputs
38
88
  api_key = resolve_provider_config_value(
39
89
  provider_config=provider_config,
40
- config_key="groq_api_key",
41
- env_names=("DIFY_PLAYER_GROQ_API_KEY", "GROQ_API_KEY"),
90
+ config_key=api_key_config_key,
91
+ env_names=api_key_env_names,
42
92
  node=node,
43
93
  required=True,
44
94
  )
45
95
  base_url = resolve_provider_config_value(
46
96
  provider_config=provider_config,
47
- config_key="groq_api_base_url",
48
- env_names=("DIFY_PLAYER_GROQ_API_BASE_URL", "GROQ_API_BASE_URL"),
97
+ config_key=base_url_config_key,
98
+ env_names=base_url_env_names,
49
99
  node=node,
50
- required=False,
51
- default=_DEFAULT_BASE_URL,
100
+ required=base_url_default is None,
101
+ default=base_url_default,
52
102
  )
53
103
 
54
104
  messages = render_value(
@@ -63,6 +113,11 @@ async def run(
63
113
  api_key=api_key,
64
114
  messages=messages,
65
115
  http_client=http_client,
116
+ provider_name=provider_name,
117
+ bad_status_error_cls=bad_status_error_cls,
118
+ request_failed_error_cls=request_failed_error_cls,
119
+ structured_output_error_cls=structured_output_error_cls,
120
+ json_validate_failed_default_message=json_validate_failed_default_message,
66
121
  )
67
122
 
68
123
  raw_text, metadata = await _request_raw_text(
@@ -71,6 +126,8 @@ async def run(
71
126
  api_key=api_key,
72
127
  messages=messages,
73
128
  http_client=http_client,
129
+ bad_status_error_cls=bad_status_error_cls,
130
+ request_failed_error_cls=request_failed_error_cls,
74
131
  )
75
132
  return _build_llm_output(
76
133
  text=raw_text,
@@ -78,23 +135,32 @@ async def run(
78
135
  usage=metadata["usage"],
79
136
  finish_reason=metadata["finish_reason"],
80
137
  attempt_count=1,
138
+ provider_name=provider_name,
81
139
  )
82
140
 
83
141
 
84
142
  def _build_request_body(*, node: Node, messages: Any) -> dict[str, Any]:
85
143
  if not isinstance(messages, list):
86
- raise ValueError(f"llm node {node.label!r} config.messages must render to an array")
144
+ raise ValueError(
145
+ f"llm node {node.label!r} config.messages must render to an array"
146
+ )
87
147
 
88
148
  request_messages: list[dict[str, str]] = []
89
149
  for index, message in enumerate(messages):
90
150
  if not isinstance(message, dict):
91
- raise ValueError(f"llm node {node.label!r} config.messages[{index}] must be an object")
151
+ raise ValueError(
152
+ f"llm node {node.label!r} config.messages[{index}] must be an object"
153
+ )
92
154
  role = message.get("role")
93
155
  content = message.get("content")
94
156
  if not isinstance(role, str) or not role:
95
- raise ValueError(f"llm node {node.label!r} config.messages[{index}].role must be a non-empty string")
157
+ raise ValueError(
158
+ f"llm node {node.label!r} config.messages[{index}].role must be a non-empty string"
159
+ )
96
160
  if not isinstance(content, str):
97
- raise ValueError(f"llm node {node.label!r} config.messages[{index}].content must be a string")
161
+ raise ValueError(
162
+ f"llm node {node.label!r} config.messages[{index}].content must be a string"
163
+ )
98
164
  request_messages.append({"role": role, "content": content})
99
165
 
100
166
  body: dict[str, Any] = {
@@ -124,6 +190,8 @@ async def _request_raw_text(
124
190
  api_key: str,
125
191
  messages: list[dict[str, str]],
126
192
  http_client: httpx.AsyncClient | None,
193
+ bad_status_error_cls: type[OpenAICompatibleLLMBadStatusError],
194
+ request_failed_error_cls: type[OpenAICompatibleLLMRequestFailedError],
127
195
  ) -> tuple[str, dict[str, Any]]:
128
196
  request_body = _build_request_body(node=node, messages=messages)
129
197
  url = f"{base_url.rstrip('/')}/chat/completions"
@@ -134,22 +202,33 @@ async def _request_raw_text(
134
202
  try:
135
203
  response = await http_client.post(
136
204
  url,
137
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
205
+ headers={
206
+ "Authorization": f"Bearer {api_key}",
207
+ "Content-Type": "application/json",
208
+ },
138
209
  json=request_body,
139
210
  timeout=node.config.get("timeout_sec", 60),
140
211
  )
141
212
  except httpx.RequestError as exc:
142
- raise GroqLLMRequestFailedError(f"llm node {node.label!r} request failed: {exc}") from exc
213
+ raise request_failed_error_cls(
214
+ f"llm node {node.label!r} request failed: {exc}"
215
+ ) from exc
143
216
 
144
217
  if response.status_code >= 400:
145
- raise GroqLLMBadStatusError(
146
- _build_bad_status_message(node=node, status_code=response.status_code, response_body=response.text),
218
+ raise bad_status_error_cls(
219
+ _build_bad_status_message(
220
+ node=node,
221
+ status_code=response.status_code,
222
+ response_body=response.text,
223
+ ),
147
224
  status_code=response.status_code,
148
225
  response_body=response.text,
149
226
  )
150
227
 
151
228
  payload = _parse_response_json(node=node, body=response.text)
152
- return _extract_response_text(node=node, payload=payload), _extract_response_metadata(payload)
229
+ return _extract_response_text(
230
+ node=node, payload=payload
231
+ ), _extract_response_metadata(payload)
153
232
 
154
233
 
155
234
  async def _run_structured_output_with_repair(
@@ -159,6 +238,11 @@ async def _run_structured_output_with_repair(
159
238
  api_key: str,
160
239
  messages: list[dict[str, str]],
161
240
  http_client: httpx.AsyncClient | None,
241
+ provider_name: str,
242
+ bad_status_error_cls: type[OpenAICompatibleLLMBadStatusError],
243
+ request_failed_error_cls: type[OpenAICompatibleLLMRequestFailedError],
244
+ structured_output_error_cls: type[OpenAICompatibleLLMStructuredOutputError],
245
+ json_validate_failed_default_message: str,
162
246
  ) -> dict[str, Any]:
163
247
  current_messages = list(messages)
164
248
  attempts: list[dict[str, str]] = []
@@ -173,9 +257,14 @@ async def _run_structured_output_with_repair(
173
257
  api_key=api_key,
174
258
  messages=current_messages,
175
259
  http_client=http_client,
260
+ bad_status_error_cls=bad_status_error_cls,
261
+ request_failed_error_cls=request_failed_error_cls,
262
+ )
263
+ except bad_status_error_cls as exc:
264
+ failed_generation = _extract_json_validate_failed_generation(
265
+ exc,
266
+ default_message=json_validate_failed_default_message,
176
267
  )
177
- except GroqLLMBadStatusError as exc:
178
- failed_generation = _extract_groq_failed_generation(exc)
179
268
  if failed_generation is None:
180
269
  raise
181
270
 
@@ -189,12 +278,15 @@ async def _run_structured_output_with_repair(
189
278
  }
190
279
  )
191
280
  if attempt_index >= _STRUCTURED_OUTPUT_REPAIR_RETRIES:
192
- raise GroqLLMStructuredOutputError(
281
+ raise structured_output_error_cls(
193
282
  f"{validation_error} after {attempt_index + 1} attempts",
194
283
  attempts=attempts,
195
284
  ) from exc
196
285
  current_messages = current_messages + [
197
- _build_repair_message(validation_error=validation_error, previous_output=raw_text)
286
+ _build_repair_message(
287
+ validation_error=validation_error,
288
+ previous_output=raw_text,
289
+ )
198
290
  ]
199
291
  continue
200
292
 
@@ -202,13 +294,19 @@ async def _run_structured_output_with_repair(
202
294
  latest_metadata = metadata
203
295
  try:
204
296
  structured_output = _parse_structured_output(node=node, raw_text=raw_text)
205
- _validate_schema(node=node, schema=node.config["json_schema"], value=structured_output, path="$")
297
+ _validate_schema(
298
+ node=node,
299
+ schema=node.config["json_schema"],
300
+ value=structured_output,
301
+ path="$",
302
+ )
206
303
  return _build_llm_output(
207
304
  text=raw_text,
208
305
  structured_output=structured_output,
209
306
  usage=aggregated_usage,
210
307
  finish_reason=latest_metadata["finish_reason"],
211
308
  attempt_count=attempt_index + 1,
309
+ provider_name=provider_name,
212
310
  )
213
311
  except ValueError as exc:
214
312
  attempts.append(
@@ -219,20 +317,25 @@ async def _run_structured_output_with_repair(
219
317
  }
220
318
  )
221
319
  if attempt_index >= _STRUCTURED_OUTPUT_REPAIR_RETRIES:
222
- raise GroqLLMStructuredOutputError(
320
+ raise structured_output_error_cls(
223
321
  f"{str(exc)} after {attempt_index + 1} attempts",
224
322
  attempts=attempts,
225
323
  ) from exc
226
- current_messages = current_messages + [_build_repair_message(validation_error=str(exc), previous_output=raw_text)]
324
+ current_messages = current_messages + [
325
+ _build_repair_message(
326
+ validation_error=str(exc),
327
+ previous_output=raw_text,
328
+ )
329
+ ]
227
330
 
228
331
  raise AssertionError("structured output repair loop must return or raise")
229
332
 
230
333
 
231
- # Groq may return json_validate_failed as HTTP 400 even when the failure is the
232
- # model's generated JSON, not a permanent request/schema error. Retry through the
233
- # existing repair prompt even when Groq omits failed_generation, because the next
234
- # attempt can still recover from provider-side structured-output validation.
235
- def _extract_groq_failed_generation(exc: GroqLLMBadStatusError) -> tuple[str, str] | None:
334
+ def _extract_json_validate_failed_generation(
335
+ exc: OpenAICompatibleLLMBadStatusError,
336
+ *,
337
+ default_message: str,
338
+ ) -> tuple[str, str] | None:
236
339
  if exc.status_code != 400 or exc.response_body is None:
237
340
  return None
238
341
 
@@ -255,7 +358,7 @@ def _extract_groq_failed_generation(exc: GroqLLMBadStatusError) -> tuple[str, st
255
358
 
256
359
  message = error.get("message")
257
360
  if not isinstance(message, str) or not message:
258
- message = "Groq json_validate_failed"
361
+ message = default_message
259
362
  return message, failed_generation
260
363
 
261
364
 
@@ -266,6 +369,7 @@ def _build_llm_output(
266
369
  usage: dict[str, int],
267
370
  finish_reason: str | None,
268
371
  attempt_count: int,
372
+ provider_name: str,
269
373
  ) -> dict[str, Any]:
270
374
  output = {
271
375
  "text": text,
@@ -273,7 +377,7 @@ def _build_llm_output(
273
377
  "finish_reason": finish_reason,
274
378
  "attempt_count": attempt_count,
275
379
  "safety": None,
276
- "provider_details": {"provider": "groq"},
380
+ "provider_details": {"provider": provider_name},
277
381
  }
278
382
  if structured_output is not None:
279
383
  output["structured_output"] = structured_output
@@ -8,10 +8,10 @@ from typing import Any
8
8
  from jinja2 import meta
9
9
 
10
10
  from dify_player.exceptions import PlanValidationError
11
- from dify_player.models import ALLOWED_NODE_KINDS, CompiledLoop, CompiledPlan, Edge, Node, Plan, format_node_label
11
+ from dify_player.models import ALLOWED_NODE_KINDS, LLM_CHAT_NODE_KINDS, CompiledLoop, CompiledPlan, Edge, Node, Plan, format_node_label
12
12
  from dify_player.value_renderer import get_native_environment
13
13
 
14
- LOOP_BODY_ALLOWED_NODE_KINDS = {"llm_azure_chat", "llm_xai_chat", "llm_groq_chat", "code", "if_else", "template", "assigner"}
14
+ LOOP_BODY_ALLOWED_NODE_KINDS = {*LLM_CHAT_NODE_KINDS, "code", "if_else", "template", "assigner"}
15
15
 
16
16
 
17
17
  def _load_json(path: Path) -> Any:
@@ -92,7 +92,7 @@ def parse_plan(data: Any) -> Plan:
92
92
  _validate_code_config(node_id=node_id, node_name=name, config=normalized_config)
93
93
  if kind == "loop":
94
94
  _validate_loop_config(node_id=node_id, node_name=name, config=normalized_config)
95
- if kind in {"llm_azure_chat", "llm_xai_chat", "llm_groq_chat"}:
95
+ if kind in LLM_CHAT_NODE_KINDS:
96
96
  _validate_llm_chat_config(node_id=node_id, node_name=name, kind=kind, config=normalized_config)
97
97
  if kind == "end" and not isinstance(normalized_config.get("outputs"), dict):
98
98
  raise PlanValidationError(f"end node {node_label!r} must define config.outputs as an object")
@@ -265,7 +265,7 @@ def _validate_node_definition(node: Node) -> None:
265
265
  elif node.kind == "loop":
266
266
  if node.inputs:
267
267
  raise PlanValidationError(f"loop node {node.label!r} must not define inputs")
268
- elif node.kind in {"llm_azure_chat", "llm_xai_chat", "llm_groq_chat"}:
268
+ elif node.kind in LLM_CHAT_NODE_KINDS:
269
269
  _validate_allowed_roots(
270
270
  node.config["messages"],
271
271
  allowed_roots={"inputs", "nodes"},
@@ -619,7 +619,7 @@ def _parse_loop_body_nodes(raw_body_nodes: list[Any], *, node_label: str) -> lis
619
619
  _validate_if_else_config(node_id=node_id, node_name=name, config=normalized_config)
620
620
  if kind == "code":
621
621
  _validate_code_config(node_id=node_id, node_name=name, config=normalized_config)
622
- if kind in {"llm_azure_chat", "llm_xai_chat", "llm_groq_chat"}:
622
+ if kind in LLM_CHAT_NODE_KINDS:
623
623
  _validate_llm_chat_config(node_id=node_id, node_name=name, kind=kind, config=normalized_config)
624
624
  if kind == "assigner":
625
625
  _validate_assigner_config(node_id=node_id, node_name=name, config=normalized_config)
@@ -677,7 +677,7 @@ def _validate_loop_body_node_definition(node: Node) -> None:
677
677
  elif node.kind == "code":
678
678
  if not isinstance(node.config.get("source"), str):
679
679
  raise PlanValidationError(f"loop body code node {node.label!r} must define config.source as a string")
680
- elif node.kind in {"llm_azure_chat", "llm_xai_chat", "llm_groq_chat"}:
680
+ elif node.kind in LLM_CHAT_NODE_KINDS:
681
681
  _validate_allowed_roots(
682
682
  node.config["messages"],
683
683
  allowed_roots={"inputs", "nodes"},
@@ -17,6 +17,9 @@ from dify_player.exceptions import (
17
17
  GroqLLMStructuredOutputError,
18
18
  HTTPBadStatusError,
19
19
  HTTPRequestFailedError,
20
+ OpenAICompatibleLLMBadStatusError,
21
+ OpenAICompatibleLLMRequestFailedError,
22
+ OpenAICompatibleLLMStructuredOutputError,
20
23
  PlanValidationError,
21
24
  UnsupportedNodeError,
22
25
  XAILLMBadStatusError,
@@ -477,7 +480,7 @@ class WorkflowRuntime:
477
480
  return cached_output
478
481
 
479
482
  def _should_use_llm_cache(self, *, node: Node) -> bool:
480
- return self.llm_cache_enabled and node.kind == "llm_azure_chat"
483
+ return self.llm_cache_enabled and node.kind in {"llm_azure_chat", "llm_openai_compatible_chat"}
481
484
 
482
485
  def _load_cached_llm_output(
483
486
  self,
@@ -536,6 +539,15 @@ class WorkflowRuntime:
536
539
  ),
537
540
  "provider_config": _cacheable_provider_config(self.provider_config),
538
541
  }
542
+ elif node.kind == "llm_openai_compatible_chat":
543
+ extra = {
544
+ "rendered_messages": render_value(
545
+ node.config["messages"],
546
+ {"inputs": workflow_inputs, "nodes": node_outputs},
547
+ location=f"llm cache key for node {node.label!r} config.messages",
548
+ ),
549
+ "provider_config": _cacheable_provider_config(self.provider_config),
550
+ }
539
551
  return build_llm_cache_key(
540
552
  node_kind=node.kind,
541
553
  node_config=node.config,
@@ -674,7 +686,7 @@ class WorkflowRuntime:
674
686
  **({"response_body": exc.response_body} if exc.response_body is not None else {}),
675
687
  },
676
688
  )
677
- if isinstance(exc, (AzureLLMRequestFailedError, XAILLMRequestFailedError, GroqLLMRequestFailedError)):
689
+ if isinstance(exc, (AzureLLMRequestFailedError, XAILLMRequestFailedError, GroqLLMRequestFailedError, OpenAICompatibleLLMRequestFailedError)):
678
690
  return WorkflowError(
679
691
  code="LLM_REQUEST_FAILED",
680
692
  message=str(exc),
@@ -683,7 +695,7 @@ class WorkflowRuntime:
683
695
  node_name=node.name,
684
696
  retryable=True,
685
697
  )
686
- if isinstance(exc, (AzureLLMBadStatusError, XAILLMBadStatusError, GroqLLMBadStatusError)):
698
+ if isinstance(exc, (AzureLLMBadStatusError, XAILLMBadStatusError, GroqLLMBadStatusError, OpenAICompatibleLLMBadStatusError)):
687
699
  return WorkflowError(
688
700
  code="LLM_BAD_STATUS",
689
701
  message=str(exc),
@@ -696,7 +708,7 @@ class WorkflowRuntime:
696
708
  **({"response_body": exc.response_body} if exc.response_body is not None else {}),
697
709
  },
698
710
  )
699
- if isinstance(exc, (AzureLLMStructuredOutputError, XAILLMStructuredOutputError, GroqLLMStructuredOutputError)):
711
+ if isinstance(exc, (AzureLLMStructuredOutputError, XAILLMStructuredOutputError, GroqLLMStructuredOutputError, OpenAICompatibleLLMStructuredOutputError)):
700
712
  return WorkflowError(
701
713
  code="NODE_EXECUTION_FAILED",
702
714
  message=str(exc),
@@ -790,6 +802,14 @@ def _cacheable_provider_config(provider_config: ProviderConfig | None) -> dict[s
790
802
  config_key="azure_openai_api_version",
791
803
  env_names=("DIFY_PLAYER_AZURE_OPENAI_API_VERSION", "AZURE_OPENAI_API_VERSION"),
792
804
  ),
805
+ "openai_compatible_api_base_url": _cacheable_provider_config_value(
806
+ provider_config=provider_config,
807
+ config_key="openai_compatible_api_base_url",
808
+ env_names=(
809
+ "DIFY_PLAYER_OPENAI_COMPATIBLE_API_BASE_URL",
810
+ "OPENAI_COMPATIBLE_API_BASE_URL",
811
+ ),
812
+ ),
793
813
  }
794
814
  return {key: value for key, value in values.items() if value}
795
815
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dify-player
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Minimal workflow runner for hand-authored Dify-like plans.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: Jinja2<4,>=3.1
@@ -47,6 +47,7 @@ dify_player/nodes/http.py
47
47
  dify_player/nodes/if_else.py
48
48
  dify_player/nodes/llm_azure_chat.py
49
49
  dify_player/nodes/llm_groq_chat.py
50
+ dify_player/nodes/llm_openai_compatible_chat.py
50
51
  dify_player/nodes/llm_xai_chat.py
51
52
  dify_player/nodes/start.py
52
53
  dify_player/nodes/template.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "dify-player"
10
- version = "0.3.7"
10
+ version = "0.3.9"
11
11
  description = "Minimal workflow runner for hand-authored Dify-like plans."
12
12
  requires-python = ">=3.11"
13
13
  dependencies = [
File without changes
File without changes