fusion-runtime 0.1.2__tar.gz → 0.1.4__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 (145) hide show
  1. {fusion_runtime-0.1.2/fusion_runtime.egg-info → fusion_runtime-0.1.4}/PKG-INFO +71 -2
  2. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/README.md +70 -1
  3. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/agent.py +4 -3
  4. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/download.py +76 -9
  5. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/_checks.py +14 -7
  6. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/app.py +2 -0
  7. fusion_runtime-0.1.4/fusion_runtime/cli/bench.py +280 -0
  8. fusion_runtime-0.1.4/fusion_runtime/cli/bench_audio/hello.wav +0 -0
  9. fusion_runtime-0.1.4/fusion_runtime/cli/bench_audio/order_1042.wav +0 -0
  10. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/keys.py +4 -6
  11. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/models.py +34 -8
  12. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/up.py +137 -24
  13. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/config.py +27 -2
  14. fusion_runtime-0.1.4/fusion_runtime/engine/audio_format.py +84 -0
  15. fusion_runtime-0.1.4/fusion_runtime/engine/audio_watch.py +108 -0
  16. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/barge_in.py +13 -0
  17. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/orchestrator.py +145 -31
  18. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/llm_server.py +148 -18
  19. fusion_runtime-0.1.4/fusion_runtime/phone/__init__.py +1 -0
  20. fusion_runtime-0.1.4/fusion_runtime/phone/exotel.py +118 -0
  21. fusion_runtime-0.1.4/fusion_runtime/phone/link.py +173 -0
  22. fusion_runtime-0.1.4/fusion_runtime/phone/mulaw.py +45 -0
  23. fusion_runtime-0.1.4/fusion_runtime/phone/plivo.py +147 -0
  24. fusion_runtime-0.1.4/fusion_runtime/phone/twilio.py +165 -0
  25. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/resolver.py +3 -0
  26. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/onnx/kokoro.py +19 -6
  27. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/onnx/tts.py +4 -1
  28. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/openai_http/llm.py +44 -1
  29. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/limits.py +23 -6
  30. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/server.py +363 -104
  31. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/metrics.py +6 -0
  32. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/trace.py +10 -0
  33. fusion_runtime-0.1.4/fusion_runtime/transport.py +139 -0
  34. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/web/fusion-runtime.js +1 -1
  35. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/web/index.html +18 -3
  36. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4/fusion_runtime.egg-info}/PKG-INFO +71 -2
  37. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/SOURCES.txt +20 -0
  38. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/pyproject.toml +2 -2
  39. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_agent.py +1 -1
  40. fusion_runtime-0.1.4/tests/test_audio_format.py +91 -0
  41. fusion_runtime-0.1.4/tests/test_audio_watch.py +156 -0
  42. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_banner.py +27 -0
  43. fusion_runtime-0.1.4/tests/test_bench.py +83 -0
  44. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_cli.py +40 -14
  45. fusion_runtime-0.1.4/tests/test_degraded.py +96 -0
  46. fusion_runtime-0.1.4/tests/test_digital_silence.py +61 -0
  47. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_doctor.py +7 -0
  48. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_greeting.py +42 -0
  49. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_http_llm.py +70 -3
  50. fusion_runtime-0.1.4/tests/test_interruption_switches.py +81 -0
  51. fusion_runtime-0.1.4/tests/test_knowledge_example.py +48 -0
  52. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_llm_server.py +130 -1
  53. fusion_runtime-0.1.4/tests/test_phone.py +361 -0
  54. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_runtimes.py +9 -0
  55. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_security.py +30 -1
  56. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_server_telemetry.py +13 -0
  57. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_telemetry.py +14 -0
  58. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_tools.py +41 -0
  59. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/LICENSE +0 -0
  60. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/NOTICE +0 -0
  61. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/__init__.py +0 -0
  62. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/audio/__init__.py +0 -0
  63. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/audio/duplex_audio.py +0 -0
  64. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/audio/echo_canceller.py +0 -0
  65. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/__init__.py +0 -0
  66. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/entries.py +0 -0
  67. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/gguf.py +0 -0
  68. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/models.toml +0 -0
  69. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/__init__.py +0 -0
  70. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/__main__.py +0 -0
  71. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/_common.py +0 -0
  72. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/_talk_client.py +0 -0
  73. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/banner.py +0 -0
  74. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/doctor.py +0 -0
  75. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/talk.py +0 -0
  76. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/version.py +0 -0
  77. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/__init__.py +0 -0
  78. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/common.py +0 -0
  79. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/llm.py +0 -0
  80. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/stt.py +0 -0
  81. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/tts.py +0 -0
  82. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/turn.py +0 -0
  83. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/__init__.py +0 -0
  84. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/conversation.py +0 -0
  85. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/scheduler.py +0 -0
  86. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/streaming.py +0 -0
  87. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/text.py +0 -0
  88. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/env.py +0 -0
  89. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/registry.py +0 -0
  90. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/__init__.py +0 -0
  91. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/ctranslate2/__init__.py +0 -0
  92. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/ctranslate2/stt.py +0 -0
  93. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/llama_cpp/__init__.py +0 -0
  94. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/llama_cpp/llm.py +0 -0
  95. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/onnx/__init__.py +0 -0
  96. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/openai_http/__init__.py +0 -0
  97. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/__init__.py +0 -0
  98. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/auth.py +0 -0
  99. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/keys.py +0 -0
  100. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/origins.py +0 -0
  101. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/tokens.py +0 -0
  102. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/__init__.py +0 -0
  103. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/errors.py +0 -0
  104. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/events.py +0 -0
  105. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/hub.py +0 -0
  106. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/loop_monitor.py +0 -0
  107. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/redact.py +0 -0
  108. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/sinks.py +0 -0
  109. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/testing/__init__.py +0 -0
  110. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/testing/conformance.py +0 -0
  111. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/testing/fakes.py +0 -0
  112. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/tools.py +0 -0
  113. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/turns/__init__.py +0 -0
  114. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/turns/silence.py +0 -0
  115. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/__init__.py +0 -0
  116. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/base.py +0 -0
  117. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/silero.py +0 -0
  118. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/turn.py +0 -0
  119. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/web/__init__.py +0 -0
  120. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/dependency_links.txt +0 -0
  121. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/entry_points.txt +0 -0
  122. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/requires.txt +0 -0
  123. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/top_level.txt +0 -0
  124. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/setup.cfg +0 -0
  125. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_barge_in.py +0 -0
  126. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_catalog.py +0 -0
  127. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_client_barge_in.py +0 -0
  128. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_conformance.py +0 -0
  129. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_contract.py +0 -0
  130. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_conversation.py +0 -0
  131. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_core.py +0 -0
  132. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_duplex_audio.py +0 -0
  133. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_echo_canceller.py +0 -0
  134. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_engine_streaming.py +0 -0
  135. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_env_file.py +0 -0
  136. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_event_loop_blocking.py +0 -0
  137. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_languages.py +0 -0
  138. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_llama_cpp_stream.py +0 -0
  139. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_model_paths.py +0 -0
  140. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_registry.py +0 -0
  141. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_resolver.py +0 -0
  142. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_scheduler.py +0 -0
  143. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_self_echo.py +0 -0
  144. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_turn_taking.py +0 -0
  145. {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusion-runtime
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Self-hosted voice agent runtime: speech-to-text, LLM and text-to-speech on your own GPU, with tools
5
5
  Author-email: Samarth Urs <hello@fusion-runtime.dev>
6
6
  License-Expression: Apache-2.0
@@ -81,6 +81,9 @@ Requires Python 3.11–3.13.
81
81
  pip install fusion-runtime
82
82
  ```
83
83
 
84
+ The first install can sit quiet for a few minutes: where there's no ready-made llama.cpp for your
85
+ machine, pip builds it. That's normal.
86
+
84
87
  An agent is one file. This is the whole thing:
85
88
 
86
89
  ```python
@@ -177,6 +180,32 @@ calling on), llama-server (`--jinja`) or a hosted API. The in-process
177
180
  llama.cpp runtime can't, and `frun up` says so at startup. A runnable version is
