lmcache-cli 0.5.1.dev0__py3-none-any.whl → 0.5.1rc3.dev0__py3-none-any.whl

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 (162) hide show
  1. lmcache/__init__.py +12 -0
  2. lmcache/_version.py +3 -3
  3. lmcache/banner.py +6 -2
  4. lmcache/cli/commands/bench/engine_bench/interactive/__init__.py +175 -68
  5. lmcache/cli/commands/bench/engine_bench/interactive/schema.py +6 -2
  6. lmcache/cli/commands/bench/engine_bench/interactive/state.py +4 -0
  7. lmcache/cli/commands/bench/engine_bench/interactive/terminal.py +71 -13
  8. lmcache/cli/commands/bench/server_bench/command.py +2 -2
  9. lmcache/cli/commands/bench/server_bench/helpers.py +16 -24
  10. lmcache/cli/commands/coordinator.py +17 -1
  11. lmcache/cli/commands/describe.py +154 -5
  12. lmcache/cli/commands/kvcache.py +2 -2
  13. lmcache/cli/commands/query/_request.py +19 -8
  14. lmcache/cli/commands/query/engine_command.py +2 -1
  15. lmcache/integration/sglang/multi_process_adapter.py +6 -2
  16. lmcache/integration/tensorrt_llm/tensorrt_mp_adapter.py +1 -1
  17. lmcache/integration/vllm/kv_cache_groups.py +16 -14
  18. lmcache/integration/vllm/lmcache_mp_connector.py +26 -1
  19. lmcache/integration/vllm/vllm_multi_process_adapter.py +4 -5
  20. lmcache/integration/vllm/vllm_v1_adapter.py +66 -14
  21. lmcache/python_ops_fallback.py +145 -11
  22. lmcache/sdk/__init__.py +10 -0
  23. lmcache/sdk/kvcache.py +539 -0
  24. lmcache/sdk/wrapper/__init__.py +9 -0
  25. lmcache/sdk/wrapper/contiguous.py +75 -0
  26. lmcache/v1/cache_controller/full_sync_sender.py +2 -2
  27. lmcache/v1/cache_engine.py +56 -39
  28. lmcache/v1/config.py +80 -0
  29. lmcache/v1/distributed/api.py +65 -2
  30. lmcache/v1/distributed/config.py +14 -12
  31. lmcache/v1/distributed/l1_manager.py +8 -3
  32. lmcache/v1/distributed/l2_adapters/base.py +3 -3
  33. lmcache/v1/distributed/l2_adapters/config.py +3 -1
  34. lmcache/v1/distributed/l2_adapters/fs_l2_adapter.py +2 -1
  35. lmcache/v1/distributed/l2_adapters/mooncake_store_l2_adapter.py +4 -3
  36. lmcache/v1/distributed/l2_adapters/s3_l2_adapter.py +4 -7
  37. lmcache/v1/distributed/memory_manager/__init__.py +5 -0
  38. lmcache/v1/distributed/memory_manager/devdax_l1_memory_manager.py +76 -0
  39. lmcache/v1/distributed/memory_manager/l1_memory_manager.py +1 -23
  40. lmcache/v1/distributed/serde/__init__.py +8 -0
  41. lmcache/v1/distributed/serde/turboquant/__init__.py +15 -0
  42. lmcache/v1/distributed/serde/turboquant/decode_kernel.py +173 -0
  43. lmcache/v1/distributed/serde/turboquant/store_kernel.py +445 -0
  44. lmcache/v1/distributed/serde/turboquant/turboquant.py +896 -0
  45. lmcache/v1/distributed/storage_controllers/prefetch_controller.py +54 -12
  46. lmcache/v1/distributed/storage_manager.py +57 -3
  47. lmcache/v1/distributed/tiers.py +23 -0
  48. lmcache/v1/distributed/transfer_channel/impl/nixl_impl.py +2 -1
  49. lmcache/v1/gpu_connector/_cufile_async.py +26 -9
  50. lmcache/v1/gpu_connector/_gds_async.py +46 -0
  51. lmcache/v1/gpu_connector/_hipfile_async.py +532 -0
  52. lmcache/v1/gpu_connector/gds_context.py +39 -37
  53. lmcache/v1/gpu_connector/gpu_connectors.py +6 -4
  54. lmcache/v1/gpu_connector/gpu_ops.py +54 -0
  55. lmcache/v1/gpu_connector/kv_format/contiguity.py +0 -3
  56. lmcache/v1/gpu_connector/kv_format/specs/base.py +12 -10
  57. lmcache/v1/gpu_connector/kv_format/specs/nb_nl_two_bs_nh_hs.py +0 -1
  58. lmcache/v1/gpu_connector/kv_format/specs/nb_nl_two_nh_bs_hs.py +0 -2
  59. lmcache/v1/gpu_connector/kv_format/specs/nl_x_nb_bs_hs.py +0 -1
  60. lmcache/v1/gpu_connector/kv_format/specs/nl_x_nb_nh_bs_two_hs.py +0 -1
  61. lmcache/v1/gpu_connector/kv_format/specs/nl_x_nb_two_nh_bs_hs.py +0 -1
  62. lmcache/v1/gpu_connector/kv_format/specs/nl_x_nbbs_one_hs.py +0 -1
  63. lmcache/v1/gpu_connector/kv_format/specs/nl_x_two_nb_nh_bs_hs.py +0 -1
  64. lmcache/v1/gpu_connector/utils.py +74 -26
  65. lmcache/v1/hidden_state_store.py +364 -0
  66. lmcache/v1/kv_layer_groups.py +141 -105
  67. lmcache/v1/lazy_memory_allocator.py +13 -11
  68. lmcache/v1/memory_management.py +61 -53
  69. lmcache/v1/mp_coordinator/__main__.py +7 -1
  70. lmcache/v1/mp_coordinator/app.py +26 -9
  71. lmcache/v1/mp_coordinator/{l2 → cache_control}/event_listener.py +1 -1
  72. lmcache/v1/mp_coordinator/{l2 → cache_control}/eviction_manager.py +6 -6
  73. lmcache/v1/mp_coordinator/cache_control/prefetch_manager.py +98 -0
  74. lmcache/v1/mp_coordinator/{l2 → cache_control}/resync_manager.py +5 -5
  75. lmcache/v1/mp_coordinator/config.py +11 -2
  76. lmcache/v1/mp_coordinator/http_apis/cache_api.py +128 -0
  77. lmcache/v1/mp_coordinator/http_apis/dependencies.py +83 -0
  78. lmcache/v1/mp_coordinator/http_apis/instances_api.py +8 -26
  79. lmcache/v1/mp_coordinator/http_apis/{l2_api.py → quota_api.py} +92 -90
  80. lmcache/v1/mp_coordinator/schemas.py +60 -11
  81. lmcache/v1/mp_observability/config.py +6 -0
  82. lmcache/v1/mp_observability/errors.py +66 -0
  83. lmcache/v1/mp_observability/event.py +5 -0
  84. lmcache/v1/mp_observability/subscribers/logging/__init__.py +4 -0
  85. lmcache/v1/mp_observability/subscribers/logging/timeout.py +28 -0
  86. lmcache/v1/mp_observability/subscribers/metrics/__init__.py +4 -0
  87. lmcache/v1/mp_observability/subscribers/metrics/timeout.py +31 -0
  88. lmcache/v1/mp_observability/subscribers/tracing/__init__.py +4 -0
  89. lmcache/v1/mp_observability/subscribers/tracing/timeout.py +70 -0
  90. lmcache/v1/mp_observability/trace/codecs.py +26 -0
  91. lmcache/v1/multiprocess/affinity_pool.py +59 -7
  92. lmcache/v1/multiprocess/cache_control/__init__.py +1 -0
  93. lmcache/v1/multiprocess/cache_control/errors.py +22 -0
  94. lmcache/v1/multiprocess/cache_control/key_resolver.py +91 -0
  95. lmcache/v1/multiprocess/cache_control/object_service.py +180 -0
  96. lmcache/v1/multiprocess/cache_control/prefetch_service.py +108 -0
  97. lmcache/v1/multiprocess/config.py +13 -0
  98. lmcache/v1/multiprocess/custom_types.py +3 -227
  99. lmcache/v1/multiprocess/engine_context.py +46 -1
  100. lmcache/v1/multiprocess/futures.py +3 -2
  101. lmcache/v1/multiprocess/group_view.py +25 -0
  102. lmcache/v1/multiprocess/http_apis/cache_api.py +254 -166
  103. lmcache/v1/multiprocess/http_apis/{conf_api.py → config_api.py} +30 -2
  104. lmcache/v1/multiprocess/http_apis/dependencies.py +78 -0
  105. lmcache/v1/multiprocess/http_apis/error_handlers.py +55 -0
  106. lmcache/v1/multiprocess/http_apis/info_api.py +76 -0
  107. lmcache/v1/multiprocess/http_apis/reconfigure_api.py +0 -37
  108. lmcache/v1/multiprocess/http_apis/schemas.py +73 -0
  109. lmcache/v1/multiprocess/http_server.py +10 -1
  110. lmcache/v1/multiprocess/modules/blend_v3.py +1 -1
  111. lmcache/v1/multiprocess/modules/lmcache_driven_transfer.py +203 -5
  112. lmcache/v1/multiprocess/modules/lookup.py +106 -6
  113. lmcache/v1/multiprocess/server.py +3 -2
  114. lmcache/v1/multiprocess/session.py +45 -2
  115. lmcache/v1/multiprocess/transfer_context/shm.py +63 -6
  116. lmcache/v1/multiprocess/transfer_context/worker_transfer.py +19 -0
  117. lmcache/v1/multiprocess/warm_prefetch.py +140 -0
  118. lmcache/v1/platform/__init__.py +10 -14
  119. lmcache/v1/platform/_registry.py +148 -16
  120. lmcache/v1/platform/base_cache_context.py +45 -39
  121. lmcache/v1/platform/base_ipc_wrapper.py +160 -0
  122. lmcache/v1/platform/base_pin_memory.py +48 -0
  123. lmcache/v1/platform/cache_context.py +5 -0
  124. lmcache/v1/platform/cpu/__init__.py +4 -31
  125. lmcache/v1/platform/cpu/cache_context.py +13 -23
  126. lmcache/v1/platform/cpu/shm.py +28 -1
  127. lmcache/v1/platform/cuda/__init__.py +11 -27
  128. lmcache/v1/platform/cuda/cache_context.py +14 -22
  129. lmcache/v1/platform/cuda/ipc_wrapper.py +189 -0
  130. lmcache/v1/platform/cuda/pin_memory.py +170 -0
  131. lmcache/v1/platform/device_ext.py +91 -0
  132. lmcache/v1/platform/musa/__init__.py +22 -1
  133. lmcache/v1/platform/musa/ipc.py +318 -0
  134. lmcache/v1/storage_backend/connector/bigtable_schema.py +5 -4
  135. lmcache/v1/storage_backend/connector/s3_connector.py +8 -8
  136. lmcache/v1/storage_backend/connector/valkey_adapter.py +64 -2
  137. lmcache/v1/storage_backend/connector/valkey_connector.py +72 -4
  138. lmcache/v1/storage_backend/gds_backend.py +8 -3
  139. lmcache/v1/storage_backend/local_cpu_backend.py +61 -42
  140. lmcache/v1/storage_backend/local_disk_backend.py +30 -28
  141. lmcache/v1/storage_backend/nixl_storage_backend.py +43 -10
  142. lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py +31 -16
  143. lmcache/v1/storage_backend/remote_backend.py +21 -19
  144. lmcache/v1/storage_backend/storage_manager.py +18 -14
  145. lmcache/v1/system_detection.py +3 -3
  146. lmcache/v1/token_database.py +5 -4
  147. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/METADATA +3 -2
  148. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/RECORD +156 -125
  149. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/scm_file_list.json +93 -22
  150. lmcache_cli-0.5.1rc3.dev0.dist-info/scm_version.json +8 -0
  151. lmcache/v1/multiprocess/http_apis/healthcheck_api.py +0 -31
  152. lmcache/v1/multiprocess/http_apis/l2_api.py +0 -241
  153. lmcache/v1/multiprocess/http_apis/root_api.py +0 -15
  154. lmcache/v1/multiprocess/http_apis/status_api.py +0 -25
  155. lmcache/v1/multiprocess/http_apis/version_api.py +0 -8
  156. lmcache_cli-0.5.1.dev0.dist-info/scm_version.json +0 -8
  157. /lmcache/v1/mp_coordinator/{l2 → cache_control}/__init__.py +0 -0
  158. /lmcache/v1/mp_coordinator/{l2 → cache_control}/usage_manager.py +0 -0
  159. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/WHEEL +0 -0
  160. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/entry_points.txt +0 -0
  161. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/licenses/LICENSE +0 -0
  162. {lmcache_cli-0.5.1.dev0.dist-info → lmcache_cli-0.5.1rc3.dev0.dist-info}/top_level.txt +0 -0
