dify-player 0.3.1__tar.gz → 0.3.2__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 (60) hide show
  1. dify_player-0.3.2/MANIFEST.in +2 -0
  2. {dify_player-0.3.1 → dify_player-0.3.2}/PKG-INFO +1 -1
  3. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/__init__.py +1 -1
  4. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/if_else.py +3 -1
  5. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/llm.py +16 -3
  6. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/exceptions.py +42 -0
  7. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/input_resolver.py +8 -2
  8. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/models.py +13 -1
  9. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/__init__.py +4 -0
  10. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/if_else.py +10 -0
  11. dify_player-0.3.2/dify_player/nodes/llm_groq_chat.py +216 -0
  12. dify_player-0.3.2/dify_player/nodes/llm_xai_chat.py +216 -0
  13. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/plan_loader.py +23 -23
  14. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/runtime.py +18 -3
  15. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/value_renderer.py +21 -0
  16. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player.egg-info/PKG-INFO +1 -1
  17. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player.egg-info/SOURCES.txt +4 -7
  18. {dify_player-0.3.1 → dify_player-0.3.2}/pyproject.toml +1 -1
  19. dify_player-0.3.1/tests/test_assigner.py +0 -301
  20. dify_player-0.3.1/tests/test_cli.py +0 -392
  21. dify_player-0.3.1/tests/test_dify_workflow_importer.py +0 -1497
  22. dify_player-0.3.1/tests/test_llm_cache.py +0 -28
  23. dify_player-0.3.1/tests/test_runtime.py +0 -2537
  24. dify_player-0.3.1/tests/test_workflow_engine.py +0 -65
  25. {dify_player-0.3.1 → dify_player-0.3.2}/README.md +0 -0
  26. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/__main__.py +0 -0
  27. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/cli.py +0 -0
  28. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/__init__.py +0 -0
  29. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/graph_parser.py +0 -0
  30. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/http_body_converter.py +0 -0
  31. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/__init__.py +0 -0
  32. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/assigner.py +0 -0
  33. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/code.py +0 -0
  34. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/end.py +0 -0
  35. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/http_request.py +0 -0
  36. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/loop.py +0 -0
  37. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/start.py +0 -0
  38. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/template_transform.py +0 -0
  39. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/variable_aggregator.py +0 -0
  40. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/plan_serializer.py +0 -0
  41. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/reference_converter.py +0 -0
  42. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/workflow_loader.py +0 -0
  43. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_importer/workflow_normalizer.py +0 -0
  44. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/dify_workflow_importer.py +0 -0
  45. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/event_logger.py +0 -0
  46. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/llm_cache.py +0 -0
  47. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/assigner.py +0 -0
  48. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/code.py +0 -0
  49. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/end.py +0 -0
  50. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/http.py +0 -0
  51. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/llm_azure_chat.py +0 -0
  52. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/start.py +0 -0
  53. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/template.py +0 -0
  54. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/nodes/variable_aggregator.py +0 -0
  55. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/workflow_engine.py +0 -0
  56. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player/workflow_executor.py +0 -0
  57. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player.egg-info/dependency_links.txt +0 -0
  58. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player.egg-info/requires.txt +0 -0
  59. {dify_player-0.3.1 → dify_player-0.3.2}/dify_player.egg-info/top_level.txt +0 -0
  60. {dify_player-0.3.1 → dify_player-0.3.2}/setup.cfg +0 -0
@@ -0,0 +1,2 @@
1
+ prune workflows
2
+ prune tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dify-player
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -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.1"
8
+ __version__ = "0.3.2"
@@ -86,4 +86,6 @@ def _convert_comparison_operator(raw_value: Any, *, node_id: str) -> str:
86
86
  return "contains"
87
87
  if raw_value == "=":
88
88
  return "equals"
89
- raise PlanValidationError(f"Dify if-else node {node_id!r} only supports comparison_operator=contains or '='")
89
+ if raw_value == "empty":
90
+ return "empty"
91
+ raise PlanValidationError(f"Dify if-else node {node_id!r} only supports comparison_operator=contains, '=', or empty")
@@ -8,6 +8,13 @@ from dify_player.exceptions import PlanValidationError
8
8
  from dify_player.models import Node
9
9
 