178
181
  [`examples/tools_agent.py`](examples/tools_agent.py).
179
182
 
183
+ ### Answering from your own documents
184
+
185
+ Retrieval is a tool like any other: a function that takes the caller's question and returns a few
186
+ short passages, which the model answers from.
187
+
188
+ ```python
189
+ @tool(timeout_s=3)
190
+ def search_help(question: str) -> dict:
191
+ """Search the shop's help articles: returns, refunds, delivery, cancelling orders.
192
+
193
+ Args:
194
+ question: What the caller wants to know, in their words.
195
+ """
196
+ return {"passages": my_search(question, limit=3)}
197
+ ```
198
+
199
+ `my_search` is yours: a vector database, Elasticsearch, your help centre's API.
200
+ [`examples/knowledge_agent.py`](examples/knowledge_agent.py) is a runnable version with a small
201
+ keyword search over the Markdown files in [`examples/knowledge/`](examples/knowledge/), in plain
202
+ Python with nothing to install.
203
+
204
+ On a call, two things matter more than in a chat window. A lookup costs a second model round
205
+ before the caller hears anything, so if your knowledge fits in a page or two, put it in the prompt
206
+ instead: that's faster than any search. And passages go into the prompt, so keep them short (a few
207
+ sentences each, a few per search): long ones slow the model's first word.
208
+
180
209
  ## On your own site
