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.
Files changed (58) hide show
  1. {dify_player-0.2.0 → dify_player-0.3.1}/PKG-INFO +1 -1
  2. {dify_player-0.2.0 → dify_player-0.3.1}/README.md +38 -4
  3. dify_player-0.3.1/dify_player/__init__.py +8 -0
  4. dify_player-0.3.1/dify_player/llm_cache.py +45 -0
  5. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/runtime.py +122 -3
  6. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/workflow_engine.py +13 -0
  7. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/workflow_executor.py +16 -1
  8. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/PKG-INFO +1 -1
  9. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/SOURCES.txt +2 -0
  10. {dify_player-0.2.0 → dify_player-0.3.1}/pyproject.toml +1 -1
  11. dify_player-0.3.1/tests/test_llm_cache.py +28 -0
  12. {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_runtime.py +388 -0
  13. {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_workflow_engine.py +20 -0
  14. dify_player-0.2.0/dify_player/__init__.py +0 -7
  15. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/__main__.py +0 -0
  16. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/cli.py +0 -0
  17. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/__init__.py +0 -0
  18. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/graph_parser.py +0 -0
  19. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/http_body_converter.py +0 -0
  20. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/__init__.py +0 -0
  21. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/assigner.py +0 -0
  22. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/code.py +0 -0
  23. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/end.py +0 -0
  24. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/http_request.py +0 -0
  25. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/if_else.py +0 -0
  26. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/llm.py +0 -0
  27. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/loop.py +0 -0
  28. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/start.py +0 -0
  29. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/template_transform.py +0 -0
  30. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/node_converters/variable_aggregator.py +0 -0
  31. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/plan_serializer.py +0 -0
  32. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/reference_converter.py +0 -0
  33. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/workflow_loader.py +0 -0
  34. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_importer/workflow_normalizer.py +0 -0
  35. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/dify_workflow_importer.py +0 -0
  36. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/event_logger.py +0 -0
  37. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/exceptions.py +0 -0
  38. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/input_resolver.py +0 -0
  39. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/models.py +0 -0
  40. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/__init__.py +0 -0
  41. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/assigner.py +0 -0
  42. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/code.py +0 -0
  43. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/end.py +0 -0
  44. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/http.py +0 -0
  45. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/if_else.py +0 -0
  46. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/llm_azure_chat.py +0 -0
  47. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/start.py +0 -0
  48. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/template.py +0 -0
  49. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/nodes/variable_aggregator.py +0 -0
  50. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/plan_loader.py +0 -0
  51. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player/value_renderer.py +0 -0
  52. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/dependency_links.txt +0 -0
  53. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/requires.txt +0 -0
  54. {dify_player-0.2.0 → dify_player-0.3.1}/dify_player.egg-info/top_level.txt +0 -0
  55. {dify_player-0.2.0 → dify_player-0.3.1}/setup.cfg +0 -0
  56. {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_assigner.py +0 -0
  57. {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_cli.py +0 -0
  58. {dify_player-0.2.0 → dify_player-0.3.1}/tests/test_dify_workflow_importer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dify-player
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -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__(self, logger: WorkflowLogger) -> None:
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 run_node(
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 run_node(
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(logger=logger)
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":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dify-player
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -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
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "dify-player"
10
- version = "0.2.0"
10
+ version = "0.3.1"
11
11
  description = "Minimal workflow runner for hand-authored Dify-like plans."
12
12
  requires-python = ">=3.11"
13
13
  dependencies = [
@@ -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)
@@ -1,7 +0,0 @@
1
- """Minimal workflow runner package."""
2
-
3
- from dify_player.workflow_engine import WorkflowEngine
4
-
5
- __all__ = ["__version__", "WorkflowEngine"]
6
-
7
- __version__ = "0.2.0"
File without changes