dify-player 0.2.0__tar.gz → 0.3.1__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.2.0 → dify_player-0.3.1}/PKG-INFO +1 -1
- {dify_player-0.2.0 → dify_player-0.3.1}/README.md +38 -4
- dify_player-0.3.1/dify_player/__init__.py +8 -0
- dify_player-0.3.1/dify_player/llm_cache.py +45 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/runtime.py +122 -3
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/workflow_engine.py +13 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/workflow_executor.py +16 -1
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/PKG-INFO +1 -1
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/SOURCES.txt +2 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/pyproject.toml +1 -1
- dify_player-0.3.1/tests/test_llm_cache.py +28 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_runtime.py +388 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_workflow_engine.py +20 -0
- dify_player-0.2.0/dify_player/__init__.py +0 -7
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/__main__.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/cli.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/__init__.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/graph_parser.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/http_body_converter.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/__init__.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/assigner.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/code.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/end.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/http_request.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/if_else.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/llm.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/loop.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/start.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/template_transform.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/variable_aggregator.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/plan_serializer.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/reference_converter.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/workflow_loader.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/workflow_normalizer.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_workflow_importer.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/event_logger.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/exceptions.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/input_resolver.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/models.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/__init__.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/assigner.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/code.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/end.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/http.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/if_else.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/llm_azure_chat.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/start.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/template.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/variable_aggregator.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/plan_loader.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/value_renderer.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/dependency_links.txt +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/requires.txt +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/top_level.txt +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/setup.cfg +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_assigner.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_cli.py +0 -0
- {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_dify_workflow_importer.py +0 -0
|
@@ -19,14 +19,17 @@ pip install -e .
|
|
|
19
19
|
`run_plan_data(...)` は strict plan JSON 相当の dict をそのまま受け取れます。
|
|
20
20
|
|
|
21
21
|
```python
|
|
22
|
-
from dify_player import WorkflowEngine
|
|
22
|
+
from dify_player import NullLLMCacheStore, WorkflowEngine
|
|
23
23
|
from dify_player.models import to_data
|
|
24
24
|
|
|
25
25
|
engine = WorkflowEngine()
|
|
26
|
+
cache_store = NullLLMCacheStore()
|
|
26
27
|
|
|
27
28
|
result = await engine.run_plan_data(
|
|
28
29
|
plan_data=plan_data,
|
|
29
30
|
inputs=inputs,
|
|
31
|
+
llm_cache=False,
|
|
32
|
+
llm_cache_store=cache_store,
|
|
30
33
|
)
|
|
31
34
|
|
|
32
35
|
if result.status == "succeeded":
|
|
@@ -84,9 +87,9 @@ async def run_workflow(payload: dict) -> dict:
|
|
|
84
87
|
|
|
85
88
|
`WorkflowEngine` には次の async API があります。
|
|
86
89
|
|
|
87
|
-
- `await engine.run_plan_data(plan_data=..., inputs=..., run_id=None, logger=None, http_client=None)`
|
|
88
|
-
- `await engine.run_plan(plan=..., inputs=..., run_id=None, logger=None, http_client=None)`
|
|
89
|
-
- `await engine.run_compiled_plan(compiled_plan=..., inputs=..., run_id=None, logger=None, http_client=None)`
|
|
90
|
+
- `await engine.run_plan_data(plan_data=..., inputs=..., run_id=None, logger=None, http_client=None, llm_cache=False, llm_cache_store=None)`
|
|
91
|
+
- `await engine.run_plan(plan=..., inputs=..., run_id=None, logger=None, http_client=None, llm_cache=False, llm_cache_store=None)`
|
|
92
|
+
- `await engine.run_compiled_plan(compiled_plan=..., inputs=..., run_id=None, logger=None, http_client=None, llm_cache=False, llm_cache_store=None)`
|
|
90
93
|
|
|
91
94
|
使い分けは次のとおりです。
|
|
92
95
|
|
|
@@ -94,6 +97,37 @@ async def run_workflow(payload: dict) -> dict:
|
|
|
94
97
|
- `run_plan`: すでに `Plan` オブジェクトを組み立てている内部利用向け
|
|
95
98
|
- `run_compiled_plan`: 同じ plan を何度も実行する用途向け
|
|
96
99
|
|
|
100
|
+
### LLM Cache Mode
|
|
101
|
+
|
|
102
|
+
workflow 改善中だけ LLM 応答を再利用したい場合は、`llm_cache=True` と cache store を渡します。
|
|
103
|
+
cache key は `node.kind`、`node.config`、`resolved_inputs`、実際に送る `messages` のレンダリング結果を正規化した内容から作られます。
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from dify_player import WorkflowEngine
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MyLLMCacheStore:
|
|
110
|
+
def get(self, key: str) -> dict | None:
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
def put(self, key: str, value: dict) -> None:
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
engine = WorkflowEngine()
|
|
118
|
+
cache_store = MyLLMCacheStore()
|
|
119
|
+
|
|
120
|
+
result = await engine.run_plan_data(
|
|
121
|
+
plan_data=plan_data,
|
|
122
|
+
inputs=inputs,
|
|
123
|
+
llm_cache=True,
|
|
124
|
+
llm_cache_store=cache_store,
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
現在 cache 対象は `llm_azure_chat` ノードだけです。
|
|
129
|
+
Azure Blob などの実ストアはこのパッケージには含めず、呼び出し側から差し込む前提です。
|
|
130
|
+
|
|
97
131
|
### Logger And HTTP Client
|
|
98
132
|
|
|
99
133
|
`logger` を渡さない場合、ライブラリ利用時はファイルログを書きません。
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Minimal workflow runner package."""
|
|
2
|
+
|
|
3
|
+
from dify_player.llm_cache import LLMCacheStore, NullLLMCacheStore
|
|
4
|
+
from dify_player.workflow_engine import WorkflowEngine
|
|
5
|
+
|
|
6
|
+
__all__ = ["__version__", "LLMCacheStore", "NullLLMCacheStore", "WorkflowEngine"]
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.1"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMCacheStore(Protocol):
|
|
9
|
+
def get(self, key: str) -> dict[str, Any] | None: ...
|
|
10
|
+
|
|
11
|
+
def put(self, key: str, value: dict[str, Any]) -> None: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NullLLMCacheStore:
|
|
15
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
16
|
+
_ = key
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
def put(self, key: str, value: dict[str, Any]) -> None:
|
|
20
|
+
_ = key
|
|
21
|
+
_ = value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_llm_cache_key(
|
|
25
|
+
*,
|
|
26
|
+
node_kind: str,
|
|
27
|
+
node_config: dict[str, Any],
|
|
28
|
+
resolved_inputs: dict[str, Any],
|
|
29
|
+
extra: dict[str, Any] | None = None,
|
|
30
|
+
) -> str:
|
|
31
|
+
payload = {
|
|
32
|
+
"node_kind": node_kind,
|
|
33
|
+
"node_config": node_config,
|
|
34
|
+
"resolved_inputs": resolved_inputs,
|
|
35
|
+
}
|
|
36
|
+
if extra:
|
|
37
|
+
payload["extra"] = extra
|
|
38
|
+
|
|
39
|
+
normalized = json.dumps(
|
|
40
|
+
payload,
|
|
41
|
+
sort_keys=True,
|
|
42
|
+
ensure_ascii=False,
|
|
43
|
+
separators=(",", ":"),
|
|
44
|
+
)
|
|
45
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
+
from copy import deepcopy
|
|
4
5
|
from datetime import datetime, timezone
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -16,13 +17,23 @@ from dify_player.exceptions import (
|
|
|
16
17
|
UnsupportedNodeError,
|
|
17
18
|
)
|
|
18
19
|
from dify_player.input_resolver import resolve_node_inputs
|
|
20
|
+
from dify_player.llm_cache import LLMCacheStore, NullLLMCacheStore, build_llm_cache_key
|
|
19
21
|
from dify_player.models import CompiledLoop, CompiledPlan, Edge, Node, NodeResult, RunResult, RunState, WorkflowError
|
|
20
22
|
from dify_player.nodes import run_node
|
|
23
|
+
from dify_player.value_renderer import render_value
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
class WorkflowRuntime:
|
|
24
|
-
def __init__(
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
logger: WorkflowLogger,
|
|
30
|
+
*,
|
|
31
|
+
llm_cache_enabled: bool = False,
|
|
32
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
33
|
+
) -> None:
|
|
25
34
|
self.logger = logger
|
|
35
|
+
self.llm_cache_enabled = llm_cache_enabled
|
|
36
|
+
self.llm_cache_store = llm_cache_store if llm_cache_store is not None else NullLLMCacheStore()
|
|
26
37
|
|
|
27
38
|
async def run(
|
|
28
39
|
self,
|
|
@@ -153,7 +164,7 @@ class WorkflowRuntime:
|
|
|
153
164
|
http_client=http_client,
|
|
154
165
|
)
|
|
155
166
|
else:
|
|
156
|
-
output = await
|
|
167
|
+
output = await self._execute_node(
|
|
157
168
|
node=node,
|
|
158
169
|
workflow_inputs=state.inputs,
|
|
159
170
|
node_outputs=state.node_outputs,
|
|
@@ -363,7 +374,7 @@ class WorkflowRuntime:
|
|
|
363
374
|
)
|
|
364
375
|
|
|
365
376
|
try:
|
|
366
|
-
output = await
|
|
377
|
+
output = await self._execute_node(
|
|
367
378
|
node=body_node,
|
|
368
379
|
workflow_inputs=state.inputs,
|
|
369
380
|
node_outputs=merged_node_outputs,
|
|
@@ -410,6 +421,114 @@ class WorkflowRuntime:
|
|
|
410
421
|
)
|
|
411
422
|
return None
|
|
412
423
|
|
|
424
|
+
async def _execute_node(
|
|
425
|
+
self,
|
|
426
|
+
*,
|
|
427
|
+
node: Node,
|
|
428
|
+
workflow_inputs: dict[str, Any],
|
|
429
|
+
node_outputs: dict[str, dict[str, Any]],
|
|
430
|
+
resolved_inputs: dict[str, Any],
|
|
431
|
+
http_client: httpx.AsyncClient,
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
if self._should_use_llm_cache(node=node):
|
|
434
|
+
cached_output = self._load_cached_llm_output(
|
|
435
|
+
node=node,
|
|
436
|
+
workflow_inputs=workflow_inputs,
|
|
437
|
+
node_outputs=node_outputs,
|
|
438
|
+
resolved_inputs=resolved_inputs,
|
|
439
|
+
)
|
|
440
|
+
if cached_output is not None:
|
|
441
|
+
cached_output["cache_hit"] = True
|
|
442
|
+
return cached_output
|
|
443
|
+
|
|
444
|
+
output = await run_node(
|
|
445
|
+
node=node,
|
|
446
|
+
workflow_inputs=workflow_inputs,
|
|
447
|
+
node_outputs=node_outputs,
|
|
448
|
+
resolved_inputs=resolved_inputs,
|
|
449
|
+
http_client=http_client,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if not self._should_use_llm_cache(node=node):
|
|
453
|
+
return output
|
|
454
|
+
|
|
455
|
+
cached_output = deepcopy(output)
|
|
456
|
+
cached_output["cache_hit"] = False
|
|
457
|
+
self._store_cached_llm_output(
|
|
458
|
+
node=node,
|
|
459
|
+
workflow_inputs=workflow_inputs,
|
|
460
|
+
node_outputs=node_outputs,
|
|
461
|
+
resolved_inputs=resolved_inputs,
|
|
462
|
+
output=cached_output,
|
|
463
|
+
)
|
|
464
|
+
return cached_output
|
|
465
|
+
|
|
466
|
+
def _should_use_llm_cache(self, *, node: Node) -> bool:
|
|
467
|
+
return self.llm_cache_enabled and node.kind == "llm_azure_chat"
|
|
468
|
+
|
|
469
|
+
def _load_cached_llm_output(
|
|
470
|
+
self,
|
|
471
|
+
*,
|
|
472
|
+
node: Node,
|
|
473
|
+
workflow_inputs: dict[str, Any],
|
|
474
|
+
node_outputs: dict[str, dict[str, Any]],
|
|
475
|
+
resolved_inputs: dict[str, Any],
|
|
476
|
+
) -> dict[str, Any] | None:
|
|
477
|
+
cached = self.llm_cache_store.get(
|
|
478
|
+
self._build_llm_cache_key(
|
|
479
|
+
node=node,
|
|
480
|
+
workflow_inputs=workflow_inputs,
|
|
481
|
+
node_outputs=node_outputs,
|
|
482
|
+
resolved_inputs=resolved_inputs,
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
if cached is None:
|
|
486
|
+
return None
|
|
487
|
+
return deepcopy(cached)
|
|
488
|
+
|
|
489
|
+
def _store_cached_llm_output(
|
|
490
|
+
self,
|
|
491
|
+
*,
|
|
492
|
+
node: Node,
|
|
493
|
+
workflow_inputs: dict[str, Any],
|
|
494
|
+
node_outputs: dict[str, dict[str, Any]],
|
|
495
|
+
resolved_inputs: dict[str, Any],
|
|
496
|
+
output: dict[str, Any],
|
|
497
|
+
) -> None:
|
|
498
|
+
self.llm_cache_store.put(
|
|
499
|
+
self._build_llm_cache_key(
|
|
500
|
+
node=node,
|
|
501
|
+
workflow_inputs=workflow_inputs,
|
|
502
|
+
node_outputs=node_outputs,
|
|
503
|
+
resolved_inputs=resolved_inputs,
|
|
504
|
+
),
|
|
505
|
+
deepcopy(output),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _build_llm_cache_key(
|
|
509
|
+
self,
|
|
510
|
+
*,
|
|
511
|
+
node: Node,
|
|
512
|
+
workflow_inputs: dict[str, Any],
|
|
513
|
+
node_outputs: dict[str, dict[str, Any]],
|
|
514
|
+
resolved_inputs: dict[str, Any],
|
|
515
|
+
) -> str:
|
|
516
|
+
extra: dict[str, Any] | None = None
|
|
517
|
+
if node.kind == "llm_azure_chat":
|
|
518
|
+
extra = {
|
|
519
|
+
"rendered_messages": render_value(
|
|
520
|
+
node.config["messages"],
|
|
521
|
+
{"inputs": workflow_inputs, "nodes": node_outputs},
|
|
522
|
+
location=f"llm cache key for node {node.label!r} config.messages",
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
return build_llm_cache_key(
|
|
526
|
+
node_kind=node.kind,
|
|
527
|
+
node_config=node.config,
|
|
528
|
+
resolved_inputs=resolved_inputs,
|
|
529
|
+
extra=extra,
|
|
530
|
+
)
|
|
531
|
+
|
|
413
532
|
def _build_loop_body_node_outputs(
|
|
414
533
|
self,
|
|
415
534
|
*,
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
8
|
from dify_player.event_logger import NullEventLogger, WorkflowLogger
|
|
9
|
+
from dify_player.llm_cache import LLMCacheStore
|
|
9
10
|
from dify_player.models import CompiledPlan, Plan, RunResult
|
|
10
11
|
from dify_player.plan_loader import parse_plan
|
|
11
12
|
from dify_player.workflow_executor import WorkflowExecutor
|
|
@@ -23,6 +24,8 @@ class WorkflowEngine:
|
|
|
23
24
|
run_id: str | None = None,
|
|
24
25
|
logger: WorkflowLogger | None = None,
|
|
25
26
|
http_client: httpx.AsyncClient | None = None,
|
|
27
|
+
llm_cache: bool = False,
|
|
28
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
26
29
|
) -> RunResult:
|
|
27
30
|
effective_logger, owns_logger = _resolve_logger(run_id=run_id, logger=logger)
|
|
28
31
|
try:
|
|
@@ -31,6 +34,8 @@ class WorkflowEngine:
|
|
|
31
34
|
inputs=inputs,
|
|
32
35
|
logger=effective_logger,
|
|
33
36
|
http_client=http_client,
|
|
37
|
+
llm_cache=llm_cache,
|
|
38
|
+
llm_cache_store=llm_cache_store,
|
|
34
39
|
)
|
|
35
40
|
finally:
|
|
36
41
|
if owns_logger:
|
|
@@ -44,6 +49,8 @@ class WorkflowEngine:
|
|
|
44
49
|
run_id: str | None = None,
|
|
45
50
|
logger: WorkflowLogger | None = None,
|
|
46
51
|
http_client: httpx.AsyncClient | None = None,
|
|
52
|
+
llm_cache: bool = False,
|
|
53
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
47
54
|
) -> RunResult:
|
|
48
55
|
effective_logger, owns_logger = _resolve_logger(run_id=run_id, logger=logger)
|
|
49
56
|
try:
|
|
@@ -52,6 +59,8 @@ class WorkflowEngine:
|
|
|
52
59
|
inputs=inputs,
|
|
53
60
|
logger=effective_logger,
|
|
54
61
|
http_client=http_client,
|
|
62
|
+
llm_cache=llm_cache,
|
|
63
|
+
llm_cache_store=llm_cache_store,
|
|
55
64
|
)
|
|
56
65
|
finally:
|
|
57
66
|
if owns_logger:
|
|
@@ -65,6 +74,8 @@ class WorkflowEngine:
|
|
|
65
74
|
run_id: str | None = None,
|
|
66
75
|
logger: WorkflowLogger | None = None,
|
|
67
76
|
http_client: httpx.AsyncClient | None = None,
|
|
77
|
+
llm_cache: bool = False,
|
|
78
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
68
79
|
) -> RunResult:
|
|
69
80
|
plan = parse_plan(plan_data)
|
|
70
81
|
return await self.run_plan(
|
|
@@ -73,6 +84,8 @@ class WorkflowEngine:
|
|
|
73
84
|
run_id=run_id,
|
|
74
85
|
logger=logger,
|
|
75
86
|
http_client=http_client,
|
|
87
|
+
llm_cache=llm_cache,
|
|
88
|
+
llm_cache_store=llm_cache_store,
|
|
76
89
|
)
|
|
77
90
|
|
|
78
91
|
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
import httpx
|
|
11
11
|
from dify_player.event_logger import EventLogger, WorkflowLogger
|
|
12
12
|
from dify_player.exceptions import PlanValidationError
|
|
13
|
+
from dify_player.llm_cache import LLMCacheStore
|
|
13
14
|
from dify_player.models import CompiledPlan, Plan, RunResult, WorkflowError
|
|
14
15
|
from dify_player.plan_loader import compile_plan, load_input_data, load_plan_definition
|
|
15
16
|
from dify_player.runtime import WorkflowRuntime
|
|
@@ -30,8 +31,14 @@ class WorkflowExecutor:
|
|
|
30
31
|
started_at: str | None = None,
|
|
31
32
|
started_monotonic: float | None = None,
|
|
32
33
|
http_client: httpx.AsyncClient | None = None,
|
|
34
|
+
llm_cache: bool = False,
|
|
35
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
33
36
|
) -> RunResult:
|
|
34
|
-
runtime = WorkflowRuntime(
|
|
37
|
+
runtime = WorkflowRuntime(
|
|
38
|
+
logger=logger,
|
|
39
|
+
llm_cache_enabled=llm_cache,
|
|
40
|
+
llm_cache_store=llm_cache_store,
|
|
41
|
+
)
|
|
35
42
|
return await runtime.run(
|
|
36
43
|
compiled_plan=compiled_plan,
|
|
37
44
|
inputs=inputs,
|
|
@@ -49,6 +56,8 @@ class WorkflowExecutor:
|
|
|
49
56
|
started_at: str | None = None,
|
|
50
57
|
started_monotonic: float | None = None,
|
|
51
58
|
http_client: httpx.AsyncClient | None = None,
|
|
59
|
+
llm_cache: bool = False,
|
|
60
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
52
61
|
) -> RunResult:
|
|
53
62
|
try:
|
|
54
63
|
compiled_plan = compile_plan(plan)
|
|
@@ -68,6 +77,8 @@ class WorkflowExecutor:
|
|
|
68
77
|
started_at=started_at,
|
|
69
78
|
started_monotonic=started_monotonic,
|
|
70
79
|
http_client=http_client,
|
|
80
|
+
llm_cache=llm_cache,
|
|
81
|
+
llm_cache_store=llm_cache_store,
|
|
71
82
|
)
|
|
72
83
|
|
|
73
84
|
async def run_plan_path(
|
|
@@ -77,6 +88,8 @@ class WorkflowExecutor:
|
|
|
77
88
|
input_path: Path,
|
|
78
89
|
log_path: Path | None = None,
|
|
79
90
|
http_client: httpx.AsyncClient | None = None,
|
|
91
|
+
llm_cache: bool = False,
|
|
92
|
+
llm_cache_store: LLMCacheStore | None = None,
|
|
80
93
|
) -> RunResult:
|
|
81
94
|
run_id = uuid.uuid4().hex
|
|
82
95
|
resolved_log_path = log_path if log_path is not None else build_default_log_path(run_id)
|
|
@@ -127,6 +140,8 @@ class WorkflowExecutor:
|
|
|
127
140
|
started_at=started_at,
|
|
128
141
|
started_monotonic=started_monotonic,
|
|
129
142
|
http_client=http_client,
|
|
143
|
+
llm_cache=llm_cache,
|
|
144
|
+
llm_cache_store=llm_cache_store,
|
|
130
145
|
)
|
|
131
146
|
|
|
132
147
|
if result.status == "failed":
|
|
@@ -7,6 +7,7 @@ dify_player/dify_workflow_importer.py
|
|
|
7
7
|
dify_player/event_logger.py
|
|
8
8
|
dify_player/exceptions.py
|
|
9
9
|
dify_player/input_resolver.py
|
|
10
|
+
dify_player/llm_cache.py
|
|
10
11
|
dify_player/models.py
|
|
11
12
|
dify_player/plan_loader.py
|
|
12
13
|
dify_player/runtime.py
|
|
@@ -49,5 +50,6 @@ dify_player/nodes/variable_aggregator.py
|
|
|
49
50
|
tests/test_assigner.py
|
|
50
51
|
tests/test_cli.py
|
|
51
52
|
tests/test_dify_workflow_importer.py
|
|
53
|
+
tests/test_llm_cache.py
|
|
52
54
|
tests/test_runtime.py
|
|
53
55
|
tests/test_workflow_engine.py
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from dify_player.llm_cache import NullLLMCacheStore, build_llm_cache_key
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LLMCacheTestCase(unittest.TestCase):
|
|
9
|
+
def test_build_llm_cache_key_is_stable_for_equivalent_dict_order(self) -> None:
|
|
10
|
+
first = build_llm_cache_key(
|
|
11
|
+
node_kind="llm_azure_chat",
|
|
12
|
+
node_config={"model": "gpt-4o", "response_format": "text"},
|
|
13
|
+
resolved_inputs={"source_text": "hello", "language": "ja"},
|
|
14
|
+
)
|
|
15
|
+
second = build_llm_cache_key(
|
|
16
|
+
node_kind="llm_azure_chat",
|
|
17
|
+
node_config={"response_format": "text", "model": "gpt-4o"},
|
|
18
|
+
resolved_inputs={"language": "ja", "source_text": "hello"},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
self.assertEqual(first, second)
|
|
22
|
+
|
|
23
|
+
def test_null_llm_cache_store_is_noop(self) -> None:
|
|
24
|
+
store = NullLLMCacheStore()
|
|
25
|
+
|
|
26
|
+
self.assertIsNone(store.get("missing"))
|
|
27
|
+
store.put("key", {"text": "hello"})
|
|
28
|
+
self.assertIsNone(store.get("key"))
|
|
@@ -21,6 +21,17 @@ def _run_runtime(runtime: WorkflowRuntime, *, compiled_plan, inputs):
|
|
|
21
21
|
return asyncio.run(runtime.run(compiled_plan=compiled_plan, inputs=inputs))
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class MemoryLLMCacheStore:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.values: dict[str, dict] = {}
|
|
27
|
+
|
|
28
|
+
def get(self, key: str) -> dict | None:
|
|
29
|
+
return self.values.get(key)
|
|
30
|
+
|
|
31
|
+
def put(self, key: str, value: dict) -> None:
|
|
32
|
+
self.values[key] = value
|
|
33
|
+
|
|
34
|
+
|
|
24
35
|
class RuntimeTestCase(unittest.TestCase):
|
|
25
36
|
def test_code_node_executes_python_and_returns_declared_outputs(self) -> None:
|
|
26
37
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
@@ -706,6 +717,383 @@ class RuntimeTestCase(unittest.TestCase):
|
|
|
706
717
|
},
|
|
707
718
|
)
|
|
708
719
|
|
|
720
|
+
def test_llm_cache_reuses_identical_llm_response(self) -> None:
|
|
721
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
722
|
+
plan_path = Path(tmpdir) / "plan.json"
|
|
723
|
+
plan_path.write_text(
|
|
724
|
+
json.dumps(
|
|
725
|
+
{
|
|
726
|
+
"nodes": [
|
|
727
|
+
{"id": "start", "kind": "start"},
|
|
728
|
+
{
|
|
729
|
+
"id": "classify",
|
|
730
|
+
"kind": "llm_azure_chat",
|
|
731
|
+
"config": {
|
|
732
|
+
"model": "gpt-4o",
|
|
733
|
+
"messages": [{"role": "user", "content": "Classify {{ inputs.source_text }}"}],
|
|
734
|
+
"response_format": "text",
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
"id": "end",
|
|
739
|
+
"kind": "end",
|
|
740
|
+
"inputs": {"text": "{{ nodes.classify.text }}"},
|
|
741
|
+
"config": {"outputs": {"text": "{{ inputs.text }}"}},
|
|
742
|
+
},
|
|
743
|
+
],
|
|
744
|
+
"edges": [
|
|
745
|
+
{"from": "start", "to": "classify"},
|
|
746
|
+
{"from": "classify", "to": "end"},
|
|
747
|
+
],
|
|
748
|
+
}
|
|
749
|
+
),
|
|
750
|
+
encoding="utf-8",
|
|
751
|
+
)
|
|
752
|
+
compiled_plan = load_plan(plan_path)
|
|
753
|
+
response = Mock(status_code=200)
|
|
754
|
+
response.text = json.dumps({"choices": [{"message": {"content": "SNS"}}]})
|
|
755
|
+
cache_store = MemoryLLMCacheStore()
|
|
756
|
+
|
|
757
|
+
with patch.dict(
|
|
758
|
+
os.environ,
|
|
759
|
+
{
|
|
760
|
+
"AZURE_OPENAI_ENDPOINT": "https://example.openai.azure.com",
|
|
761
|
+
"AZURE_OPENAI_API_KEY": "secret",
|
|
762
|
+
"AZURE_OPENAI_API_VERSION": "2024-10-21",
|
|
763
|
+
},
|
|
764
|
+
clear=False,
|
|
765
|
+
):
|
|
766
|
+
with patch(
|
|
767
|
+
"dify_player.nodes.llm_azure_chat.httpx.AsyncClient.post",
|
|
768
|
+
new_callable=AsyncMock,
|
|
769
|
+
return_value=response,
|
|
770
|
+
) as mock_post:
|
|
771
|
+
for index, expected_cache_hit in enumerate((False, True), start=1):
|
|
772
|
+
logger = EventLogger(log_path=Path(tmpdir) / f"run-{index}.jsonl", run_id=f"llm-cache-{index}")
|
|
773
|
+
runtime = WorkflowRuntime(
|
|
774
|
+
logger=logger,
|
|
775
|
+
llm_cache_enabled=True,
|
|
776
|
+
llm_cache_store=cache_store,
|
|
777
|
+
)
|
|
778
|
+
try:
|
|
779
|
+
result = _run_runtime(runtime, compiled_plan=compiled_plan, inputs={"source_text": "hello"})
|
|
780
|
+
finally:
|
|
781
|
+
logger.close()
|
|
782
|
+
|
|
783
|
+
self.assertEqual(result.status, "succeeded")
|
|
784
|
+
self.assertEqual(result.outputs, {"text": "SNS"})
|
|
785
|
+
self.assertEqual(result.nodes["classify"].outputs["cache_hit"], expected_cache_hit)
|
|
786
|
+
|
|
787
|
+
self.assertEqual(mock_post.call_count, 1)
|
|
788
|
+
self.assertEqual(len(cache_store.values), 1)
|
|
789
|
+
|
|
790
|
+
def test_llm_cache_disabled_calls_llm_every_time(self) -> None:
|
|
791
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
792
|
+
plan_path = Path(tmpdir) / "plan.json"
|
|
793
|
+
plan_path.write_text(
|
|
794
|
+
json.dumps(
|
|
795
|
+
{
|
|
796
|
+
"nodes": [
|
|
797
|
+
{"id": "start", "kind": "start"},
|
|
798
|
+
{
|
|
799
|
+
"id": "classify",
|
|
800
|
+
"kind": "llm_azure_chat",
|
|
801
|
+
"config": {
|
|
802
|
+
"model": "gpt-4o",
|
|
803
|
+
"messages": [{"role": "user", "content": "Classify {{ inputs.source_text }}"}],
|
|
804
|
+
"response_format": "text",
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
"id": "end",
|
|
809
|
+
"kind": "end",
|
|
810
|
+
"inputs": {"text": "{{ nodes.classify.text }}"},
|
|
811
|
+
"config": {"outputs": {"text": "{{ inputs.text }}"}},
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
"edges": [
|
|
815
|
+
{"from": "start", "to": "classify"},
|
|
816
|
+
{"from": "classify", "to": "end"},
|
|
817
|
+
],
|
|
818
|
+
}
|
|
819
|
+
),
|
|
820
|
+
encoding="utf-8",
|
|
821
|
+
)
|
|
822
|
+
compiled_plan = load_plan(plan_path)
|
|
823
|
+
first_response = Mock(status_code=200)
|
|
824
|
+
first_response.text = json.dumps({"choices": [{"message": {"content": "First"}}]})
|
|
825
|
+
second_response = Mock(status_code=200)
|
|
826
|
+
second_response.text = json.dumps({"choices": [{"message": {"content": "Second"}}]})
|
|
827
|
+
cache_store = MemoryLLMCacheStore()
|
|
828
|
+
|
|
829
|
+
with patch.dict(
|
|
830
|
+
os.environ,
|
|
831
|
+
{
|
|
832
|
+
"AZURE_OPENAI_ENDPOINT": "https://example.openai.azure.com",
|
|
833
|
+
"AZURE_OPENAI_API_KEY": "secret",
|
|
834
|
+
"AZURE_OPENAI_API_VERSION": "2024-10-21",
|
|
835
|
+
},
|
|
836
|
+
clear=False,
|
|
837
|
+
):
|
|
838
|
+
with patch(
|
|
839
|
+
"dify_player.nodes.llm_azure_chat.httpx.AsyncClient.post",
|
|
840
|
+
new_callable=AsyncMock,
|
|
841
|
+
side_effect=[first_response, second_response],
|
|
842
|
+
) as mock_post:
|
|
843
|
+
for index in range(2):
|
|
844
|
+
logger = EventLogger(log_path=Path(tmpdir) / f"disabled-{index}.jsonl", run_id=f"llm-disabled-{index}")
|
|
845
|
+
runtime = WorkflowRuntime(
|
|
846
|
+
logger=logger,
|
|
847
|
+
llm_cache_enabled=False,
|
|
848
|
+
llm_cache_store=cache_store,
|
|
849
|
+
)
|
|
850
|
+
try:
|
|
851
|
+
result = _run_runtime(runtime, compiled_plan=compiled_plan, inputs={"source_text": "hello"})
|
|
852
|
+
finally:
|
|
853
|
+
logger.close()
|
|
854
|
+
|
|
855
|
+
self.assertEqual(result.status, "succeeded")
|
|
856
|
+
self.assertNotIn("cache_hit", result.nodes["classify"].outputs)
|
|
857
|
+
|
|
858
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
859
|
+
self.assertEqual(cache_store.values, {})
|
|
860
|
+
|
|
861
|
+
def test_llm_cache_misses_when_resolved_inputs_change(self) -> None:
|
|
862
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
863
|
+
plan_path = Path(tmpdir) / "plan.json"
|
|
864
|
+
plan_path.write_text(
|
|
865
|
+
json.dumps(
|
|
866
|
+
{
|
|
867
|
+
"nodes": [
|
|
868
|
+
{"id": "start", "kind": "start"},
|
|
869
|
+
{
|
|
870
|
+
"id": "classify",
|
|
871
|
+
"kind": "llm_azure_chat",
|
|
872
|
+
"config": {
|
|
873
|
+
"model": "gpt-4o",
|
|
874
|
+
"messages": [{"role": "user", "content": "Classify {{ inputs.source_text }}"}],
|
|
875
|
+
"response_format": "text",
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
"id": "end",
|
|
880
|
+
"kind": "end",
|
|
881
|
+
"inputs": {"text": "{{ nodes.classify.text }}"},
|
|
882
|
+
"config": {"outputs": {"text": "{{ inputs.text }}"}},
|
|
883
|
+
},
|
|
884
|
+
],
|
|
885
|
+
"edges": [
|
|
886
|
+
{"from": "start", "to": "classify"},
|
|
887
|
+
{"from": "classify", "to": "end"},
|
|
888
|
+
],
|
|
889
|
+
}
|
|
890
|
+
),
|
|
891
|
+
encoding="utf-8",
|
|
892
|
+
)
|
|
893
|
+
compiled_plan = load_plan(plan_path)
|
|
894
|
+
first_response = Mock(status_code=200)
|
|
895
|
+
first_response.text = json.dumps({"choices": [{"message": {"content": "SNS"}}]})
|
|
896
|
+
second_response = Mock(status_code=200)
|
|
897
|
+
second_response.text = json.dumps({"choices": [{"message": {"content": "Email"}}]})
|
|
898
|
+
cache_store = MemoryLLMCacheStore()
|
|
899
|
+
|
|
900
|
+
with patch.dict(
|
|
901
|
+
os.environ,
|
|
902
|
+
{
|
|
903
|
+
"AZURE_OPENAI_ENDPOINT": "https://example.openai.azure.com",
|
|
904
|
+
"AZURE_OPENAI_API_KEY": "secret",
|
|
905
|
+
"AZURE_OPENAI_API_VERSION": "2024-10-21",
|
|
906
|
+
},
|
|
907
|
+
clear=False,
|
|
908
|
+
):
|
|
909
|
+
with patch(
|
|
910
|
+
"dify_player.nodes.llm_azure_chat.httpx.AsyncClient.post",
|
|
911
|
+
new_callable=AsyncMock,
|
|
912
|
+
side_effect=[first_response, second_response],
|
|
913
|
+
) as mock_post:
|
|
914
|
+
seen_texts: list[str] = []
|
|
915
|
+
for index, source_text in enumerate(("hello", "bye"), start=1):
|
|
916
|
+
logger = EventLogger(log_path=Path(tmpdir) / f"vary-{index}.jsonl", run_id=f"llm-vary-{index}")
|
|
917
|
+
runtime = WorkflowRuntime(
|
|
918
|
+
logger=logger,
|
|
919
|
+
llm_cache_enabled=True,
|
|
920
|
+
llm_cache_store=cache_store,
|
|
921
|
+
)
|
|
922
|
+
try:
|
|
923
|
+
result = _run_runtime(runtime, compiled_plan=compiled_plan, inputs={"source_text": source_text})
|
|
924
|
+
finally:
|
|
925
|
+
logger.close()
|
|
926
|
+
|
|
927
|
+
seen_texts.append(result.outputs["text"])
|
|
928
|
+
self.assertFalse(result.nodes["classify"].outputs["cache_hit"])
|
|
929
|
+
|
|
930
|
+
self.assertEqual(seen_texts, ["SNS", "Email"])
|
|
931
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
932
|
+
self.assertEqual(len(cache_store.values), 2)
|
|
933
|
+
|
|
934
|
+
def test_llm_cache_reuses_structured_output_repair_result(self) -> None:
|
|
935
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
936
|
+
plan_path = Path(tmpdir) / "plan.json"
|
|
937
|
+
plan_path.write_text(
|
|
938
|
+
json.dumps(
|
|
939
|
+
{
|
|
940
|
+
"nodes": [
|
|
941
|
+
{"id": "start", "kind": "start"},
|
|
942
|
+
{
|
|
943
|
+
"id": "detect",
|
|
944
|
+
"kind": "llm_azure_chat",
|
|
945
|
+
"config": {
|
|
946
|
+
"model": "gpt-4o",
|
|
947
|
+
"messages": [{"role": "user", "content": "Detect"}],
|
|
948
|
+
"response_format": "json_schema",
|
|
949
|
+
"json_schema": {
|
|
950
|
+
"type": "object",
|
|
951
|
+
"properties": {"language": {"type": "string"}},
|
|
952
|
+
"required": ["language"],
|
|
953
|
+
"additionalProperties": False,
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
"id": "end",
|
|
959
|
+
"kind": "end",
|
|
960
|
+
"inputs": {"language": "{{ nodes.detect.structured_output.language }}"},
|
|
961
|
+
"config": {"outputs": {"language": "{{ inputs.language }}"}},
|
|
962
|
+
},
|
|
963
|
+
],
|
|
964
|
+
"edges": [
|
|
965
|
+
{"from": "start", "to": "detect"},
|
|
966
|
+
{"from": "detect", "to": "end"},
|
|
967
|
+
],
|
|
968
|
+
}
|
|
969
|
+
),
|
|
970
|
+
encoding="utf-8",
|
|
971
|
+
)
|
|
972
|
+
compiled_plan = load_plan(plan_path)
|
|
973
|
+
first_response = Mock(status_code=200)
|
|
974
|
+
first_response.text = json.dumps({"choices": [{"message": {"content": "{}"}}]})
|
|
975
|
+
second_response = Mock(status_code=200)
|
|
976
|
+
second_response.text = json.dumps({"choices": [{"message": {"content": '{"language":"Japanese"}'}}]})
|
|
977
|
+
cache_store = MemoryLLMCacheStore()
|
|
978
|
+
|
|
979
|
+
with patch.dict(
|
|
980
|
+
os.environ,
|
|
981
|
+
{
|
|
982
|
+
"AZURE_OPENAI_ENDPOINT": "https://example.openai.azure.com",
|
|
983
|
+
"AZURE_OPENAI_API_KEY": "secret",
|
|
984
|
+
"AZURE_OPENAI_API_VERSION": "2024-10-21",
|
|
985
|
+
},
|
|
986
|
+
clear=False,
|
|
987
|
+
):
|
|
988
|
+
with patch(
|
|
989
|
+
"dify_player.nodes.llm_azure_chat.httpx.AsyncClient.post",
|
|
990
|
+
new_callable=AsyncMock,
|
|
991
|
+
side_effect=[first_response, second_response],
|
|
992
|
+
) as mock_post:
|
|
993
|
+
for index, expected_cache_hit in enumerate((False, True), start=1):
|
|
994
|
+
logger = EventLogger(log_path=Path(tmpdir) / f"repair-{index}.jsonl", run_id=f"llm-repair-cache-{index}")
|
|
995
|
+
runtime = WorkflowRuntime(
|
|
996
|
+
logger=logger,
|
|
997
|
+
llm_cache_enabled=True,
|
|
998
|
+
llm_cache_store=cache_store,
|
|
999
|
+
)
|
|
1000
|
+
try:
|
|
1001
|
+
result = _run_runtime(runtime, compiled_plan=compiled_plan, inputs={})
|
|
1002
|
+
finally:
|
|
1003
|
+
logger.close()
|
|
1004
|
+
|
|
1005
|
+
self.assertEqual(result.status, "succeeded")
|
|
1006
|
+
self.assertEqual(result.outputs, {"language": "Japanese"})
|
|
1007
|
+
self.assertEqual(result.nodes["detect"].outputs["attempt_count"], 2)
|
|
1008
|
+
self.assertEqual(result.nodes["detect"].outputs["cache_hit"], expected_cache_hit)
|
|
1009
|
+
|
|
1010
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
1011
|
+
self.assertEqual(len(cache_store.values), 1)
|
|
1012
|
+
|
|
1013
|
+
def test_llm_cache_does_not_store_failed_run(self) -> None:
|
|
1014
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1015
|
+
plan_path = Path(tmpdir) / "plan.json"
|
|
1016
|
+
plan_path.write_text(
|
|
1017
|
+
json.dumps(
|
|
1018
|
+
{
|
|
1019
|
+
"nodes": [
|
|
1020
|
+
{"id": "start", "kind": "start"},
|
|
1021
|
+
{
|
|
1022
|
+
"id": "classify",
|
|
1023
|
+
"kind": "llm_azure_chat",
|
|
1024
|
+
"config": {
|
|
1025
|
+
"model": "gpt-4o",
|
|
1026
|
+
"messages": [{"role": "user", "content": "Classify"}],
|
|
1027
|
+
"response_format": "text",
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
"id": "end",
|
|
1032
|
+
"kind": "end",
|
|
1033
|
+
"inputs": {"text": "{{ nodes.classify.text }}"},
|
|
1034
|
+
"config": {"outputs": {"text": "{{ inputs.text }}"}},
|
|
1035
|
+
},
|
|
1036
|
+
],
|
|
1037
|
+
"edges": [
|
|
1038
|
+
{"from": "start", "to": "classify"},
|
|
1039
|
+
{"from": "classify", "to": "end"},
|
|
1040
|
+
],
|
|
1041
|
+
}
|
|
1042
|
+
),
|
|
1043
|
+
encoding="utf-8",
|
|
1044
|
+
)
|
|
1045
|
+
compiled_plan = load_plan(plan_path)
|
|
1046
|
+
bad_response = Mock(status_code=500)
|
|
1047
|
+
bad_response.text = json.dumps({"error": {"message": "server error"}})
|
|
1048
|
+
good_response = Mock(status_code=200)
|
|
1049
|
+
good_response.text = json.dumps({"choices": [{"message": {"content": "SNS"}}]})
|
|
1050
|
+
cache_store = MemoryLLMCacheStore()
|
|
1051
|
+
|
|
1052
|
+
with patch.dict(
|
|
1053
|
+
os.environ,
|
|
1054
|
+
{
|
|
1055
|
+
"AZURE_OPENAI_ENDPOINT": "https://example.openai.azure.com",
|
|
1056
|
+
"AZURE_OPENAI_API_KEY": "secret",
|
|
1057
|
+
"AZURE_OPENAI_API_VERSION": "2024-10-21",
|
|
1058
|
+
},
|
|
1059
|
+
clear=False,
|
|
1060
|
+
):
|
|
1061
|
+
with patch(
|
|
1062
|
+
"dify_player.nodes.llm_azure_chat.httpx.AsyncClient.post",
|
|
1063
|
+
new_callable=AsyncMock,
|
|
1064
|
+
side_effect=[bad_response, good_response],
|
|
1065
|
+
) as mock_post:
|
|
1066
|
+
logger = EventLogger(log_path=Path(tmpdir) / "failed-run.jsonl", run_id="llm-cache-failed")
|
|
1067
|
+
failed_runtime = WorkflowRuntime(
|
|
1068
|
+
logger=logger,
|
|
1069
|
+
llm_cache_enabled=True,
|
|
1070
|
+
llm_cache_store=cache_store,
|
|
1071
|
+
)
|
|
1072
|
+
try:
|
|
1073
|
+
failed_result = _run_runtime(failed_runtime, compiled_plan=compiled_plan, inputs={})
|
|
1074
|
+
finally:
|
|
1075
|
+
logger.close()
|
|
1076
|
+
|
|
1077
|
+
self.assertEqual(failed_result.status, "failed")
|
|
1078
|
+
self.assertEqual(cache_store.values, {})
|
|
1079
|
+
|
|
1080
|
+
logger = EventLogger(log_path=Path(tmpdir) / "success-run.jsonl", run_id="llm-cache-success")
|
|
1081
|
+
success_runtime = WorkflowRuntime(
|
|
1082
|
+
logger=logger,
|
|
1083
|
+
llm_cache_enabled=True,
|
|
1084
|
+
llm_cache_store=cache_store,
|
|
1085
|
+
)
|
|
1086
|
+
try:
|
|
1087
|
+
success_result = _run_runtime(success_runtime, compiled_plan=compiled_plan, inputs={})
|
|
1088
|
+
finally:
|
|
1089
|
+
logger.close()
|
|
1090
|
+
|
|
1091
|
+
self.assertEqual(success_result.status, "succeeded")
|
|
1092
|
+
self.assertFalse(success_result.nodes["classify"].outputs["cache_hit"])
|
|
1093
|
+
|
|
1094
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
1095
|
+
self.assertEqual(len(cache_store.values), 1)
|
|
1096
|
+
|
|
709
1097
|
def test_llm_azure_chat_includes_http_error_details(self) -> None:
|
|
710
1098
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
711
1099
|
plan_path = Path(tmpdir) / "plan.json"
|
|
@@ -3,10 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import unittest
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from unittest.mock import AsyncMock
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
9
10
|
from dify_player import WorkflowEngine
|
|
11
|
+
from dify_player.llm_cache import NullLLMCacheStore
|
|
10
12
|
from dify_player.models import to_data
|
|
11
13
|
from tests.workflow_http_server import HTTPServerContext
|
|
12
14
|
|
|
@@ -43,3 +45,21 @@ class WorkflowEngineTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
43
45
|
self.assertFalse(http_client.is_closed)
|
|
44
46
|
self.assertEqual(requests_log[0]["method"], "POST")
|
|
45
47
|
self.assertEqual(requests_log[0]["query"], {"q": ["hello"]})
|
|
48
|
+
|
|
49
|
+
async def test_run_plan_data_passes_llm_cache_options_to_executor(self) -> None:
|
|
50
|
+
engine = WorkflowEngine()
|
|
51
|
+
cache_store = NullLLMCacheStore()
|
|
52
|
+
plan_data = json.loads((ROOT / "examples" / "hello" / "plan.json").read_text(encoding="utf-8"))
|
|
53
|
+
expected_result = object()
|
|
54
|
+
engine._executor.run_plan = AsyncMock(return_value=expected_result)
|
|
55
|
+
|
|
56
|
+
result = await engine.run_plan_data(
|
|
57
|
+
plan_data=plan_data,
|
|
58
|
+
inputs={"name": "World"},
|
|
59
|
+
llm_cache=True,
|
|
60
|
+
llm_cache_store=cache_store,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.assertIs(result, expected_result)
|
|
64
|
+
self.assertTrue(engine._executor.run_plan.await_args.kwargs["llm_cache"])
|
|
65
|
+
self.assertIs(engine._executor.run_plan.await_args.kwargs["llm_cache_store"], cache_store)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/__init__.py
RENAMED
|
File without changes
|
{dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/assigner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/http_request.py
RENAMED
|
File without changes
|
{dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/if_else.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|