181
210
 
182
211
  ```html
@@ -191,6 +220,42 @@ site embeds. With no `url` it connects back to wherever the script came from.
191
220
  Browsers only allow a microphone on `https://`, so a deployment needs TLS and `wss://`. A page
192
221
  never holds an API key: your backend mints it a short-lived token.
193
222
 
223
+ ## Phone calls
224
+
225
+ Keep your own number and your provider account (and its KYC); the provider streams each call to
226
+ fusion, and the agent answers it. Twilio, Exotel and Plivo:
227
+
228
+ | Provider | Point the number at | Set where fusion runs |
229
+ |---|---|---|
230
+ | Twilio | voice webhook "A call comes in": `https://<server>/v1/phone/twilio` (POST) | `FUSION_TWILIO_AUTH_TOKEN`: the account's auth token |
231
+ | Exotel | a Voicebot applet in the call flow: `wss://fusion:<key>@<server>/v1/phone/exotel/stream?sample-rate=16000` | `FUSION_ACCEPTED_KEYS`, with the key in that URL |
232
+ | Plivo | the number's answer URL: `https://<server>/v1/phone/plivo` (POST) | `FUSION_PLIVO_AUTH_TOKEN`: the account's auth token |
233
+
234
+ Each request is checked before a call is taken: Twilio's webhook signature, the key in Exotel's
235
+ URL, Plivo's signature on the stream. Behind a proxy, set `FUSION_PUBLIC_URL` to the address the
236
+ provider calls (on a Runpod pod `frun up` sets it), since that's the address they sign.
237
+
238
+ A call gets everything a browser conversation does: the same limits, interruption (the provider
239
+ drops the agent's unplayed audio the moment the caller talks over it), tools and logs, with
240
+ `transport=twilio` and the call's id on `session.start`. On top of that:
241
+
242
+ - The greeting is made when the server starts, so a caller hears it within ~50 ms.
243
+ - A full server answers "all our lines are busy" rather than dropping the call.
244
+ - A dead line is faint hiss, not silence: nothing from the caller above `FUSION_PHONE_FLOOR_RMS`
245
+ (40) for `FUSION_DEAD_LINE_S` (10 s) is logged as a quiet line, and every call's `session.end`
246
+ records its loudest moment and whether the caller was heard at all.
247
+
248
+ Try it without a number or an account: [`tools/phone_sim`](tools/phone_sim/README.md) plays the
249
+ provider's side from your machine, with your microphone, at phone quality. All three are built
250
+ from the providers' documentation and tested with that simulator, not yet on real numbers; if your
251
+ account behaves differently, open an issue.
252
+
253
+ On Twilio, test with an upgraded account and a number of your own. A trial account's calls come
254
+ from a shared sandbox number that neither signs its requests nor streams the call's audio to a
255
+ server, and a newly upgraded account may hold calls until Twilio's KYC (a Trust Hub customer
256
+ profile) is approved. A refused webhook is logged with the address fusion checked, which tells a
257
+ wrong address from a wrong token.
258
+
194
259
  ## Authentication