lmcache/__init__.py CHANGED
@@ -8,6 +8,7 @@ import types
8
8
 
9
9
  # First Party
10
10
  from lmcache.logging import init_logger
11
+ from lmcache.v1.platform.device_ext import DeviceExt
11
12
 
12
13
  try:
13
14
  # First Party
@@ -62,6 +63,17 @@ torch_dev, torch_device_type = _detect_device()
62
63
  logger.info(" torch_dev=%s, torch_device_type=%s", torch_dev, torch_device_type)
63
64
 
64
65
 
66
+ # Attach the DeviceExt instance as ``torch_dev.ext``. This monkey-patches a
67
+ # standard torch module (e.g. ``torch.cuda``) with a custom attribute that does
68
+ # not exist in the original module. The ``# type: ignore[attr-defined]`` suppresses
69
+ # the expected mypy/pyright "attr-defined" error from this intentional extension.
70
+ if torch_dev is not None:
71
+ torch_dev.ext = DeviceExt(torch_device_type) # type: ignore[attr-defined]
72
+ else:
73
+ logger.warning("torch_dev is None, skipping DeviceExt initialization.")
74
+ pass
75
+
76
+
65
77
  # --------------------------
66
78
  # Dynamic backend selection
67
79
  # --------------------------
lmcache/_version.py CHANGED
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.1.dev0'
22
- __version_tuple__ = version_tuple = (0, 5, 1, 'dev0')
21
+ __version__ = version = '0.5.1rc3.dev0'
22
+ __version_tuple__ = version_tuple = (0, 5, 1, 'rc3', 'dev0')
23
23
 
