dify-player 0.3.8__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.8 → dify_player-0.3.9}/PKG-INFO +1 -1
  2. {dify_player-0.3.8 → dify_player-0.3.9}/README.md +7 -1
  3. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/__init__.py +1 -1
  4. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/exceptions.py +21 -0
  5. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/models.py +8 -3
  6. {dify_player-0.3.8 → 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.8/dify_player/nodes/llm_groq_chat.py → dify_player-0.3.9/dify_player/nodes/llm_openai_compatible_chat.py +135 -42
  9. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/plan_loader.py +6 -6
  10. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/runtime.py +24 -4
  11. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player.egg-info/PKG-INFO +1 -1
  12. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player.egg-info/SOURCES.txt +1 -0
  13. {dify_player-0.3.8 → dify_player-0.3.9}/pyproject.toml +1 -1
  14. {dify_player-0.3.8 → dify_player-0.3.9}/MANIFEST.in +0 -0
  15. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/__main__.py +0 -0
  16. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/cli.py +0 -0
  17. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/__init__.py +0 -0
  18. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/graph_parser.py +0 -0
  19. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/http_body_converter.py +0 -0
  20. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/__init__.py +0 -0
  21. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/assigner.py +0 -0
  22. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/code.py +0 -0
  23. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/end.py +0 -0
  24. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/http_request.py +0 -0
  25. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/if_else.py +0 -0
  26. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/llm.py +0 -0
  27. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/loop.py +0 -0
  28. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/start.py +0 -0
  29. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/template_transform.py +0 -0
  30. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/node_converters/variable_aggregator.py +0 -0
  31. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/plan_serializer.py +0 -0
  32. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/reference_converter.py +0 -0
  33. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/workflow_loader.py +0 -0
  34. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_importer/workflow_normalizer.py +0 -0
  35. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/dify_workflow_importer.py +0 -0
  36. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/event_logger.py +0 -0
  37. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/input_resolver.py +0 -0
  38. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/llm_cache.py +0 -0
  39. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/assigner.py +0 -0
  40. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/code.py +0 -0
  41. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/end.py +0 -0
  42. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/http.py +0 -0
  43. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/if_else.py +0 -0
  44. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/llm_azure_chat.py +0 -0
  45. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/llm_xai_chat.py +0 -0
  46. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/start.py +0 -0
  47. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/template.py +0 -0
  48. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/nodes/variable_aggregator.py +0 -0
  49. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/provider_config.py +0 -0
  50. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/value_renderer.py +0 -0
  51. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/workflow_engine.py +0 -0
  52. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player/workflow_executor.py +0 -0
  53. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player.egg-info/dependency_links.txt +0 -0
  54. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player.egg-info/requires.txt +0 -0
  55. {dify_player-0.3.8 → dify_player-0.3.9}/dify_player.egg-info/top_level.txt +0 -0
  56. {dify_player-0.3.8 → 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.8
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
+ )
@@ -1,14 +1,3 @@
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
1
  from __future__ import annotations
13
2
 
14
3
  import json
@@ -16,7 +5,11 @@ from typing import Any
16
5
 
17
6
  import httpx
18
7
 
19
- from dify_player.exceptions import GroqLLMBadStatusError, GroqLLMRequestFailedError, GroqLLMStructuredOutputError
8
+ from dify_player.exceptions import (
9
+ OpenAICompatibleLLMBadStatusError,
10
+ OpenAICompatibleLLMRequestFailedError,
11
+ OpenAICompatibleLLMStructuredOutputError,
12
+ )
20
13
  from dify_player.models import Node
21
14
  from dify_player.nodes.llm_azure_chat import (
22
15
  _build_bad_status_message,
@@ -32,7 +25,6 @@ from dify_player.nodes.llm_azure_chat import (
32
25
  from dify_player.provider_config import ProviderConfig, resolve_provider_config_value
33
26
  from dify_player.value_renderer import render_value
34
27
 
35
- _DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
36
28
  _STRUCTURED_OUTPUT_REPAIR_RETRIES = 1
37
29
 
38
30
 
@@ -44,22 +36,69 @@ async def run(
44
36
  resolved_inputs: dict[str, Any],
45
37
  http_client: httpx.AsyncClient | None = None,
46
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,
47
86
  ) -> dict[str, Any]:
48
87
  _ = resolved_inputs
49
88
  api_key = resolve_provider_config_value(
50
89
  provider_config=provider_config,
51
- config_key="groq_api_key",
52
- env_names=("DIFY_PLAYER_GROQ_API_KEY", "GROQ_API_KEY"),
90
+ config_key=api_key_config_key,
91
+ env_names=api_key_env_names,
53
92
  node=node,
54
93
  required=True,
55
94
  )
56
95
  base_url = resolve_provider_config_value(
57
96
  provider_config=provider_config,
58
- config_key="groq_api_base_url",
59
- 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,
60
99
  node=node,
61
- required=False,
62
- default=_DEFAULT_BASE_URL,
100
+ required=base_url_default is None,
101
+ default=base_url_default,
63
102
  )
64
103
 
65
104
  messages = render_value(
@@ -74,6 +113,11 @@ async def run(
74
113
  api_key=api_key,
75
114
  messages=messages,
76
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,
77
121
  )
78
122
 
79
123
  raw_text, metadata = await _request_raw_text(
@@ -82,6 +126,8 @@ async def run(
82
126
  api_key=api_key,
83
127
  messages=messages,
84
128
  http_client=http_client,
129
+ bad_status_error_cls=bad_status_error_cls,
130
+ request_failed_error_cls=request_failed_error_cls,
85
131
  )
86
132
  return _build_llm_output(
87
133
  text=raw_text,
@@ -89,23 +135,32 @@ async def run(
89
135
  usage=metadata["usage"],
90
136
  finish_reason=metadata["finish_reason"],
91
137
  attempt_count=1,
138
+ provider_name=provider_name,
92
139
  )
93
140
 
94
141
 
95
142
  def _build_request_body(*, node: Node, messages: Any) -> dict[str, Any]:
96
143
  if not isinstance(messages, list):
97
- 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
+ )
98
147
 
99
148
  request_messages: list[dict[str, str]] = []
100
149
  for index, message in enumerate(messages):
101
150
  if not isinstance(message, dict):
102
- 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
+ )
103
154
  role = message.get("role")
104
155
  content = message.get("content")
105
156
  if not isinstance(role, str) or not role:
106
- 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
+ )
107
160
  if not isinstance(content, str):
108
- 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
+ )
109
164
  request_messages.append({"role": role, "content": content})
110
165
 
111
166
  body: dict[str, Any] = {
@@ -135,6 +190,8 @@ async def _request_raw_text(
135
190
  api_key: str,
136
191
  messages: list[dict[str, str]],
137
192
  http_client: httpx.AsyncClient | None,
193
+ bad_status_error_cls: type[OpenAICompatibleLLMBadStatusError],
194
+ request_failed_error_cls: type[OpenAICompatibleLLMRequestFailedError],
138
195
  ) -> tuple[str, dict[str, Any]]:
139
196
  request_body = _build_request_body(node=node, messages=messages)
140
197
  url = f"{base_url.rstrip('/')}/chat/completions"
@@ -145,22 +202,33 @@ async def _request_raw_text(
145
202
  try:
146
203
  response = await http_client.post(
147
204
  url,
148
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
205
+ headers={
206
+ "Authorization": f"Bearer {api_key}",
207
+ "Content-Type": "application/json",
208
+ },
149
209
  json=request_body,
150
210
  timeout=node.config.get("timeout_sec", 60),
151
211
  )
152
212
  except httpx.RequestError as exc:
153
- 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
154
216
 
155
217
  if response.status_code >= 400:
156
- raise GroqLLMBadStatusError(
157
- _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
+ ),
158
224
  status_code=response.status_code,
159
225
  response_body=response.text,
160
226
  )
161
227
 
162
228
  payload = _parse_response_json(node=node, body=response.text)
163
- 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)
164
232
 
165
233
 
166
234
  async def _run_structured_output_with_repair(
@@ -170,6 +238,11 @@ async def _run_structured_output_with_repair(
170
238
  api_key: str,
171
239
  messages: list[dict[str, str]],
172
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,
173
246
  ) -> dict[str, Any]:
174
247
  current_messages = list(messages)
175
248
  attempts: list[dict[str, str]] = []
@@ -184,9 +257,14 @@ async def _run_structured_output_with_repair(
184
257
  api_key=api_key,
185
258
  messages=current_messages,
186
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,
187
267
  )
188
- except GroqLLMBadStatusError as exc:
189
- failed_generation = _extract_groq_failed_generation(exc)
190
268
  if failed_generation is None:
191
269
  raise
192
270
 
@@ -200,12 +278,15 @@ async def _run_structured_output_with_repair(
200
278
  }
201
279
  )
202
280
  if attempt_index >= _STRUCTURED_OUTPUT_REPAIR_RETRIES:
203
- raise GroqLLMStructuredOutputError(
281
+ raise structured_output_error_cls(
204
282
  f"{validation_error} after {attempt_index + 1} attempts",
205
283
  attempts=attempts,
206
284
  ) from exc
207
285
  current_messages = current_messages + [
208
- _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
+ )
209
290
  ]
210
291
  continue
211
292
 
@@ -213,13 +294,19 @@ async def _run_structured_output_with_repair(
213
294
  latest_metadata = metadata
214
295
  try:
215
296
  structured_output = _parse_structured_output(node=node, raw_text=raw_text)
216
- _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
+ )
217
303
  return _build_llm_output(
218
304
  text=raw_text,
219
305
  structured_output=structured_output,
220
306
  usage=aggregated_usage,
221
307
  finish_reason=latest_metadata["finish_reason"],
222
308
  attempt_count=attempt_index + 1,
309
+ provider_name=provider_name,
223
310
  )
224
311
  except ValueError as exc:
225
312
  attempts.append(
@@ -230,20 +317,25 @@ async def _run_structured_output_with_repair(
230
317
  }
231
318
  )
232
319
  if attempt_index >= _STRUCTURED_OUTPUT_REPAIR_RETRIES:
233
- raise GroqLLMStructuredOutputError(
320
+ raise structured_output_error_cls(
234
321
  f"{str(exc)} after {attempt_index + 1} attempts",
235
322
  attempts=attempts,
236
323
  ) from exc
237
- 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
+ ]
238
330
 
239
331
  raise AssertionError("structured output repair loop must return or raise")
240
332
 
241
333
 
242
- # Groq may return json_validate_failed as HTTP 400 even when the failure is the
243
- # model's generated JSON, not a permanent request/schema error. Retry through the
244
- # existing repair prompt even when Groq omits failed_generation, because the next
245
- # attempt can still recover from provider-side structured-output validation.
246
- 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:
247
339
  if exc.status_code != 400 or exc.response_body is None:
248
340
  return None
249
341
 
@@ -266,7 +358,7 @@ def _extract_groq_failed_generation(exc: GroqLLMBadStatusError) -> tuple[str, st
266
358
 
267
359
  message = error.get("message")
268
360
  if not isinstance(message, str) or not message:
269
- message = "Groq json_validate_failed"
361
+ message = default_message
270
362
  return message, failed_generation
271
363
 
272
364
 
@@ -277,6 +369,7 @@ def _build_llm_output(
277
369
  usage: dict[str, int],
278
370
  finish_reason: str | None,
279
371
  attempt_count: int,
372
+ provider_name: str,
280
373
  ) -> dict[str, Any]:
281
374
  output = {
282
375
  "text": text,
@@ -284,7 +377,7 @@ def _build_llm_output(
284
377
  "finish_reason": finish_reason,
285
378
  "attempt_count": attempt_count,
286
379
  "safety": None,
287
- "provider_details": {"provider": "groq"},
380
+ "provider_details": {"provider": provider_name},
288
381
  }
289
382
  if structured_output is not None:
290
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.8
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.8"
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