195
260
 
196
261
  ```bash
@@ -241,7 +306,7 @@ console), so you can reproduce them rather than trusting ours. Barge-in fired on
241
306
  ## Several callers at once
242
307
 
243
308
  Measured on the same 3090, real WebSocket sessions, three turns each
244
- ([`scripts/concurrency_check.py`](scripts/concurrency_check.py)). Response is the server's own
309
+ (`frun bench`). Response is the server's own
245
310
  figure: the turn ends, audio comes back.
246
311
 
247
312
  | Callers | In-process llama.cpp | vLLM | SGLang |
@@ -287,9 +352,13 @@ fusion where it is:
287
352
  ```bash
288
353
  python3 -m venv ~/vllm-env && ~/vllm-env/bin/pip install vllm
289
354
  export FUSION_LLM_ENGINE_ENV=~/vllm-env
355
+ frun models pull agent.py # the LLM too (5.6 GB here), where vLLM looks for it
290
356
  frun up agent.py
291
357
  ```
292
358
 
359
+ Without the pull, vLLM downloads the model during its first start, which takes 5–15 minutes and
360
+ looks like nothing is happening.
361
+
293
362
  `frun up` starts the server before loading speech, so it takes its share of the GPU first (60%
294
363
  by default, which fits a 4-bit 7B model next to Whisper and Kokoro on a 24 GB card), restarts it
295
364
  if it exits, and stops it with Ctrl+C. GPU memory, context length, tool parsing, extra engine
@@ -27,6 +27,9 @@ Requires Python 3.11–3.13.
27
27
  pip install fusion-runtime
28
28
  ```
29
29
 
30
+ The first install can sit quiet for a few minutes: where there's no ready-made llama.cpp for your
31
+ machine, pip builds it. That's normal.
32
+
30
33
  An agent is one file. This is the whole thing:
31
34
 
32
35
  ```python
@@ -123,6 +126,32 @@ calling on), llama-server (`--jinja`) or a hosted API. The in-process
123
126
  llama.cpp runtime can't, and `frun up` says so at startup. A runnable version is
124
127
  [`examples/tools_agent.py`](examples/tools_agent.py).
125
128
 
129
+ ### Answering from your own documents
130
+
131
+ Retrieval is a tool like any other: a function that takes the caller's question and returns a few
132
+ short passages, which the model answers from.
133
+
134
+ ```python
135
+ @tool(timeout_s=3)
136
+ def search_help(question: str) -> dict:
137
+ """Search the shop's help articles: returns, refunds, delivery, cancelling orders.
138
+
139
+ Args:
140
+ question: What the caller wants to know, in their words.
141
+ """
142
+ return {"passages": my_search(question, limit=3)}
143
+ ```
144
+
145
+ `my_search` is yours: a vector database, Elasticsearch, your help centre's API.
146
+ [`examples/knowledge_agent.py`](examples/knowledge_agent.py) is a runnable version with a small
147
+ keyword search over the Markdown files in [`examples/knowledge/`](examples/knowledge/), in plain
148
+ Python with nothing to install.
149
+
150
+ On a call, two things matter more than in a chat window. A lookup costs a second model round
151
+ before the caller hears anything, so if your knowledge fits in a page or two, put it in the prompt
152
+ instead: that's faster than any search. And passages go into the prompt, so keep them short (a few
153
+ sentences each, a few per search): long ones slow the model's first word.
154
+
126
155
  ## On your own site
127
156
 
128
157
  ```html