24
- __commit_id__ = commit_id = 'g389b9cfcb'
24
+ __commit_id__ = commit_id = 'g979719d7a'
lmcache/banner.py CHANGED
@@ -8,9 +8,13 @@ only, so tensor-parallel deployments print a single banner). Setting the
8
8
  """
9
9
 
10
10
  # Standard
11
- from typing import TextIO
11
+ from typing import TYPE_CHECKING
12
12
  import os
13
13
 
14
+ if TYPE_CHECKING:
15
+ # Standard
16
+ from typing import TextIO
17
+
14
18
  try:
15
19
  # First Party
16
20
  from lmcache import _version
@@ -97,7 +101,7 @@ def _render_banner(colored: bool) -> str:
97
101
  return "\n".join(lines)
98
102
 
99
103
 
100
- def print_banner_once(stream: TextIO) -> None:
104
+ def print_banner_once(stream: "TextIO") -> None:
101
105
  """Print the LMCache startup banner to ``stream`` at most once.
102
106
 
103
107
  The banner shows the LMCache logo, version, and website, followed by
@@ -7,6 +7,7 @@ returns a complete ``argparse.Namespace`` ready for the orchestrator.
7
7
  """
8
8
 
9
9
  # Standard
10
+ from typing import cast
10
11
  import argparse
11
12
  import sys
12
13
 
@@ -20,6 +21,7 @@ from lmcache.cli.commands.bench.engine_bench.interactive.state import (
20
21
  from lmcache.cli.commands.bench.engine_bench.interactive.terminal import (
21
22
  BOLD,
22
23
  CYAN,
24
+ GO_BACK,
23
25
  RESET,
24
26
  YELLOW,
25
27
  prompt_bool,
@@ -31,18 +33,43 @@ from lmcache.cli.commands.bench.engine_bench.interactive.terminal import (
31
33
  __all__ = ["run_interactive"]
32
34
 
33
35
 
36
+ # Config items whose text input is a URL and accepts port/host shorthand.
37
+ _URL_KEYS = {"engine_url", "lmcache_url"}
38
+
39
+
40
+ def _normalize_url(value: str) -> str:
41
+ """Expand shorthand URL input into a fully-qualified URL.
42
+
43
+ A bare port (``8000``) becomes ``http://localhost:8000``; a bare host or
44
+ ``host:port`` (``localhost:8000``) gains an ``http://`` scheme. Values
45
+ that already carry a scheme (``://``) and the empty string are returned
46
+ unchanged.
47
+ """
48
+ value = value.strip()
49
+ if not value or "://" in value:
50
+ return value
51
+ if value.isdigit():
52
+ return f"http://localhost:{value}"
53
+ return f"http://{value}"
54
+
55
+
34
56
  # ---------------------------------------------------------------------------
35
57
  # Prompt dispatcher
36
58
  # ---------------------------------------------------------------------------
37
59
 
38
60
 
39
- def _prompt_for_item(item: ConfigItem) -> object:
40
- """Prompt the user for a single config item based on its type."""
61
+ def _prompt_for_item(item: ConfigItem, allow_back: bool) -> object:
62
+ """Prompt the user for a single config item based on its type.
63
+
64
+ Returns the entered value, or :data:`GO_BACK` when ``allow_back`` is True
65
+ and the user asks to step back to the previous question.
66
+ """
41
67
  if item.input_type == "text":
42
68
  return prompt_text(
43
69
  item.display_name,
44
70
  item.description,
45
71
  default=item.default if item.default is not None else "",
72
+ allow_back=allow_back,
46
73
  )
47
74
  if item.input_type == "int":
48
75
  return prompt_number(
@@ -50,6 +77,7 @@ def _prompt_for_item(item: ConfigItem) -> object:
50
77
  item.description,
51
78
  default=item.default,
52
79
  number_type=int,
80
+ allow_back=allow_back,
53
81
  )
54
82
  if item.input_type == "float":
55
83
  return prompt_number(
@@ -57,12 +85,14 @@ def _prompt_for_item(item: ConfigItem) -> object:
57
85
  item.description,
58
86
  default=item.default,
59
87
  number_type=float,
88
+ allow_back=allow_back,
60
89
  )
61
90
  if item.input_type == "bool":
62
91
  return prompt_bool(
63
92
  item.display_name,
64
93
  item.description,
65
94
  default=bool(item.default) if item.default is not None else True,
95
+ allow_back=allow_back,
66
96
  )
67
97
  if item.input_type == "choice":
68
98
  return prompt_choice(
@@ -70,6 +100,7 @@ def _prompt_for_item(item: ConfigItem) -> object:
70
100
  item.description,
71
101
  choices=item.choices,
72
102
  default=item.default if item.default is not None else "",
103
+ allow_back=allow_back,
73
104
  )
74
105
  raise ValueError(f"Unknown input_type {item.input_type!r} for {item.key}")
75
106
 
@@ -79,23 +110,20 @@ def _prompt_for_item(item: ConfigItem) -> object:
79
110
  # ---------------------------------------------------------------------------
80
111
 
81
112
 
82
- def _prompt_gate(section_name: str, detail: str) -> bool:
113
+ def _prompt_gate(section_name: str, detail: str, allow_back: bool) -> object:
83
114
  """Ask the user whether to configure a section or skip with defaults.
84
115
 
85
- Returns True if the user wants to configure.
116
+ Returns ``"configure"``, ``"use defaults"``, or :data:`GO_BACK`.
86
117
  """
87
- return (
88
- prompt_choice(
89
- section_name,
90
- f"Would you like to configure {detail}?\n"
91
- f" Defaults will be used if you skip.",
92
- choices=[
93
- ("use defaults", "Skip, use defaults"),
94
- ("configure", "Yes, configure"),
95
- ],
96
- default="use defaults",
97
- )
98
- == "configure"
118
+ return prompt_choice(
119
+ section_name,
120
+ f"Would you like to configure {detail}?\n Defaults will be used if you skip.",
121
+ choices=[
122
+ ("use defaults", "Skip, use defaults"),
123
+ ("configure", "Yes, configure"),
124
+ ],
125
+ default="use defaults",
126
+ allow_back=allow_back,
99
127
  )
100
128
 
101
129
 
@@ -121,10 +149,10 @@ def _print_summary(state: InteractiveState) -> None:
121
149
  # ---------------------------------------------------------------------------
122
150
 
123
151
 
124
- def _prompt_action() -> str:
125
- """Ask the user to start the benchmark or export config.
152
+ def _prompt_action(allow_back: bool) -> object:
153
+ """Ask the user to start the benchmark, export config, or quit.
126
154
 
127
- Returns ``"start"`` or ``"export"``.
155
+ Returns ``"start"``, ``"export"``, ``"quit"``, or :data:`GO_BACK`.
128
156
  """
129
157
  return prompt_choice(
130
158
  "What would you like to do?",
@@ -132,8 +160,10 @@ def _prompt_action() -> str:
132
160
  choices=[
133
161
  ("start", "Start benchmark"),
134
162
  ("export", "Export configuration for later use and exit"),
163
+ ("quit", "Quit without running"),
135
164
  ],
136
165
  default="start",
166
+ allow_back=allow_back,
137
167
  )
138
168
 
139
169
 
@@ -177,10 +207,14 @@ def _resolve_before_export(state: InteractiveState) -> None:
177
207
  def _handle_export(state: InteractiveState) -> None:
178
208
  """Prompt for filename, resolve values, save JSON, and exit."""
179
209
  _resolve_before_export(state)
180
- filename = prompt_text(
181
- "Export filename",
182
- "",
183
- default="bench_config.json",
210
+ # No allow_back here, so the return is always a plain string.
211
+ filename = cast(
212
+ str,
213
+ prompt_text(
214
+ "Export filename",
215
+ "",
216
+ default="bench_config.json",
217
+ ),
184
218
  )
185
219
  state.save_json(filename)
186
220
  print()
@@ -199,12 +233,116 @@ def _handle_export(state: InteractiveState) -> None:
199
233
  # ---------------------------------------------------------------------------
200
234
 
201
235
 
236
+ # Heading text for each section gate, keyed by section name.
237
+ _GATE_TEXT = {
238
+ "general": ("General settings", "general settings (model, KV cache volume, etc.)"),
239
+ }
240
+
241
+
242
+ def _next_question(
243
+ state: InteractiveState, gates: dict[str, str]
244
+ ) -> ConfigItem | str | None:
245
+ """Determine the next question to ask.
246
+
247
+ Returns a ``ConfigItem`` to prompt for a value, a section name
248
+ (``"general"`` / ``"workload"``) to prompt the section gate, or None when
249
+ configuration is complete. The result is derived purely from current
250
+ state plus the gate decisions already made, so stepping back (which
251
+ un-sets the most recent answer) naturally re-presents the question that
252
+ produced it.
253
+ """
254
+ missing = state.get_missing_required()
255
+ if missing:
256
+ return missing[0]
257
+
258
+ if state.has_unconfigured_general() and "general" not in gates:
259
+ return "general"
260
+ if gates.get("general") == "configure":
261
+ general = state.get_general_items()
262
+ if general:
263
+ return general[0]
264
+
265
+ workload = [i for i in state.get_workload_items() if not state.is_set(i.key)]
266
+ if workload and "workload" not in gates:
267
+ return "workload"
268
+ if gates.get("workload") == "configure" and workload:
269
+ return workload[0]
270
+
271
+ return None
272
+
273
+
274
+ def _gate_text(section: str, state: InteractiveState) -> tuple[str, str]:
275
+ """Return the ``(section_name, detail)`` heading for a gate prompt."""
276
+ if section == "workload":
277
+ return (
278
+ f"Workload settings ({state.get('workload', 'workload')})",
279
+ "workload-specific settings",
280
+ )
281
+ return _GATE_TEXT[section]
282
+
283
+
284
+ def _gather_config(state: InteractiveState) -> bool:
285
+ """Drive the question loop until the user starts, exports, or quits.
286
+
287
+ Walks required items, the general/workload gates, and the summary, while
288
+ letting the user step back to revise any earlier answer. Gate decisions
289
+ are tracked outside ``state`` so they never leak into the exported config.
290
+
291
+ Returns True to start the benchmark, False to quit. Exits the process
292
+ directly on export.
293
+ """
294
+ gates: dict[str, str] = {}
295
+ # Trail of committed answers, for stepping back. Item keys are stored as
296
+ # the key itself; gate decisions as ``"gate:<section>"``.
297
+ trail: list[str] = []
298
+
299
+ def step_back() -> None:
300
+ marker = trail.pop()
301
+ if marker.startswith("gate:"):
302
+ gates.pop(marker[len("gate:") :], None)
303
+ else:
304
+ state.unset(marker)
305
+
306
+ while True:
307
+ question = _next_question(state, gates)
308
+
309
+ if question is None:
310
+ _print_summary(state)
311
+ action = _prompt_action(allow_back=bool(trail))
312
+ if action is GO_BACK:
313
+ step_back()
314
+ continue
315
+ if action == "export":
316
+ _handle_export(state) # exits
317
+ return action == "start"
318
+
319
+ if isinstance(question, ConfigItem):
320
+ value = _prompt_for_item(question, allow_back=bool(trail))
321
+ if value is GO_BACK:
322
+ step_back()
323
+ continue
324
+ if question.key in _URL_KEYS and isinstance(value, str):
325
+ value = _normalize_url(value)
326
+ state.set(question.key, value)
327
+ trail.append(question.key)
328
+ else:
329
+ name, detail = _gate_text(question, state)
330
+ choice = _prompt_gate(name, detail, allow_back=bool(trail))
331
+ if choice is GO_BACK:
332
+ step_back()
333
+ continue
334
+ gates[question] = str(choice)
335
+ trail.append(f"gate:{question}")
336
+
337
+
202
338
  def run_interactive(args: argparse.Namespace) -> argparse.Namespace:
203
339
  """Run the interactive configuration flow.
204
340
 
205
341
  Walks the user through missing required items, offers gates for
206
342
  general and workload-specific settings, shows a summary, and
207
- returns a complete ``argparse.Namespace``.
343
+ returns a complete ``argparse.Namespace``. At any prompt the user can
344
+ step back to the previous question (``<``) or exit (Ctrl-C / Ctrl-D, or
345
+ the Quit action).
208
346
 
209
347
  Args:
210
348
  args: Partially-populated CLI args (some may be None).
@@ -219,50 +357,19 @@ def run_interactive(args: argparse.Namespace) -> argparse.Namespace:
219
357
  print(f"{BOLD}{'═' * 50}{RESET}")
220
358
  print(f"{BOLD} lmcache bench engine — Interactive Setup{RESET}")
221
359
  print(f"{BOLD}{'═' * 50}{RESET}")
222
-
223
- # ── Phase 1: Required items ───────────────────────────────────────
224
- # Walk through missing required items one by one. Re-evaluate the
225
- # list after each prompt because setting one value (e.g., has_lmcache)
226
- # can make new items eligible (e.g., lmcache_url) or skip others
227
- # (e.g., tokens_per_gb_kvcache).
228
- while True:
229
- missing = state.get_missing_required()
230
- if not missing:
231
- break
232
- item = missing[0]
233
- value = _prompt_for_item(item)
234
- state.set(item.key, value)
235
-
236
- # ── Phase 2: General settings gate ────────────────────────────────
237
- if state.has_unconfigured_general():
238
- if _prompt_gate(
239
- "General settings",
240
- "general settings (model, KV cache volume, etc.)",
241
- ):
242
- for item in state.get_general_items():
243
- value = _prompt_for_item(item)
244
- state.set(item.key, value)
245
-
246
- # ── Phase 3: Workload settings gate (always shown) ────────────────
247
- if state.has_workload_items():
248
- workload = state.get("workload", "workload")
249
- if _prompt_gate(
250
- f"Workload settings ({workload})",
251
- "workload-specific settings",
252
- ):
253
- for item in state.get_workload_items():
254
- value = _prompt_for_item(item)
255
- state.set(item.key, value)
256
-
257
- # Fill all remaining defaults
258
- state.fill_defaults()
259
-
260
- # ── Phase 4: Summary + action ─────────────────────────────────────
261
- _print_summary(state)
262
- action = _prompt_action()
263
-
264
- if action == "export":
265
- _handle_export(state)
360
+ print(f" {YELLOW}Type < to go back · Ctrl-C to exit{RESET}")
361
+
362
+ try:
363
+ start = _gather_config(state)
364
+ except (KeyboardInterrupt, EOFError):
365
+ print()
366
+ print(f" {YELLOW}Setup cancelled.{RESET}")
367
+ sys.exit(0)
368
+
369
+ if not start:
370
+ print()
371
+ print(f" {YELLOW}Quit without running.{RESET}")
372
+ sys.exit(0)
266
373
 
267
374
  # Carry over output settings from original CLI args
268
375
  ns = state.to_namespace()
@@ -90,7 +90,8 @@ ALL_ITEMS: list[ConfigItem] = [
90
90
  key="engine_url",
91
91
  display_name="Engine URL",
92
92
  description=(
93
- "URL of the inference engine. "
93
+ "URL of the inference engine. Enter just a port (e.g. 8000) to "
94
+ "use http://localhost:8000. "
94
95
  "Set OPENAI_API_KEY env var if authentication is needed."
95
96
  ),
96
97
  input_type="text",
@@ -135,7 +136,10 @@ ALL_ITEMS: list[ConfigItem] = [
135
136
  ConfigItem(
136
137
  key="lmcache_url",
137
138
  display_name="LMCache Server URL",
138
- description="URL of the running LMCache HTTP server.",
139
+ description=(
140
+ "URL of the running LMCache HTTP server. Enter just a port "
141
+ "(e.g. 8080) to use http://localhost:8080."
142
+ ),
139
143
  input_type="text",
140
144
  default="http://localhost:8080",
141
145
  required=False,
@@ -74,6 +74,10 @@ class InteractiveState:
74
74
  def set(self, key: str, value: Any) -> None:
75
75
  self._values[key] = value
76
76
 
77
+ def unset(self, key: str) -> None:
78
+ """Remove a key so it counts as unset again (used when stepping back)."""
79
+ self._values.pop(key, None)
80
+
77
81
  @property
78
82
  def values(self) -> dict[str, Any]:
79
83
  return dict(self._values)
@@ -7,11 +7,28 @@ Provides simple prompt functions for collecting user input:
7
7
  - ``prompt_number`` — numeric input with type validation
8
8
  - ``prompt_bool`` — Y/N confirmation
9
9
  - ``prompt_choice`` — arrow-key selection (falls back to numbered list on non-TTY)
10
+
11
+ Every prompt accepts ``allow_back``. When enabled, the user steps back to the
12
+ previous question by typing ``<`` — in every prompt, typed or arrow-key — in
13
+ which case the prompt returns the :data:`GO_BACK` sentinel instead of a value.
10
14
  """
11
15
 
12
16
  # Standard
13
17
  import sys
14
18
 
19
+
20
+ class _GoBack:
21
+ """Sentinel type returned by prompts when the user asks to step back."""
22
+
23
+
24
+ # Returned by any prompt (when ``allow_back=True``) to mean "go to the
25
+ # previous question". Compare with ``is`` — there is only one instance.
26
+ GO_BACK = _GoBack()
27
+
28
+ # Token the user types to go back, in any text/number/bool prompt and in the
29
+ # non-TTY choice fallback.
30
+ BACK_TOKEN = "<"
31
+
15
32
  # ---------------------------------------------------------------------------
16
33
  # ANSI helpers
17
34
  # ---------------------------------------------------------------------------
@@ -30,6 +47,11 @@ def _is_tty() -> bool:
30
47
  return hasattr(sys.stdin, "fileno") and sys.stdin.isatty()
31
48
 
32
49
 
50
+ def _print_back_hint() -> None:
51
+ """Print the dim 'type < to go back' hint used by typed prompts."""
52
+ print(f" {DIM}(type {BACK_TOKEN} to go back){RESET}")
53
+
54
+
33
55
  # ---------------------------------------------------------------------------
34
56
  # prompt_text
35
57
  # ---------------------------------------------------------------------------
@@ -39,26 +61,33 @@ def prompt_text(
39
61
  label: str,
40
62
  description: str = "",
41
63
  default: str = "",
42
- ) -> str:
64
+ allow_back: bool = False,
65
+ ) -> str | _GoBack:
43
66
  """Prompt for free-form text input.
44
67
 
45
68
  Args:
46
69
  label: The config item name shown as a heading.
47
70
  description: One-line explanation shown below the label.
48
71
  default: Default value; shown in brackets, returned on empty Enter.
72
+ allow_back: If True, typing ``<`` returns :data:`GO_BACK`.
49
73
 
50
74
  Returns:
51
- The user's input, or the default if they pressed Enter.
75
+ The user's input, the default if they pressed Enter, or
76
+ :data:`GO_BACK` if they asked to step back.
52
77
  """
53
78
  print()
54
79
  print(f"{BOLD}{label}{RESET}")
55
80
  if description:
56
81
  print(f" {description}")
82
+ if allow_back:
83
+ _print_back_hint()
57
84
  if default:
58
85
  prompt = f" {DIM}[default: {default}]{RESET} {GREEN}>{RESET} "
59
86
  else:
60
87
  prompt = f" {GREEN}>{RESET} "
61
88
  value = input(prompt).strip()
89
+ if allow_back and value == BACK_TOKEN:
90
+ return GO_BACK
62
91
  return value if value else default
63
92
 
64
93
 
@@ -72,7 +101,8 @@ def prompt_number(
72
101
  description: str = "",
73
102
  default: float | int | None = None,
74
103
  number_type: type = int,
75
- ) -> float | int:
104
+ allow_back: bool = False,
105
+ ) -> float | int | _GoBack:
76
106
  """Prompt for a numeric value with validation.
77
107
 
78
108
  Args:
@@ -80,14 +110,17 @@ def prompt_number(
80
110
  description: One-line explanation.
81
111
  default: Default value; returned on empty Enter. None means required.
82
112
  number_type: ``int`` or ``float``.
113
+ allow_back: If True, typing ``<`` returns :data:`GO_BACK`.
83
114
 
84
115
  Returns:
85
- The parsed number.
116
+ The parsed number, or :data:`GO_BACK` if the user asked to step back.
86
117
  """
