grpchook 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. grpchook-0.0.2/LICENSE.txt +28 -0
  2. grpchook-0.0.2/PKG-INFO +275 -0
  3. grpchook-0.0.2/examples/__init__.py +0 -0
  4. grpchook-0.0.2/examples/interactive_streaming/GrpcServerExample.py +16 -0
  5. grpchook-0.0.2/examples/interactive_streaming/LMProxyClient.py +100 -0
  6. grpchook-0.0.2/examples/interactive_streaming/TextClient.py +102 -0
  7. grpchook-0.0.2/examples/interactive_streaming/__init__.py +0 -0
  8. grpchook-0.0.2/examples/interactive_streaming/_lm_http.py +144 -0
  9. grpchook-0.0.2/examples/interactive_streaming/readme.md +48 -0
  10. grpchook-0.0.2/examples/interactive_streaming/run_server_proxy.py +26 -0
  11. grpchook-0.0.2/examples/interactive_streaming/run_text_client.py +14 -0
  12. grpchook-0.0.2/examples/mcp_server/FileOperationClient.py +200 -0
  13. grpchook-0.0.2/examples/mcp_server/GrpcServer.py +22 -0
  14. grpchook-0.0.2/examples/mcp_server/LlmBridgeClient.py +359 -0
  15. grpchook-0.0.2/examples/mcp_server/RunnerClient.py +218 -0
  16. grpchook-0.0.2/examples/mcp_server/__init__.py +0 -0
  17. grpchook-0.0.2/examples/mcp_server/_llm_utils.py +92 -0
  18. grpchook-0.0.2/examples/mcp_server/_task.py +131 -0
  19. grpchook-0.0.2/examples/mcp_server/readme.md +84 -0
  20. grpchook-0.0.2/examples/mcp_server/run_example.py +96 -0
  21. grpchook-0.0.2/examples/readme.md +3 -0
  22. grpchook-0.0.2/examples/watchdog/__init__.py +0 -0
  23. grpchook-0.0.2/examples/watchdog/readme.md +42 -0
  24. grpchook-0.0.2/examples/watchdog/server_watchdog.py +160 -0
  25. grpchook-0.0.2/examples/watchdog/watchdog_ui.py +1016 -0
  26. grpchook-0.0.2/grpchook/__init__.py +1 -0
  27. grpchook-0.0.2/grpchook/__main__.py +675 -0
  28. grpchook-0.0.2/grpchook/assets/HOW_TO.md +296 -0
  29. grpchook-0.0.2/grpchook/baseclient.py +615 -0
  30. grpchook-0.0.2/grpchook/baseserver.py +418 -0
  31. grpchook-0.0.2/grpchook/custom_interface.py +200 -0
  32. grpchook-0.0.2/grpchook/data_register.py +204 -0
  33. grpchook-0.0.2/grpchook/exceptions.py +25 -0
  34. grpchook-0.0.2/grpchook/logger.py +164 -0
  35. grpchook-0.0.2/grpchook/message.proto +60 -0
  36. grpchook-0.0.2/grpchook/message_pb2.py +50 -0
  37. grpchook-0.0.2/grpchook/message_pb2.pyi +75 -0
  38. grpchook-0.0.2/grpchook/message_pb2_grpc.py +101 -0
  39. grpchook-0.0.2/grpchook/schema_version.py +28 -0
  40. grpchook-0.0.2/grpchook/timer.py +302 -0
  41. grpchook-0.0.2/grpchook/tools.py +208 -0
  42. grpchook-0.0.2/pdm_build.py +49 -0
  43. grpchook-0.0.2/pyproject.toml +59 -0
  44. grpchook-0.0.2/readme.md +252 -0
  45. grpchook-0.0.2/requirements.txt +6 -0
  46. grpchook-0.0.2/tests/__init__.py +7 -0
  47. grpchook-0.0.2/tests/integration/__init__.py +0 -0
  48. grpchook-0.0.2/tests/integration/_interface.py +48 -0
  49. grpchook-0.0.2/tests/integration/_server_base.py +106 -0
  50. grpchook-0.0.2/tests/integration/basic/__init__.py +0 -0
  51. grpchook-0.0.2/tests/integration/basic/clients_basic.py +70 -0
  52. grpchook-0.0.2/tests/integration/basic/server_basic.py +18 -0
  53. grpchook-0.0.2/tests/integration/broadcast/__init__.py +0 -0
  54. grpchook-0.0.2/tests/integration/broadcast/clients_broadcast.py +102 -0
  55. grpchook-0.0.2/tests/integration/broadcast/server_broadcast.py +72 -0
  56. grpchook-0.0.2/tests/integration/config_client/__init__.py +0 -0
  57. grpchook-0.0.2/tests/integration/config_client/clients_config_client.py +117 -0
  58. grpchook-0.0.2/tests/integration/config_client/server_config_client.py +77 -0
  59. grpchook-0.0.2/tests/integration/custom_interface/__init__.py +0 -0
  60. grpchook-0.0.2/tests/integration/custom_interface/_proto_setup.py +52 -0
  61. grpchook-0.0.2/tests/integration/custom_interface/clients_custom_interface.py +65 -0
  62. grpchook-0.0.2/tests/integration/custom_interface/custom_if/.gitignore +3 -0
  63. grpchook-0.0.2/tests/integration/custom_interface/custom_if/message.proto +50 -0
  64. grpchook-0.0.2/tests/integration/custom_interface/server_custom_interface.py +27 -0
  65. grpchook-0.0.2/tests/integration/exception_handling/__init__.py +0 -0
  66. grpchook-0.0.2/tests/integration/exception_handling/clients_exception_handling.py +123 -0
  67. grpchook-0.0.2/tests/integration/exception_handling/server_exception_handling.py +24 -0
  68. grpchook-0.0.2/tests/integration/high_fire/__init__.py +0 -0
  69. grpchook-0.0.2/tests/integration/high_fire/clients_high_fire.py +114 -0
  70. grpchook-0.0.2/tests/integration/high_fire/server_high_fire.py +11 -0
  71. grpchook-0.0.2/tests/integration/history/__init__.py +0 -0
  72. grpchook-0.0.2/tests/integration/history/clients_history.py +115 -0
  73. grpchook-0.0.2/tests/integration/history/server_history.py +36 -0
  74. grpchook-0.0.2/tests/integration/password/__init__.py +0 -0
  75. grpchook-0.0.2/tests/integration/password/clients_password.py +115 -0
  76. grpchook-0.0.2/tests/integration/password/server_password.py +46 -0
  77. grpchook-0.0.2/tests/integration/run_integration_tests.py +258 -0
  78. grpchook-0.0.2/tests/integration/server_disconnect/__init__.py +0 -0
  79. grpchook-0.0.2/tests/integration/server_disconnect/clients_server_disconnect.py +85 -0
  80. grpchook-0.0.2/tests/integration/server_disconnect/server_server_disconnect.py +50 -0
  81. grpchook-0.0.2/tests/integration/server_off/__init__.py +0 -0
  82. grpchook-0.0.2/tests/integration/server_off/clients_server_off.py +46 -0
  83. grpchook-0.0.2/tests/integration/static_data/__init__.py +0 -0
  84. grpchook-0.0.2/tests/integration/static_data/clients_static_data.py +80 -0
  85. grpchook-0.0.2/tests/integration/static_data/server_static_data.py +97 -0
  86. grpchook-0.0.2/tests/integration/timer/__init__.py +0 -0
  87. grpchook-0.0.2/tests/integration/timer/clients_timer.py +111 -0
  88. grpchook-0.0.2/tests/integration/timer/server_timer.py +11 -0
  89. grpchook-0.0.2/tests/integration/wait_for_clients/__init__.py +0 -0
  90. grpchook-0.0.2/tests/integration/wait_for_clients/clients_wait_for_clients.py +126 -0
  91. grpchook-0.0.2/tests/integration/wait_for_clients/server_wait_for_clients.py +92 -0
  92. grpchook-0.0.2/tests/test_base_client.py +278 -0
  93. grpchook-0.0.2/tests/test_base_server.py +183 -0
  94. grpchook-0.0.2/tests/test_cli_installed.py +450 -0
  95. grpchook-0.0.2/tests/test_data_register.py +225 -0
  96. grpchook-0.0.2/tests/test_logger.py +167 -0
  97. grpchook-0.0.2/tests/test_tools.py +246 -0
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Fabian Krumm
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.1
2
+ Name: grpchook
3
+ Version: 0.0.2
4
+ Summary: grpchook (grpc + hook) is an asynchronous Python gRPC bidirectional-streaming framework. Subclass BaseServer/BaseClient, override hooks — the base handles all gRPC plumbing.
5
+ Author-Email: Fabian Krumm <ghub_fk@gmx.de>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: License :: OSI Approved :: BSD License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Classifier: Topic :: System :: Networking
15
+ Project-URL: homepage, https://github.com/fwkrumm/grpchook
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: grpcio>=1.81.0
18
+ Requires-Dist: grpcio-tools>=1.81.0
19
+ Requires-Dist: protobuf>=6.0.0
20
+ Requires-Dist: coloredlogs>=15.0
21
+ Requires-Dist: psutil>=5.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # grpchook
25
+
26
+ **grpchook** (gRPC + hook) is a Python framework for building asynchronous gRPC bidirectional-streaming services. Subclass `BaseServer` and `BaseClient`, override the hooks you need — the framework handles all gRPC plumbing.
27
+
28
+ [![PyPI](https://img.shields.io/pypi/v/grpchook)](https://pypi.org/project/grpchook/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/grpchook)](https://pypi.org/project/grpchook/)
30
+ [![License](https://img.shields.io/badge/license-BSD%203--Clause-blue)](LICENSE.txt)
31
+
32
+ > **Status: Work in Progress.**
33
+ > The project is open source and will remain open source.
34
+ > Treat with caution. If you depend on it, **pin your version**.
35
+
36
+ ---
37
+
38
+ ## Table of Contents
39
+
40
+ - [Disclaimer](#disclaimer)
41
+ - [When to Use and When Not to Use grpchook](#when-to-use-and-when-not-to-use-grpchook)
42
+ - [Requirements](#requirements)
43
+ - [Installation](#installation)
44
+ - [From PyPI](#from-pypi)
45
+ - [From Source](#from-source)
46
+ - [Quick Start](#quick-start)
47
+ - [Examples](#examples)
48
+ - [Testing](#testing)
49
+ - [Regenerating the gRPC Interface](#regenerating-the-grpc-interface)
50
+ - [ToDos & Roadmap](#todos-roadmap)
51
+ - [Known Issues & Troubleshooting](#known-issues-troubleshooting)
52
+ - [Contributing](#contributing)
53
+ - [License](#license)
54
+ - [Release History](#release-history)
55
+
56
+ ---
57
+ <a name="disclaimer"></a>
58
+ <a id="disclaimer"></a>
59
+
60
+ ## Disclaimer
61
+
62
+ Core concept by a human developer. AI was used to assist with unit and integration tests, examples, documentation, and selected code sections. Core logic has been reviewed by a human; the test suite has not been fully audited — there may be AI-introduced oversights. Please report any issues you find.
63
+
64
+ This software is provided **"as is"**, without warranty of any kind. The developer is not responsible for any damage, data loss, security vulnerabilities, or other issues that may arise from using this software. **You use it at your own risk.** See [LICENSE.txt](LICENSE.txt) for the full BSD 3-Clause terms.
65
+
66
+
67
+ ---
68
+ <a name="when-to-use-and-when-not-to-use-grpchook"></a>
69
+ <a id="when-to-use-and-when-not-to-use-grpchook"></a>
70
+ ## When to Use and When Not to Use grpchook
71
+
72
+ ### When to Use grpchook
73
+ - You need a simple, Python-based gRPC bidirectional streaming server and client.
74
+ - You want a data exchange blueprint for developers or AI agents to build on top of.
75
+ - You want a framework that can be extended with custom hooks for specific events.
76
+ - You want to distribute clients to many different machines (e.g. voice recorder, voice to text, text to LLM, and vice versa until the final response is replayed)
77
+
78
+ **Example — four clients on four machines, all routed through one grpchook server:**
79
+
80
+ > 💡 Diagram requires the [Markdown Preview Mermaid Support](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) extension to render in VS Code.
81
+
82
+ ```mermaid
83
+ flowchart LR
84
+ subgraph M1["📦 Machine 1"]
85
+ VR["🎤 Voice Recorder"]
86
+ end
87
+ subgraph M2["📦 Machine 2"]
88
+ STT["📝 Speech-to-Text"]
89
+ end
90
+ subgraph M3["📦 Machine 3"]
91
+ LLM["🤖 LLM Processor"]
92
+ end
93
+ subgraph M4["📦 Machine 4"]
94
+ RP["🔊 Voice Replay"]
95
+ end
96
+
97
+ SRV(["⚙️ grpchook Server"])
98
+
99
+ VR -->|"① audio"| SRV
100
+ SRV -->|"① audio"| STT
101
+ STT -->|"② transcript"| SRV
102
+ SRV -->|"② transcript"| LLM
103
+ LLM -->|"③ llm_response"| SRV
104
+ SRV -->|"③ llm_response"| RP
105
+ ```
106
+
107
+ ### When Not to Use grpchook
108
+ - When you need a very large number of clients; the threading model may introduce overhead.
109
+ - When you need direct peer-to-peer communication without a server intermediary; grpchook routes all messages through a central server.
110
+ - You want a framework that supports multiple programming languages out of the box; grpchook is (currently) Python-only.
111
+
112
+ ---
113
+ <a name="requirements"></a>
114
+ <a id="requirements"></a>
115
+
116
+ ## Requirements
117
+
118
+ - Python 3.10 or later
119
+ - A dedicated virtual environment is **strongly recommended** — gRPC version conflicts with other packages are common when using grpchook.
120
+
121
+ ---
122
+ <a name="installation"></a>
123
+ <a id="installation"></a>
124
+
125
+ ## Installation
126
+
127
+ <a name="from-pypi"></a>
128
+ <a id="from-pypi"></a>
129
+ ### From PyPI
130
+
131
+ ```bash
132
+ pip install grpchook
133
+ ```
134
+
135
+ <a name="from-source"></a>
136
+ <a id="from-source"></a>
137
+ ### From Source
138
+
139
+ ```bash
140
+ git clone https://github.com/fwkrumm/grpchook.git
141
+ cd grpchook
142
+ pip install -e .
143
+ ```
144
+
145
+ ---
146
+ <a name="quick-start"></a>
147
+ <a id="quick-start"></a>
148
+
149
+ ## Quick Start
150
+
151
+ Refer to [HOW_TO.md](grpchook/assets/HOW_TO.md) for the full API reference and code examples.
152
+ Alternatively run
153
+
154
+ ```bash
155
+ python -m grpchook --generate-skeletons
156
+ ```
157
+
158
+ to generate a very basic server and client skeleton in the current directory.
159
+ Use
160
+
161
+ ```bash
162
+ python -m grpchook --generate-interface-with-skeletons
163
+ ```
164
+
165
+ to generate the skeletons along with a copy of the `message.proto` interface file in the current directory to modify which is then used by the skeletons.
166
+
167
+ ---
168
+ <a name="examples"></a>
169
+ <a id="examples"></a>
170
+
171
+ ## Examples
172
+
173
+ Runnable examples are available in two locations:
174
+
175
+ - `examples/` — self-contained, scenario-focused examples
176
+ - `tests/integration/` — integration test scenarios covering a broad range of use cases
177
+
178
+ Run them on a machine with adequate resources; some scenarios are resource-intensive.
179
+
180
+ ---
181
+ <a name="testing"></a>
182
+ <a id="testing"></a>
183
+
184
+ ## Testing
185
+
186
+ Install dev dependencies and run the unit tests:
187
+
188
+ ```bash
189
+ pip install -r requirements_dev.txt
190
+ python -m unittest discover -s tests
191
+ ```
192
+
193
+ Integration tests are in `tests/integration/` and can be run via:
194
+
195
+ ```bash
196
+ python tests/integration/run_integration_tests.py
197
+ ```
198
+
199
+ ---
200
+ <a name="regenerating-the-grpc-interface"></a>
201
+ <a id="regenerating-the-grpc-interface"></a>
202
+
203
+
204
+ ## Regenerating the gRPC Interface
205
+
206
+ If you modify `grpchook/message.proto` after cloning the repository, regenerate the Python bindings with:
207
+
208
+ ```bash
209
+ python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. --pyi_out=. grpchook/message.proto
210
+ ```
211
+
212
+ Note that all clients which connect to a server have to use the same proto schema version i.e. the same proto file. The different signals for the clients must be used in substructures:
213
+
214
+ ```proto
215
+ message Payload {
216
+ // For client A
217
+ SomeTypeA payloadClientA = 1;
218
+
219
+ // For client B
220
+ SomeTypeB payloadClientB = 2;
221
+
222
+ ...
223
+ }
224
+
225
+ ```
226
+
227
+ ---
228
+ <a name="todos-roadmap"></a>
229
+ <a id="todos-roadmap"></a>
230
+
231
+ ## ToDos & Roadmap
232
+
233
+ ### Performance & Stability
234
+ - Evaluate replacing the threading model with `asyncio` if the performance gain justifies the API tradeoff.
235
+ - Verify behavior when connections are interrupted mid-stream; ensure no ghost threads or queue deadlocks occur.
236
+
237
+ ### Planned Features
238
+ - Multi-language client example (e.g., C++ or Rust).
239
+ - SSL/TLS usage example.
240
+
241
+
242
+ ---
243
+ <a name="known-issues-troubleshooting"></a>
244
+ <a id="known-issues-troubleshooting"></a>
245
+
246
+ ## Known Issues & Troubleshooting
247
+
248
+ TBD
249
+
250
+ ---
251
+ <a name="contributing"></a>
252
+ <a id="contributing"></a>
253
+
254
+ ## Contributing
255
+
256
+ Contributions are welcome. Please open an issue first for major changes so the approach can be discussed. For bug fixes and small improvements, a pull request is sufficient.
257
+
258
+ ---
259
+ <a name="license"></a>
260
+ <a id="license"></a>
261
+
262
+ ## License
263
+
264
+ BSD 3-Clause — see [LICENSE.txt](LICENSE.txt).
265
+
266
+ ---
267
+ <a name="release-history"></a>
268
+ <a id="release-history"></a>
269
+
270
+ ## Release History
271
+
272
+
273
+ | Version / Git Tag on Master | Description |
274
+ |----------------------------|-------------|
275
+ | 0.0.1 | Initial public release. |
File without changes
@@ -0,0 +1,16 @@
1
+ """Minimal gRPC server for the interactive streaming example."""
2
+ from grpchook.baseserver import BaseServer
3
+
4
+
5
+ class GrpcServer(BaseServer):
6
+ """
7
+ Minimal server for interactive streaming example.
8
+ """
9
+
10
+ def __init__(self, port: int = 49999):
11
+ super().__init__(port)
12
+
13
+
14
+ if __name__ == "__main__":
15
+ s = GrpcServer(49999)
16
+ s.serve_forever()
@@ -0,0 +1,100 @@
1
+ """LM Studio proxy client: receives lm_request, streams lm_response_stream chunks."""
2
+ import threading
3
+ import time
4
+
5
+ from grpchook import message_pb2
6
+ from grpchook.baseclient import BaseClient
7
+ from grpchook.tools import json_to_struct, struct_to_json
8
+ from examples.interactive_streaming import _lm_http
9
+ from examples.interactive_streaming._lm_http import (
10
+ _iter_stream, _fetch_sync, _offline_stream, make_http_session,
11
+ )
12
+
13
+
14
+ try:
15
+ import requests
16
+ except ImportError:
17
+ requests = None
18
+
19
+
20
+ class LMProxyClient(BaseClient):
21
+ """Proxy: receives lm_request, queries LM Studio, streams lm_response_stream chunks."""
22
+
23
+ def __init__(self, name: str, port: int, lmstudio_base: str | None = None,
24
+ model: str = "gemma-4e2b"):
25
+ super().__init__(port, name=name, provides=["lm_response_stream"], requires=["lm_request"])
26
+ self.lmstudio_base = lmstudio_base or "http://127.0.0.1:1234/v1"
27
+ self.model = model
28
+
29
+ if requests:
30
+ sess = make_http_session()
31
+ if sess:
32
+ _lm_http._session = sess
33
+ self.logger.debug("persistent HTTP session created")
34
+ else:
35
+ self.logger.warning("requests not installed — HTTP calls will fail")
36
+
37
+ threading.Thread(target=self.spin_forever, daemon=True).start()
38
+
39
+ def _send_chunk(self, request: message_pb2.Message, text: str,
40
+ done: bool = False):
41
+ msg = message_pb2.Message(
42
+ metaInfo=message_pb2.MetaInformation(messageName="lm_response_stream"),
43
+ payload=message_pb2.Payload(
44
+ structPayload=json_to_struct({"chunk": text, "done": done})
45
+ ),
46
+ )
47
+ msg.metaInfo.messageId = request.metaInfo.messageId
48
+ self.send_data(msg)
49
+
50
+ def _handle_request(self, request: message_pb2.Message):
51
+ """Forward an lm_request to LM Studio and stream back the response."""
52
+ try:
53
+ prompt = struct_to_json(request.payload.structPayload).get("text", "")
54
+ except (ValueError, TypeError, AttributeError):
55
+ prompt = ""
56
+
57
+ if not prompt:
58
+ self._send_chunk(request, "", done=True)
59
+ return
60
+
61
+ mid = request.metaInfo.messageId
62
+ self.logger.info("Forwarding to LM Studio %s messageId=%s", self.lmstudio_base, mid)
63
+
64
+ had_chunks = False
65
+ try:
66
+ for chunk in _iter_stream(prompt, self.lmstudio_base, self.model):
67
+ if not self.run_event.is_set():
68
+ self.logger.debug("proxy shutting down, aborting stream messageId=%s", mid)
69
+ return
70
+ self._send_chunk(request, chunk)
71
+ had_chunks = True
72
+ except OSError:
73
+ self.logger.exception("stream failed messageId=%s", mid)
74
+
75
+ if not had_chunks:
76
+ try:
77
+ self._send_chunk(request, _fetch_sync(prompt, self.lmstudio_base, self.model))
78
+ had_chunks = True
79
+ except OSError:
80
+ self.logger.exception("sync fetch failed messageId=%s", mid)
81
+
82
+ if not had_chunks:
83
+ self.logger.warning("LM Studio unreachable, using offline stream messageId=%s", mid)
84
+ for chunk in _offline_stream(prompt):
85
+ self._send_chunk(request, chunk)
86
+
87
+ self._send_chunk(request, "", done=True)
88
+
89
+ def on_receive(self, data: message_pb2.Message) -> bool:
90
+ threading.Thread(target=self._handle_request, args=(data,), daemon=True).start()
91
+ return True
92
+
93
+
94
+ if __name__ == "__main__":
95
+ proxy = LMProxyClient("lm-proxy", 49999)
96
+ try:
97
+ while True:
98
+ time.sleep(1)
99
+ except KeyboardInterrupt:
100
+ proxy.disconnect()
@@ -0,0 +1,102 @@
1
+ """Interactive text client for the LM Studio streaming example."""
2
+ import uuid
3
+ import queue
4
+
5
+ from grpchook import message_pb2
6
+ from grpchook.baseclient import BaseClient
7
+ from grpchook.tools import json_to_struct, struct_to_json
8
+
9
+
10
+ class TextClient(BaseClient):
11
+ """
12
+ Interactive text client. Sends `lm_request` and receives streaming
13
+ `lm_response_stream` messages (partial chunks with `done` flag).
14
+ """
15
+
16
+ def __init__(self, identifier: str, port: int):
17
+ super().__init__(port, name=identifier,
18
+ provides=["lm_request"],
19
+ requires=["lm_response_stream"])
20
+ self.logger.setLevel("WARNING")
21
+
22
+ def on_receive(self, data: message_pb2.Message) -> bool:
23
+ try:
24
+ payload = (
25
+ struct_to_json(data.payload.structPayload)
26
+ if data.payload and data.payload.structPayload
27
+ else {}
28
+ )
29
+ except (ValueError, TypeError, AttributeError):
30
+ payload = {}
31
+
32
+ chunk = payload.get("chunk", "")
33
+ done = payload.get("done", False)
34
+
35
+ if chunk:
36
+ print(chunk, end="", flush=True)
37
+ if done:
38
+ print()
39
+ return True
40
+
41
+ def interactive_loop(self):
42
+ """
43
+ Prompt user, send request, stream responses until `done` True.
44
+ """
45
+ try:
46
+ while True:
47
+ text = input("You: ").strip()
48
+ if not text:
49
+ continue
50
+ if text.lower() in ("exit", "quit"):
51
+ break
52
+
53
+ msg_id = str(uuid.uuid4())
54
+ msg = message_pb2.Message(
55
+ metaInfo=message_pb2.MetaInformation(
56
+ messageId=msg_id,
57
+ messageName="lm_request",
58
+ ),
59
+ payload=message_pb2.Payload(structPayload=json_to_struct({"text": text}))
60
+ )
61
+
62
+ self.send_data(msg)
63
+
64
+ # collect streaming chunks for this request
65
+ while True:
66
+ try:
67
+ resp = self.get_data()
68
+ except queue.Empty:
69
+ print("\nTimeout waiting for response")
70
+ break
71
+ except (RuntimeError, OSError) as e:
72
+ print("\nClient stopped:", e)
73
+ return
74
+
75
+ if resp.metaInfo.messageId != msg_id:
76
+ # not for this request; ignore
77
+ continue
78
+
79
+ try:
80
+ payload = (
81
+ struct_to_json(resp.payload.structPayload)
82
+ if resp.payload and resp.payload.structPayload
83
+ else {}
84
+ )
85
+ except (ValueError, TypeError, AttributeError):
86
+ payload = {}
87
+
88
+ chunk = payload.get("chunk", "")
89
+ done = payload.get("done", False)
90
+ if chunk:
91
+ print(chunk, end="", flush=True)
92
+ if done:
93
+ print()
94
+ break
95
+
96
+ finally:
97
+ self.disconnect()
98
+
99
+
100
+ if __name__ == "__main__":
101
+ client = TextClient("text-ui", 49999)
102
+ client.interactive_loop()
@@ -0,0 +1,144 @@
1
+ """HTTP helpers for LMProxyClient: streaming/sync LM Studio calls."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import time
8
+
9
+ try:
10
+ import requests
11
+ from requests.adapters import HTTPAdapter as _HTTPAdapter
12
+ except ImportError:
13
+ requests = None
14
+ _HTTPAdapter = None
15
+
16
+ from grpchook.logger import get_logger
17
+
18
+ SYSTEM_PROMPT = os.environ.get(
19
+ "LMSTUDIO_SYSTEM_PROMPT",
20
+ "Use only printable UTF-8 characters. Reply in plain text without special symbols or emojis.",
21
+ )
22
+
23
+ # Set by LMProxyClient.__init__() to a persistent requests.Session.
24
+ _session = None
25
+
26
+
27
+ def _sanitize_text(s: object) -> str:
28
+ if s is None:
29
+ return ""
30
+ if isinstance(s, bytes):
31
+ s = s.decode("utf-8", errors="ignore")
32
+ s = str(s)
33
+ s = s.replace("\ufffd", "")
34
+ s = re.sub(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]", "", s)
35
+ return s
36
+
37
+
38
+ def _build_messages(prompt: str) -> list:
39
+ return [
40
+ {"role": "system", "content": SYSTEM_PROMPT},
41
+ {"role": "user", "content": prompt},
42
+ ]
43
+
44
+
45
+ def _extract_text(j: dict) -> str | None:
46
+ """Return the text content from a parsed SSE JSON object, or None."""
47
+ if "choices" in j and j["choices"]:
48
+ c = j["choices"][0]
49
+ return c.get("delta", {}).get("content") or c.get("text")
50
+ return j.get("text") or j.get("token")
51
+
52
+
53
+ def _iter_stream(prompt: str, base_url: str, model: str):
54
+ """Stream from OpenAI-compatible /chat/completions. Yields sanitized text chunks."""
55
+ api_key = os.environ.get("LMSTUDIO_API_KEY") or os.environ.get("OPENAI_API_KEY")
56
+ url = base_url.rstrip("/") + "/chat/completions"
57
+ headers = {"Accept": "text/event-stream", "Content-Type": "application/json"}
58
+ if api_key:
59
+ headers["Authorization"] = f"Bearer {api_key}"
60
+ payload = {"model": model, "messages": _build_messages(prompt), "stream": True}
61
+ log = get_logger("LMProxyClient", log_level=logging.DEBUG)
62
+ sess = _session or requests
63
+ log.debug("stream POST %s model=%s", url, model)
64
+ n_chunks = 0
65
+ with sess.post(url, json=payload, stream=True, headers=headers, timeout=(5, None)) as r:
66
+ log.debug("stream response status=%s", r.status_code)
67
+ if not 200 <= r.status_code < 300:
68
+ log.warning("stream got non-2xx status=%s", r.status_code)
69
+ return
70
+ for raw in r.iter_lines(decode_unicode=False):
71
+ if not raw:
72
+ continue
73
+ line = (
74
+ raw.decode("utf-8", errors="ignore").strip()
75
+ if isinstance(raw, bytes)
76
+ else str(raw).strip()
77
+ )
78
+ if line.startswith("data: "):
79
+ line = line[6:]
80
+ if line == "[DONE]":
81
+ log.debug("stream [DONE] after %d chunks", n_chunks)
82
+ return
83
+ try:
84
+ j = json.loads(line)
85
+ except json.JSONDecodeError:
86
+ yield _sanitize_text(line)
87
+ n_chunks += 1
88
+ continue
89
+ text = _extract_text(j)
90
+ if text:
91
+ if n_chunks == 0:
92
+ log.debug("stream first chunk received")
93
+ n_chunks += 1
94
+ yield _sanitize_text(text)
95
+
96
+
97
+ def _fetch_sync(prompt: str, base_url: str, model: str) -> str:
98
+ """Synchronous /chat/completions fallback. Returns text or raises."""
99
+ api_key = os.environ.get("LMSTUDIO_API_KEY") or os.environ.get("OPENAI_API_KEY")
100
+ url = base_url.rstrip("/") + "/chat/completions"
101
+ headers = {"Content-Type": "application/json"}
102
+ if api_key:
103
+ headers["Authorization"] = f"Bearer {api_key}"
104
+ payload = {"model": model, "messages": _build_messages(prompt)}
105
+ log = get_logger("LMProxyClient", log_level=logging.DEBUG)
106
+ sess = _session or requests
107
+ log.debug("sync POST %s model=%s", url, model)
108
+ r = sess.post(url, json=payload, headers=headers, timeout=30)
109
+ log.debug("sync response status=%s", r.status_code)
110
+ r.raise_for_status()
111
+ j = r.json()
112
+ if "choices" in j and j["choices"]:
113
+ c = j["choices"][0]
114
+ text = (c.get("message") or {}).get("content") or c.get("text")
115
+ if text:
116
+ log.debug("sync got text len=%d", len(text))
117
+ return _sanitize_text(text)
118
+ raise RuntimeError("No text in sync response")
119
+
120
+
121
+ def _offline_stream(_prompt: str):
122
+ """Multi-chunk offline fallback when LM Studio is unreachable."""
123
+ chunks = [
124
+ "LM Studio is not available. This is a static offline response.",
125
+ " It simulates streaming by sending multiple chunks.",
126
+ " Use it to test client rendering and chunk reassembly.",
127
+ ]
128
+ for chunk in chunks:
129
+ yield _sanitize_text(chunk)
130
+ time.sleep(0.05)
131
+
132
+
133
+ def make_http_session():
134
+ """Create a persistent requests.Session with connection pooling.
135
+
136
+ Returns None when requests is not installed.
137
+ """
138
+ if requests is None or _HTTPAdapter is None:
139
+ return None
140
+ sess = requests.Session()
141
+ adapter = _HTTPAdapter(pool_connections=4, pool_maxsize=16)
142
+ sess.mount("http://", adapter)
143
+ sess.mount("https://", adapter)
144
+ return sess