@@ -137,6 +166,42 @@ site embeds. With no `url` it connects back to wherever the script came from.
137
166
  Browsers only allow a microphone on `https://`, so a deployment needs TLS and `wss://`. A page
138
167
  never holds an API key: your backend mints it a short-lived token.
139
168
 
169
+ ## Phone calls
170
+
171
+ Keep your own number and your provider account (and its KYC); the provider streams each call to
172
+ fusion, and the agent answers it. Twilio, Exotel and Plivo:
173
+
174
+ | Provider | Point the number at | Set where fusion runs |
175
+ |---|---|---|
176
+ | Twilio | voice webhook "A call comes in": `https://<server>/v1/phone/twilio` (POST) | `FUSION_TWILIO_AUTH_TOKEN`: the account's auth token |
177
+ | Exotel | a Voicebot applet in the call flow: `wss://fusion:<key>@<server>/v1/phone/exotel/stream?sample-rate=16000` | `FUSION_ACCEPTED_KEYS`, with the key in that URL |
178
+ | Plivo | the number's answer URL: `https://<server>/v1/phone/plivo` (POST) | `FUSION_PLIVO_AUTH_TOKEN`: the account's auth token |
179
+
180
+ Each request is checked before a call is taken: Twilio's webhook signature, the key in Exotel's
181
+ URL, Plivo's signature on the stream. Behind a proxy, set `FUSION_PUBLIC_URL` to the address the
182
+ provider calls (on a Runpod pod `frun up` sets it), since that's the address they sign.
183
+
184
+ A call gets everything a browser conversation does: the same limits, interruption (the provider
185
+ drops the agent's unplayed audio the moment the caller talks over it), tools and logs, with
186
+ `transport=twilio` and the call's id on `session.start`. On top of that:
187
+
188
+ - The greeting is made when the server starts, so a caller hears it within ~50 ms.
189
+ - A full server answers "all our lines are busy" rather than dropping the call.
190
+ - A dead line is faint hiss, not silence: nothing from the caller above `FUSION_PHONE_FLOOR_RMS`
191
+ (40) for `FUSION_DEAD_LINE_S` (10 s) is logged as a quiet line, and every call's `session.end`
192
+ records its loudest moment and whether the caller was heard at all.
193
+
194
+ Try it without a number or an account: [`tools/phone_sim`](tools/phone_sim/README.md) plays the
195
+ provider's side from your machine, with your microphone, at phone quality. All three are built
196
+ from the providers' documentation and tested with that simulator, not yet on real numbers; if your
197
+ account behaves differently, open an issue.
198
+
199
+ On Twilio, test with an upgraded account and a number of your own. A trial account's calls come
200
+ from a shared sandbox number that neither signs its requests nor streams the call's audio to a
201
+ server, and a newly upgraded account may hold calls until Twilio's KYC (a Trust Hub customer
202
+ profile) is approved. A refused webhook is logged with the address fusion checked, which tells a
203
+ wrong address from a wrong token.
204
+
140
205
  ## Authentication
141
206
 
142
207
  ```bash
@@ -187,7 +252,7 @@ console), so you can reproduce them rather than trusting ours. Barge-in fired on
187
252
  ## Several callers at once
188
253
 
189
254
  Measured on the same 3090, real WebSocket sessions, three turns each
190
- ([`scripts/concurrency_check.py`](scripts/concurrency_check.py)). Response is the server's own
255
+ (`frun bench`). Response is the server's own
191
256
  figure: the turn ends, audio comes back.
192
257
 
193
258
  | Callers | In-process llama.cpp | vLLM | SGLang |
@@ -233,9 +298,13 @@ fusion where it is:
233
298
  ```bash
234
299
  python3 -m venv ~/vllm-env && ~/vllm-env/bin/pip install vllm
235
300
  export FUSION_LLM_ENGINE_ENV=~/vllm-env