87
118
  print()
88
119
  print(f"{BOLD}{label}{RESET}")
89
120
  if description:
90
121
  print(f" {description}")
122
+ if allow_back:
123
+ _print_back_hint()
91
124
 
92
125
  while True:
93
126
  if default is not None:
@@ -95,6 +128,8 @@ def prompt_number(
95
128
  else:
96
129
  prompt = f" {GREEN}>{RESET} "
97
130
  raw = input(prompt).strip()
131
+ if allow_back and raw == BACK_TOKEN:
132
+ return GO_BACK
98
133
  if not raw and default is not None:
99
134
  return default
100
135
  try:
@@ -113,7 +148,8 @@ def prompt_bool(
113
148
  label: str,
114
149
  description: str = "",
115
150
  default: bool = True,
116
- ) -> bool:
151
+ allow_back: bool = False,
152
+ ) -> bool | _GoBack:
117
153
  """Prompt for a yes/no confirmation.
118
154
 
119
155
  Shows ``[Y/n]`` or ``[y/N]`` depending on the default.
@@ -123,18 +159,24 @@ def prompt_bool(
123
159
  label: The config item name.
124
160
  description: One-line explanation.
125
161
  default: Default value when user presses Enter.
162
+ allow_back: If True, typing ``<`` returns :data:`GO_BACK`.
126
163
 
127
164
  Returns:
128
- True for yes, False for no.
165
+ True for yes, False for no, or :data:`GO_BACK` if the user asked to
166
+ step back.
129
167
  """