10
10
  _AZURE_PROVIDER = "langgenius/azure_openai/azure_openai"
11
+ _XAI_PROVIDER = "langgenius/x/x"
12
+ _GROQ_PROVIDER = "langgenius/groq/groq"
13
+ _PROVIDER_NODE_KINDS = {
14
+ _AZURE_PROVIDER: "llm_azure_chat",
15
+ _XAI_PROVIDER: "llm_xai_chat",
16
+ _GROQ_PROVIDER: "llm_groq_chat",
17
+ }
11
18
 
12
19
 
13
20
  def convert_llm_node(
@@ -23,8 +30,12 @@ def convert_llm_node(
23
30
  if not isinstance(model, dict):
24
31
  raise PlanValidationError(f"Dify llm node {node_id!r} must define model")
25
32
  provider = model.get("provider")
26
- if provider != _AZURE_PROVIDER:
27
- raise PlanValidationError(f"Dify llm node {node_id!r} only supports model.provider={_AZURE_PROVIDER}")
33
+ node_kind = _PROVIDER_NODE_KINDS.get(provider)
34
+ if node_kind is None:
35
+ supported_providers = " or ".join(_PROVIDER_NODE_KINDS)
36
+ raise PlanValidationError(
37
+ f"Dify llm node {node_id!r} only supports model.provider={supported_providers}"
38
+ )
28
39
  if model.get("mode") != "chat":
29
40
  raise PlanValidationError(f"Dify llm node {node_id!r} only supports model.mode=chat")
30
41
  model_name = model.get("name")
@@ -38,6 +49,8 @@ def convert_llm_node(
38
49
  raise PlanValidationError(f"Dify llm node {node_id!r} model.completion_params must be an object")
39
50
 
40
51
  structured_output_enabled = data.get("structured_output_enabled", False)
52
+ if structured_output_enabled is None:
53
+ structured_output_enabled = False
41
54
  if not isinstance(structured_output_enabled, bool):
42
55
  raise PlanValidationError(f"Dify llm node {node_id!r} structured_output_enabled must be a boolean")
43
56
 
@@ -54,7 +67,7 @@ def convert_llm_node(
54
67
  config["response_format"] = "json_schema"
55
68
  config["json_schema"] = _extract_json_schema(node_id=node_id, data=data, completion_params=completion_params)
56
69
 
57
- return Node(id=node_id, kind="llm_azure_chat", name=node_name, config=config)
70
+ return Node(id=node_id, kind=node_kind, name=node_name, config=config)
58
71
 
59
72
 
60
73
  def _convert_prompt_template(*, node_id: str, data: dict[str, Any], node_specs: dict[str, dict[str, Any]]) -> list[dict[str, str]]:
@@ -42,3 +42,45 @@ class AzureLLMStructuredOutputError(RuntimeError):
42
42
  def __init__(self, message: str, *, attempts: list[dict[str, str]]) -> None:
43
43
  super().__init__(message)
44
44
  self.attempts = attempts
45
+
46
+
47
+ class XAILLMRequestFailedError(RuntimeError):
48
+ """Raised when an xAI LLM request fails before receiving a response."""
49
+
50
+
51
+ class XAILLMBadStatusError(RuntimeError):
52
+ """Raised when an xAI LLM request returns a 4xx/5xx response."""
53
+
54
+ def __init__(self, message: str, *, status_code: int, response_body: str | None = None) -> None:
55
+ super().__init__(message)
56
+ self.status_code = status_code
57
+ self.response_body = response_body
58
+
59
+
60
+ class XAILLMStructuredOutputError(RuntimeError):
61
+ """Raised when xAI structured output remains invalid after repair retries."""
62
+
63
+ def __init__(self, message: str, *, attempts: list[dict[str, str]]) -> None:
64
+ super().__init__(message)
65
+ self.attempts = attempts
66
+
67
+
68
+ class GroqLLMRequestFailedError(RuntimeError):
69
+ """Raised when a Groq LLM request fails before receiving a response."""
70
+
71
+
72
+ class GroqLLMBadStatusError(RuntimeError):
73
+ """Raised when a Groq LLM request returns a 4xx/5xx response."""
74
+
75
+ def __init__(self, message: str, *, status_code: int, response_body: str | None = None) -> None:
76
+ super().__init__(message)
77
+ self.status_code = status_code
78
+ self.response_body = response_body
79
+
80
+
81
+ class GroqLLMStructuredOutputError(RuntimeError):
82
+ """Raised when Groq structured output remains invalid after repair retries."""
83
+
84
+ def __init__(self, message: str, *, attempts: list[dict[str, str]]) -> None:
85
+ super().__init__(message)
86
+ self.attempts = attempts
@@ -4,7 +4,7 @@ from typing import Any
4
4
 
5
5
  from dify_player.exceptions import PlanValidationError
6
6
  from dify_player.models import Node
7
- from dify_player.value_renderer import render_value
7
+ from dify_player.value_renderer import EMPTY_RENDERED_VALUE, render_value
8
8
 
9
9
 
10
10
  def resolve_node_inputs(
@@ -12,15 +12,21 @@ def resolve_node_inputs(
12
12
  *,
13
13
  workflow_inputs: dict[str, Any],
14
14
  node_outputs: dict[str, dict[str, Any]],
15
+ skipped_node_ids: set[str] | None = None,
15
16
  ) -> dict[str, Any]:
16
17
  if node.kind == "start":
17
18
  return {}
18
19
 
20
+ render_node_outputs: dict[str, Any] = dict(node_outputs)
21
+ if skipped_node_ids:
22
+ for node_id in skipped_node_ids:
23
+ render_node_outputs.setdefault(node_id, EMPTY_RENDERED_VALUE)
24
+
19
25
  resolved = render_value(
20
26
  node.inputs,
21
27
  {
22
28
  "inputs": workflow_inputs,
23
- "nodes": node_outputs,
29
+ "nodes": render_node_outputs,
24
30
  },
25
31
  location=f"node {node.label!r} inputs",
26
32
  )
@@ -3,7 +3,19 @@ from __future__ import annotations
3
3
  from dataclasses import asdict, dataclass, field, is_dataclass
4
4
  from typing import Any
5
5
 
6
- ALLOWED_NODE_KINDS = {"start", "template", "http", "llm_azure_chat", "if_else", "variable_aggregator", "code", "loop", "end"}
6
+ ALLOWED_NODE_KINDS = {
7
+ "start",
8
+ "template",
9
+ "http",
10
+ "llm_azure_chat",
11
+ "llm_xai_chat",
12
+ "llm_groq_chat",
13
+ "if_else",
14
+ "variable_aggregator",
15
+ "code",
16
+ "loop",
17
+ "end",
18
+ }
7
19
 
8
20
 
9
21
  def format_node_label(node_id: str, name: str | None = None) -> str:
@@ -13,6 +13,8 @@ from dify_player.nodes.end import run as run_end
13
13
  from dify_player.nodes.http import run as run_http
14
14
  from dify_player.nodes.if_else import run as run_if_else
15
15
  from dify_player.nodes.llm_azure_chat import run as run_llm_azure_chat
16
+ from dify_player.nodes.llm_groq_chat import run as run_llm_groq_chat
17
+ from dify_player.nodes.llm_xai_chat import run as run_llm_xai_chat
16
18
  from dify_player.nodes.start import run as run_start
17
19
  from dify_player.nodes.template import run as run_template
18
20
  from dify_player.nodes.variable_aggregator import run as run_variable_aggregator
@@ -24,6 +26,8 @@ _EXECUTORS: dict[str, NodeExecutor] = {
24
26
  "template": run_template,
25
27
  "http": run_http,
26
28
  "llm_azure_chat": run_llm_azure_chat,
29
+ "llm_xai_chat": run_llm_xai_chat,
30
+ "llm_groq_chat": run_llm_groq_chat,
27
31
  "if_else": run_if_else,
28
32
  "variable_aggregator": run_variable_aggregator,
29
33
  "code": run_code,
@@ -39,6 +39,16 @@ def _matches_condition(*, condition: dict[str, Any], resolved_inputs: dict[str,
39
39
  left_value = resolved_inputs[condition["input_name"]]
40
40
  if condition["operator"] == "contains":
41
41
  return str(condition["value"]) in str(left_value)
42
+ if condition["operator"] == "empty":
43
+ return _is_empty(left_value)
42
44
  if condition["operator"] == "equals":
43
45
  return str(left_value) == str(condition["value"])
44
46
  raise ValueError(f"unsupported if_else operator {condition['operator']!r}")
47
+
48
+
49
+ def _is_empty(value: Any) -> bool:
50
+ if value is None:
51
+ return True
52
+ if isinstance(value, (str, list, tuple, dict, set)):
53
+ return len(value) == 0
54
+ return False
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from dify_player.exceptions import GroqLLMBadStatusError, GroqLLMRequestFailedError, GroqLLMStructuredOutputError
9
+ from dify_player.models import Node
10
+ from dify_player.nodes.llm_azure_chat import (
11
+ _build_bad_status_message,
12
+ _build_repair_message,
13
+ _extract_response_metadata,
14
+ _extract_response_text,
15
+ _merge_usage,
16
+ _parse_response_json,
17
+ _parse_structured_output,
18
+ _validate_schema,
19
+ _zero_usage,
20
+ )
21
+ from dify_player.value_renderer import render_value
22
+
23
+ _DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
24
+ _STRUCTURED_OUTPUT_REPAIR_RETRIES = 1
25
+
26
+
27
+ async def run(
28
+ *,
29
+ node: Node,
30
+ workflow_inputs: dict[str, Any],
31
+ node_outputs: dict[str, dict[str, Any]],
32
+ resolved_inputs: dict[str, Any],
33
+ http_client: httpx.AsyncClient | None = None,
34
+ ) -> dict[str, Any]:
35
+ _ = resolved_inputs
36
+ api_key = _require_env("GROQ_API_KEY", node=node)
37
+ base_url = os.environ.get("GROQ_API_BASE_URL", _DEFAULT_BASE_URL)
38
+
39
+ messages = render_value(
40
+ node.config["messages"],
41
+ {"inputs": workflow_inputs, "nodes": node_outputs},
42
+ location=f"llm node {node.label!r} config.messages",
43
+ )
44
+ if node.config["response_format"] == "json_schema":
45
+ return await _run_structured_output_with_repair(
46
+ node=node,
47
+ base_url=base_url,
48
+ api_key=api_key,
49
+ messages=messages,
50
+ http_client=http_client,
51
+ )
52
+
53
+ raw_text, metadata = await _request_raw_text(
54
+ node=node,
55
+ base_url=base_url,
56
+ api_key=api_key,
57
+ messages=messages,
58
+ http_client=http_client,
59
+ )
60
+ return _build_llm_output(
61
+ text=raw_text,
62
+ structured_output=None,
63
+ usage=metadata["usage"],
64
+ finish_reason=metadata["finish_reason"],
65
+ attempt_count=1,
66
+ )
67
+
68
+
69
+ def _require_env(name: str, *, node: Node) -> str:
70
+ value = os.environ.get(name)
71
+ if value:
72
+ return value
73
+ raise ValueError(f"llm node {node.label!r} requires environment variable {name}")
74
+
75
+
76
+ def _build_request_body(*, node: Node, messages: Any) -> dict[str, Any]:
77
+ if not isinstance(messages, list):
78
+ raise ValueError(f"llm node {node.label!r} config.messages must render to an array")
79
+
80
+ request_messages: list[dict[str, str]] = []
81
+ for index, message in enumerate(messages):
82
+ if not isinstance(message, dict):
83
+ raise ValueError(f"llm node {node.label!r} config.messages[{index}] must be an object")
84
+ role = message.get("role")
85
+ content = message.get("content")
86
+ if not isinstance(role, str) or not role:
87
+ raise ValueError(f"llm node {node.label!r} config.messages[{index}].role must be a non-empty string")
88
+ if not isinstance(content, str):
89
+ raise ValueError(f"llm node {node.label!r} config.messages[{index}].content must be a string")
90
+ request_messages.append({"role": role, "content": content})
91
+
92
+ body: dict[str, Any] = {
93
+ "model": node.config["model"],
94
+ "messages": request_messages,
95
+ }
96
+ for key in ("temperature", "top_p", "max_tokens"):
97
+ if key in node.config:
98
+ body[key] = node.config[key]
99
+
100
+ if node.config["response_format"] == "json_schema":
101
+ body["response_format"] = {
102
+ "type": "json_schema",
103
+ "json_schema": {
104
+ "name": "structured_output",
105
+ "schema": node.config["json_schema"],
106
+ },
107
+ }
108
+
109
+ return body
110
+
111
+
112
+ async def _request_raw_text(
113
+ *,
114
+ node: Node,
115
+ base_url: str,
116
+ api_key: str,
117
+ messages: list[dict[str, str]],
118
+ http_client: httpx.AsyncClient | None,
119
+ ) -> tuple[str, dict[str, Any]]:
120
+ request_body = _build_request_body(node=node, messages=messages)
121
+ url = f"{base_url.rstrip('/')}/chat/completions"
122
+
123
+ if http_client is None:
124
+ raise ValueError(f"llm node {node.label!r} requires an async HTTP client")
125
+
126
+ try:
127
+ response = await http_client.post(
128
+ url,
129
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
130
+ json=request_body,
131
+ timeout=node.config.get("timeout_sec", 60),
132
+ )
133
+ except httpx.RequestError as exc:
134
+ raise GroqLLMRequestFailedError(f"llm node {node.label!r} request failed: {exc}") from exc
135
+
136
+ if response.status_code >= 400:
137
+ raise GroqLLMBadStatusError(
138
+ _build_bad_status_message(node=node, status_code=response.status_code, response_body=response.text),
139
+ status_code=response.status_code,
140
+ response_body=response.text,
141
+ )
142
+
143
+ payload = _parse_response_json(node=node, body=response.text)
144
+ return _extract_response_text(node=node, payload=payload), _extract_response_metadata(payload)
145
+
146
+
147
+ async def _run_structured_output_with_repair(
148
+ *,
149
+ node: Node,
150
+ base_url: str,
151
+ api_key: str,
152
+ messages: list[dict[str, str]],
153
+ http_client: httpx.AsyncClient | None,
154
+ ) -> dict[str, Any]:
155
+ current_messages = list(messages)
156
+ attempts: list[dict[str, str]] = []
157
+ aggregated_usage = _zero_usage()
158
+ latest_metadata = {"usage": _zero_usage(), "finish_reason": None}
159
+
160
+ for attempt_index in range(_STRUCTURED_OUTPUT_REPAIR_RETRIES + 1):
161
+ raw_text, metadata = await _request_raw_text(
162
+ node=node,
163
+ base_url=base_url,
164
+ api_key=api_key,
165
+ messages=current_messages,
166
+ http_client=http_client,
167
+ )
168
+ aggregated_usage = _merge_usage(aggregated_usage, metadata["usage"])
169
+ latest_metadata = metadata
170
+ try:
171
+ structured_output = _parse_structured_output(node=node, raw_text=raw_text)
172
+ _validate_schema(node=node, schema=node.config["json_schema"], value=structured_output, path="$")
173
+ return _build_llm_output(
174
+ text=raw_text,
175
+ structured_output=structured_output,
176
+ usage=aggregated_usage,
177
+ finish_reason=latest_metadata["finish_reason"],
178
+ attempt_count=attempt_index + 1,
179
+ )
180
+ except ValueError as exc:
181
+ attempts.append(
182
+ {
183
+ "attempt": str(attempt_index + 1),
184
+ "error": str(exc),
185
+ "raw_text": raw_text,
186
+ }
187
+ )
188
+ if attempt_index >= _STRUCTURED_OUTPUT_REPAIR_RETRIES:
189
+ raise GroqLLMStructuredOutputError(
190
+ f"{str(exc)} after {attempt_index + 1} attempts",
191
+ attempts=attempts,
192
+ ) from exc
193
+ current_messages = current_messages + [_build_repair_message(validation_error=str(exc), previous_output=raw_text)]
194
+
195
+ raise AssertionError("structured output repair loop must return or raise")
196
+
197
+
198
+ def _build_llm_output(
199
+ *,
200
+ text: str,
201
+ structured_output: Any,
202
+ usage: dict[str, int],
203
+ finish_reason: str | None,
204
+ attempt_count: int,
205
+ ) -> dict[str, Any]:
206
+ output = {
207
+ "text": text,
208
+ "usage": usage,
209
+ "finish_reason": finish_reason,
210
+ "attempt_count": attempt_count,
211
+ "safety": None,
212
+ "provider_details": {"provider": "groq"},
213
+ }
214
+ if structured_output is not None:
215
+ output["structured_output"] = structured_output
216
+ return output
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from dify_player.exceptions import XAILLMBadStatusError, XAILLMRequestFailedError, XAILLMStructuredOutputError
9
+ from dify_player.models import Node
10
+ from dify_player.nodes.llm_azure_chat import (
11
+ _build_bad_status_message,
12
+ _build_repair_message,
13
+ _extract_response_metadata,
14
+ _extract_response_text,
15
+ _merge_usage,
16
+ _parse_response_json,
17
+ _parse_structured_output,
18
+ _validate_schema,
19
+ _zero_usage,
20
+ )
21
+ from dify_player.value_renderer import render_value
22
+
23
+ _DEFAULT_BASE_URL = "https://api.x.ai/v1"
24
+ _STRUCTURED_OUTPUT_REPAIR_RETRIES = 1
25
+
26
+
27
+ async def run(
28
+ *,
29
+ node: Node,
30
+ workflow_inputs: dict[str, Any],
31
+ node_outputs: dict[str, dict[str, Any]],
32
+ resolved_inputs: dict[str, Any],
33
+ http_client: httpx.AsyncClient | None = None,
34
+ ) -> dict[str, Any]:
35
+ _ = resolved_inputs
36
+ api_key = _require_env("XAI_API_KEY", node=node)
37
+ base_url = os.environ.get("XAI_API_BASE_URL", _DEFAULT_BASE_URL)
38
+
39
+ messages = render_value(
40
+ node.config["messages"],
41
+ {"inputs": workflow_inputs, "nodes": node_outputs},
42
+ location=f"llm node {node.label!r} config.messages",
43
+ )
44
+ if node.config["response_format"] == "json_schema":
45
+ return await _run_structured_output_with_repair(
46
+ node=node,
47
+ base_url=base_url,
48
+ api_key=api_key,
49
+ messages=messages,
50
+ http_client=http_client,
51
+ )
52
+
53
+ raw_text, metadata = await _request_raw_text(
54
+ node=node,
55
+ base_url=base_url,
56
+ api_key=api_key,
57
+ messages=messages,
58
+ http_client=http_client,
59
+ )
60
+ return _build_llm_output(
61
+ text=raw_text,
62
+ structured_output=None,
63
+ usage=metadata["usage"],
64
+ finish_reason=metadata["finish_reason"],
65
+ attempt_count=1,
66
+ )
67
+
68
+
69
+ def _require_env(name: str, *, node: Node) -> str:
70
+ value = os.environ.get(name)
71
+ if value:
72
+ return value
73
+ raise ValueError(f"llm node {node.label!r} requires environment variable {name}")
74
+
75
+
76
+ def _build_request_body(*, node: Node, messages: Any) -> dict[str, Any]:
77
+ if not isinstance(messages, list):
78
+ raise ValueError(f"llm node {node.label!r} config.messages must render to an array")
79
+
80
+ request_messages: list[dict[str, str]] = []
81
+ for index, message in enumerate(messages):
82
+ if not isinstance(message, dict):
83
+ raise ValueError(f"llm node {node.label!r} config.messages[{index}] must be an object")
84
+ role = message.get("role")
85
+ content = message.get("content")
86
+ if not isinstance(role, str) or not role:
87
+ raise ValueError(f"llm node {node.label!r} config.messages[{index}].role must be a non-empty string")
88
+ if not isinstance(content, str):
89
+ raise ValueError(f"llm node {node.label!r} config.messages[{index}].content must be a string")
90
+ request_messages.append({"role": role, "content": content})
91
+
92
+ body: dict[str, Any] = {
93
+ "model": node.config["model"],
94
+ "messages": request_messages,
95
+ }
96
+ for key in ("temperature", "top_p", "max_tokens"):
97
+ if key in node.config:
98
+ body[key] = node.config[key]
99
+
100
+ if node.config["response_format"] == "json_schema":
101
+ body["response_format"] = {
102
+ "type": "json_schema",
103
+ "json_schema": {
104
+ "name": "structured_output",
105
+ "schema": node.config["json_schema"],
106
+ },
107
+ }
108
+
109
+ return body
110
+
111
+
112
+ async def _request_raw_text(
113
+ *,
114
+ node: Node,
115
+ base_url: str,
116
+ api_key: str,
117
+ messages: list[dict[str, str]],
118
+ http_client: httpx.AsyncClient | None,
119
+ ) -> tuple[str, dict[str, Any]]:
120
+ request_body = _build_request_body(node=node, messages=messages)
121
+ url = f"{base_url.rstrip('/')}/chat/completions"
122
+
123
+ if http_client is None:
124
+ raise ValueError(f"llm node {node.label!r} requires an async HTTP client")
125
+
126
+ try:
127
+ response = await http_client.post(
128
+ url,
129
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
130
+ json=request_body,
131
+ timeout=node.config.get("timeout_sec", 60),
132
+ )
133
+ except httpx.RequestError as exc:
134
+ raise XAILLMRequestFailedError(f"llm node {node.label!r} request failed: {exc}") from exc
135
+
136
+ if response.status_code >= 400:
137
+ raise XAILLMBadStatusError(
138
+ _build_bad_status_message(node=node, status_code=response.status_code, response_body=response.text),
139
+ status_code=response.status_code,
140
+ response_body=response.text,
141
+ )
142
+
143
+ payload = _parse_response_json(node=node, body=response.text)
144
+ return _extract_response_text(node=node, payload=payload), _extract_response_metadata(payload)
145
+
146
+
147
+ async def _run_structured_output_with_repair(
148
+ *,
149
+ node: Node,
150
+ base_url: str,
151
+ api_key: str,
152
+ messages: list[dict[str, str]],
153
+ http_client: httpx.AsyncClient | None,
154
+ ) -> dict[str, Any]:
155
+ current_messages = list(messages)
156
+ attempts: list[dict[str, str]] = []
157
+ aggregated_usage = _zero_usage()
158
+ latest_metadata = {"usage": _zero_usage(), "finish_reason": None}
159
+
160
+ for attempt_index in range(_STRUCTURED_OUTPUT_REPAIR_RETRIES + 1):
161
+ raw_text, metadata = await _request_raw_text(
162
+ node=node,
163
+ base_url=base_url,
164
+ api_key=api_key,
165
+ messages=current_messages,
166
+ http_client=http_client,
167
+ )
168
+ aggregated_usage = _merge_usage(aggregated_usage, metadata["usage"])
169
+ latest_metadata = metadata
170
+ try:
171
+ structured_output = _parse_structured_output(node=node, raw_text=raw_text)
172
+ _validate_schema(node=node, schema=node.config["json_schema"], value=structured_output, path="$")
173
+ return _build_llm_output(
174
+ text=raw_text,
175
+ structured_output=structured_output,
176
+ usage=aggregated_usage,
177
+ finish_reason=latest_metadata["finish_reason"],
178
+ attempt_count=attempt_index + 1,
179
+ )
180
+ except ValueError as exc:
181
+ attempts.append(
182
+ {
183
+ "attempt": str(attempt_index + 1),
184
+ "error": str(exc),
185
+ "raw_text": raw_text,
186
+ }
187
+ )
188
+ if attempt_index >= _STRUCTURED_OUTPUT_REPAIR_RETRIES:
189
+ raise XAILLMStructuredOutputError(
190
+ f"{str(exc)} after {attempt_index + 1} attempts",
191
+ attempts=attempts,
192
+ ) from exc
193
+ current_messages = current_messages + [_build_repair_message(validation_error=str(exc), previous_output=raw_text)]
194
+
195
+ raise AssertionError("structured output repair loop must return or raise")
196
+
197
+
198
+ def _build_llm_output(
199
+ *,
200
+ text: str,
201
+ structured_output: Any,
202
+ usage: dict[str, int],
203
+ finish_reason: str | None,
204
+ attempt_count: int,
205
+ ) -> dict[str, Any]:
206
+ output = {
207
+ "text": text,
208
+ "usage": usage,
209
+ "finish_reason": finish_reason,
210
+ "attempt_count": attempt_count,
211
+ "safety": None,
212
+ "provider_details": {"provider": "xai"},
213
+ }
214
+ if structured_output is not None:
215
+ output["structured_output"] = structured_output
216
+ return output