301
+ frun models pull agent.py # the LLM too (5.6 GB here), where vLLM looks for it
236
302
  frun up agent.py
237
303
  ```
238
304
 
305
+ Without the pull, vLLM downloads the model during its first start, which takes 5–15 minutes and
306
+ looks like nothing is happening.
307
+
239
308
  `frun up` starts the server before loading speech, so it takes its share of the GPU first (60%
240
309
  by default, which fits a 4-bit 7B model next to Whisper and Kokoro on a 24 GB card), restarts it
241
310
  if it exits, and stops it with Ctrl+C. GPU memory, context length, tool parsing, extra engine
@@ -220,7 +220,8 @@ class Agent:
220
220
  vad: Optional[VAD] = None
221
221
  tools: Sequence[Any] = () # functions the model can call; plain functions become tools
222
222
  greeting: Optional[str] = None # said when a caller connects, before they speak; None = wait for them
223
- profile: str = "development" # the defaults everything above is applied to
223
+ greeting_interruptible: bool = True # False: always heard in full ("this call may be recorded")
224
+ profile: str = "development" # the defaults everything above is applied to: development (laptops), production (GPU)
224
225
  source: Optional[Path] = None # the file it was loaded from, when it came from one
225
226
 
226
227
  def __post_init__(self) -> None:
@@ -248,10 +249,10 @@ class Agent:
248
249
 
249
250
  def config(self, environ: Optional[Mapping[str, str]] = None):
250
251
  """This agent as a PipelineConfig: profile defaults, the agent on top, then the environment."""
251
- from fusion_runtime.config import PROFILES, with_env_overrides
252
+ from fusion_runtime.config import PROFILES, profile_choices, with_env_overrides
252
253
 
253
254
  if self.profile not in PROFILES:
254
- raise AgentError(f"unknown profile {self.profile!r}; choose one of: {', '.join(PROFILES)}")
255
+ raise AgentError(f"unknown profile {self.profile!r}; choose one of: {profile_choices()}")
255
256
  config = PROFILES[self.profile]
256
257
  parts: Dict[str, Any] = {}
257
258
 
@@ -45,7 +45,7 @@ def bytes_to_download(entry: ModelEntry, root: Path, force: bool = False) -> int
45
45
  return sum(entry.files[n] for n in names)
46
46
 
47
47
 
48
- def check_disk_space(needed: int, root: Path) -> None:
48
+ def check_disk_space(needed: int, root: Path, setting: str = "FUSION_MODEL_DIR") -> None:
49
49
  probe = root
50
50
  while not probe.exists(): # model dir may not exist yet
51
51
  probe = probe.parent
@@ -54,7 +54,7 @@ def check_disk_space(needed: int, root: Path) -> None:
54
54
  raise NotEnoughDiskSpace(
55
55
  f"Need {format_size(needed)} (plus {format_size(DISK_HEADROOM_BYTES)} headroom) "
56
56
  f"but only {format_size(free)} is free at {probe}. "
57
- "Free up space or set FUSION_MODEL_DIR to a bigger disk."
57
+ f"Free up space or set {setting} to a bigger disk."
58
58
  )
59
59
 
60
60
 
@@ -189,16 +189,83 @@ def pull_hf(ref: str, root: Path, log: Callable[[str], None] = print, force: boo
189
189
  except DownloadError:
190
190
  raise
191
191
  except Exception as e:
192
- name = type(e).__name__
193
- if name in ("GatedRepoError", "RepositoryNotFoundError") or "401" in str(e) or "403" in str(e):
194
- raise ModelAccessDenied(
195
- f"{repo} is gated or private. Accept its terms on huggingface.co/{repo}, then put an access "
196
- f"token in ${HF_TOKEN_ENV} (export {HF_TOKEN_ENV}=hf_...)"
197
- ) from e
198
- raise DownloadError(f"{repo}: download failed: {name}: {e}") from e
192
+ raise _hub_error(repo, e) from e
199
193
  return target
200
194
 
201
195
 
196
+ def _hub_error(repo: str, e: Exception) -> DownloadError:
197
+ name = type(e).__name__
198
+ if name in ("GatedRepoError", "RepositoryNotFoundError") or "401" in str(e) or "403" in str(e):
199
+ return ModelAccessDenied(
200
+ f"{repo} is gated or private. Accept its terms on huggingface.co/{repo}, then put an access "
201
+ f"token in ${HF_TOKEN_ENV} (export {HF_TOKEN_ENV}=hf_...)")
202
+ return DownloadError(f"{repo}: download failed: {name}: {e}")
203
+
204
+
205
+ # ---- models vLLM and SGLang load -------------------------------------------------------------
206
+ #
207
+ # A model on vLLM or SGLang is loaded by the engine, from Hugging Face's own cache (HF_HOME), not
208
+ # from fusion's model directory. Left to the engine, a first `frun up` spends 5-15 minutes
209
+ # downloading inside its start-up, looking hung. `frun models pull agent.py` fetches it into that
210
+ # same cache beforehand, with progress, so the engine finds it there.
211
+
212
+ # Weights in other formats the engines don't load (a repo may ship several), and training leftovers
213
+ ENGINE_UNWANTED = HF_UNWANTED + ["*.gguf", "*.onnx", "*.onnx_data", "onnx/**", "*.pt"]
214
+
215
+
216
+ def engine_files(repo: str, revision: Optional[str] = None, token: Optional[str] = None) -> list:
217
+ """[(name, size)] of what vLLM or SGLang needs from a repo: weights, configs, tokenizer, chat template."""
218
+ from huggingface_hub import HfApi
219
+
220
+ info = HfApi().repo_info(repo, revision=revision, files_metadata=True, token=token)
221
+ files = [(f.rfilename, f.size or 0) for f in (info.siblings or [])
222
+ if not any(Path(f.rfilename).match(p) for p in ENGINE_UNWANTED)]
223
+ if any(name.endswith(".safetensors") for name, _ in files):
224
+ files = [f for f in files if not f[0].endswith(".bin")] # the same weights again, in the older format
225
+ return files
226
+
227
+
228
+ def in_engine_cache(repo: str, filename: str = "config.json", revision: Optional[str] = None) -> bool:
229
+ """Whether Hugging Face's cache holds this file of the repo. Looks on disk only, never online."""
230
+ from huggingface_hub import try_to_load_from_cache
231
+
232
+ return isinstance(try_to_load_from_cache(repo, filename, revision=revision), str)
233
+
234
+
235
+ def engine_cache_dir() -> Path:
236
+ from huggingface_hub import constants
237
+
238
+ return Path(constants.HF_HUB_CACHE)
239
+
240
+
241
+ def pull_for_engine(ref: str, log: Callable[[str], None] = print, force: bool = False) -> int:
242
+ """Download a vLLM/SGLang model into Hugging Face's cache. Returns the bytes fetched (0: all there)."""
243
+ repo, revision, filename = hf_reference(ref if ref.startswith(HF_PREFIX) else HF_PREFIX + ref)
244
+ if filename:
245
+ raise DownloadError(f"vLLM and SGLang load a whole repo; name it as hf:{repo}, without {filename!r}")
246
+ from huggingface_hub import snapshot_download
247
+
248
+ token = os.getenv(HF_TOKEN_ENV)
249
+ try:
250
+ files = engine_files(repo, revision, token)
251
+ if not files:
252
+ raise DownloadError(f"{repo} has no files vLLM or SGLang can load")
253
+ missing = files if force else [f for f in files if not in_engine_cache(repo, f[0], revision)]
254
+ if not missing:
255
+ return 0
256
+ needed = sum(size for _, size in missing)
257
+ cache = engine_cache_dir()
258
+ check_disk_space(needed, cache, setting="HF_HOME")
259
+ log(f" {repo}{'@' + revision if revision else ''} ({format_size(needed)}) → {cache}, where the engine looks")
260
+ snapshot_download(repo_id=repo, revision=revision, allow_patterns=[name for name, _ in files],
261
+ token=token, force_download=force)
262
+ except DownloadError:
263
+ raise
264
+ except Exception as e:
265
+ raise _hub_error(repo, e) from e
266
+ return needed
267
+
268
+
202
269
  def _files_to_fetch(repo: str, revision: Optional[str], filename: Optional[str], token) -> list:
203
270
  """The named file plus the small files every copy needs, or the whole repo when it holds one model."""
204
271
  files = hf_files(repo, revision, token)
@@ -235,7 +235,7 @@ def check_acceleration() -> List[CheckResult]:
235
235
  if gpu_offload:
236
236
  results.append(CheckResult(OK, f"llama.cpp can use the GPU ({backend})"))
237
237
  if DEVELOPMENT_CONFIG.llm.n_gpu_layers == 0:
238
- results.append(CheckResult(INFO, "The development profile still runs the LLM on CPU (n_gpu_layers=0)"))
238
+ results.append(CheckResult(INFO, "The development profile is for laptops: it runs the LLM on the CPU (n_gpu_layers=0); production uses the GPU"))
239
239
  elif cuda_devices:
240
240
  # The GPU image builds llama.cpp for the CPU on purpose: on an NVIDIA card the LLM
241
241
  # belongs on a model server, which serves about three times the callers.
@@ -259,20 +259,27 @@ def check_models() -> List[CheckResult]:
259
259
  from fusion_runtime.config import model_dir, model_dir_source
260
260
 
261
261
  results = [CheckResult(INFO, f"Model directory: {short_path(model_dir())} ({model_dir_source()})")]
262
+ # Each profile's missing models count only on the machine that profile is for: on a GPU server,
263
+ # the laptop profile's CPU models aren't a problem, and the reverse.
264
+ gpu = bool(_cuda_device_count())
262
265
  dev_missing = missing_models(Profile.development, apply_env=False)
263
266
  if dev_missing:
264
- results.append(CheckResult(FAIL, f"development profile is missing {', '.join(dev_missing)}",
265
- "frun models pull"))
267
+ results.append(CheckResult(
268
+ INFO if gpu else FAIL,
269
+ f"development profile (laptops) is missing {', '.join(dev_missing)}"
270
+ + (" (only for --config development)" if gpu else ""),
271
+ "frun models pull",
272
+ ))
266
273
  else:
267
- results.append(CheckResult(OK, "development profile: all models installed"))
274
+ results.append(CheckResult(OK, "development profile (laptops): all models installed"))
268
275
 
269
276
  prod_missing = missing_models(Profile.production, apply_env=False)
270
277
  if not prod_missing:
271
- results.append(CheckResult(OK, "production profile: all models installed"))
278
+ results.append(CheckResult(OK, "production profile (NVIDIA GPU): all models installed"))
272
279
  else:
273
280
  results.append(CheckResult(
274
- WARN if _cuda_device_count() else INFO,
275
- f"production profile is missing {', '.join(prod_missing)}",
281
+ WARN if gpu else INFO,
282
+ f"production profile (NVIDIA GPU) is missing {', '.join(prod_missing)}",
276
283
  "frun models pull --config production",
277
284
  ))
278
285
  return results
@@ -1,6 +1,7 @@
1
1
  """The Typer app every `frun` command registers on."""
2
2
  import typer
3
3
 
4
+ from fusion_runtime.cli.bench import bench
4
5
  from fusion_runtime.cli.doctor import doctor
5
6
  from fusion_runtime.cli.keys import key_app, keys_app, token
6
7
  from fusion_runtime.cli.models import models_app
@@ -49,6 +50,7 @@ def _root(
49
50
 
50
51
  app.command()(up)
51
52
  app.command()(talk)
53
+ app.command()(bench)
52
54
  app.add_typer(key_app, name="key")
53
55
  app.add_typer(keys_app, name="keys")
54
56
  app.command()(token)