130
168
  print()
131
169
  print(f"{BOLD}{label}{RESET}")
132
170
  if description:
133
171
  print(f" {description}")
172
+ if allow_back:
173
+ _print_back_hint()
134
174
 
135
175
  hint = "[default: Y] (Y/n)" if default else "[default: N] (y/N)"
136
176
  while True:
137
177
  raw = input(f" {DIM}{hint}{RESET} {GREEN}>{RESET} ").strip().lower()
178
+ if allow_back and raw == BACK_TOKEN:
179
+ return GO_BACK
138
180
  if not raw:
139
181
  return default
140
182
  if raw in ("y", "yes"):
@@ -164,6 +206,12 @@ def _read_key() -> str:
164
206
  try:
165
207
  tty.setraw(fd)
166
208
  ch = sys.stdin.read(1)
209
+ # Raw mode disables signal generation, so translate Ctrl-C / Ctrl-D
210
+ # into the usual exceptions the caller already expects.
211
+ if ch == "\x03":
212
+ raise KeyboardInterrupt
213
+ if ch == "\x04":
214
+ raise EOFError
167
215
  if ch == "\r" or ch == "\n":
168
216
  return "enter"
169
217
  if ch == "\x1b":
@@ -201,7 +249,8 @@ def prompt_choice(
201
249
  description: str = "",
202
250
  choices: list[tuple[str, str]] = [], # noqa: B006
203
251
  default: str = "",
204
- ) -> str:
252
+ allow_back: bool = False,
253
+ ) -> str | _GoBack:
205
254
  """Prompt user to select from a list using arrow keys.
206
255
 
207
256
  Each choice is a ``(value, description)`` tuple. The description is
@@ -214,9 +263,11 @@ def prompt_choice(
214
263
  description: One-line explanation.
215
264
  choices: List of ``(value, one_line_description)`` tuples.
216
265
  default: Pre-selected value. Defaults to the first choice.
266
+ allow_back: If True, pressing ``<`` returns :data:`GO_BACK`.
217
267
 
218
268
  Returns:
219
- The selected value string.
269
+ The selected value string, or :data:`GO_BACK` if the user asked to
270
+ step back.
220
271
  """
