dify-player 0.3.0__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.
- dify_player-0.3.2/MANIFEST.in +2 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/PKG-INFO +1 -1
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/__init__.py +1 -1
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/if_else.py +3 -1
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/llm.py +16 -3
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/exceptions.py +42 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/input_resolver.py +8 -2
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/models.py +13 -1
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/__init__.py +4 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/if_else.py +10 -0
- dify_player-0.3.2/dify_player/nodes/llm_groq_chat.py +216 -0
- dify_player-0.3.2/dify_player/nodes/llm_xai_chat.py +216 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/plan_loader.py +23 -23
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/runtime.py +18 -3
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/value_renderer.py +21 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player.egg-info/PKG-INFO +1 -1
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player.egg-info/SOURCES.txt +4 -7
- {dify_player-0.3.0 → dify_player-0.3.2}/pyproject.toml +1 -1
- dify_player-0.3.0/tests/test_assigner.py +0 -301
- dify_player-0.3.0/tests/test_cli.py +0 -392
- dify_player-0.3.0/tests/test_dify_workflow_importer.py +0 -1497
- dify_player-0.3.0/tests/test_llm_cache.py +0 -28
- dify_player-0.3.0/tests/test_runtime.py +0 -2537
- dify_player-0.3.0/tests/test_workflow_engine.py +0 -65
- {dify_player-0.3.0 → dify_player-0.3.2}/README.md +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/__main__.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/cli.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/__init__.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/graph_parser.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/http_body_converter.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/__init__.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/assigner.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/code.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/end.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/http_request.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/loop.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/start.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/template_transform.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/variable_aggregator.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/plan_serializer.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/reference_converter.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/workflow_loader.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/workflow_normalizer.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_workflow_importer.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/event_logger.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/llm_cache.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/assigner.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/code.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/end.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/http.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/llm_azure_chat.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/start.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/template.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/nodes/variable_aggregator.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/workflow_engine.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player/workflow_executor.py +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player.egg-info/dependency_links.txt +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player.egg-info/requires.txt +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/dify_player.egg-info/top_level.txt +0 -0
- {dify_player-0.3.0 → dify_player-0.3.2}/setup.cfg +0 -0
{dify_player-0.3.0 → dify_player-0.3.2}/dify_player/dify_importer/node_converters/if_else.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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=
|
|
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":
|
|
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 = {
|
|
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
|