221
272
  if not choices:
222
273
  raise ValueError("choices must not be empty")
@@ -235,9 +286,11 @@ def prompt_choice(
235
286
 
236
287
  # Non-TTY fallback: numbered list
237
288
  if not _is_tty():
238
- return _prompt_choice_fallback(choices, selected)
289
+ return _prompt_choice_fallback(choices, selected, allow_back)
239
290
 
240
- print(f" {DIM}Use ↑↓ to navigate, Enter to select.{RESET}")
291
+ nav = "Use ↑↓ to navigate, Enter to select"
292
+ nav += f", {BACK_TOKEN} to go back." if allow_back else "."
293
+ print(f" {DIM}{nav}{RESET}")
241
294
  print()
242
295
 
243
296
  # Initial render
@@ -251,14 +304,14 @@ def prompt_choice(
251
304
  selected = (selected - 1) % len(choices)
252
305
  elif key == "down":
253
306
  selected = (selected + 1) % len(choices)
254
- elif key == "enter":
307
+ elif key == "enter" or (key == BACK_TOKEN and allow_back):
255
308
  # Clear and re-render final state
256
309
  num_lines = len(choices)
257
310
  sys.stdout.write(f"\r{CURSOR_UP * num_lines}")
258
311
  lines = _render_choices(choices, selected)
259
312
  for line in lines:
260
313
  print(f"{CLEAR_LINE}{line}")
261
- return choices[selected][0]
314
+ return GO_BACK if key == BACK_TOKEN else choices[selected][0]
262
315
  else:
263
316
  continue
264
317
 
@@ -273,13 +326,18 @@ def prompt_choice(
273
326
  def _prompt_choice_fallback(
274
327
  choices: list[tuple[str, str]],
275
328
  default_index: int,
276
- ) -> str:
329
+ allow_back: bool = False,
330
+ ) -> str | _GoBack:
277
331
  """Numbered fallback for non-TTY environments."""
278
332
  for i, (val, desc) in enumerate(choices):
279
333
  marker = "*" if i == default_index else " "
280
334
  print(f" {marker} {i + 1}) {val} — {desc}")
335
+ if allow_back:
336
+ _print_back_hint()
281
337
  while True:
282
338
  raw = input(f" [default: {default_index + 1}] {GREEN}>{RESET} ").strip()
339
+ if allow_back and raw == BACK_TOKEN:
340
+ return GO_BACK
283
341
  if not raw:
284
342
  return choices[default_index][0]
285
343
  try: