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.
- {fusion_runtime-0.1.2/fusion_runtime.egg-info → fusion_runtime-0.1.4}/PKG-INFO +71 -2
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/README.md +70 -1
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/agent.py +4 -3
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/download.py +76 -9
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/_checks.py +14 -7
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/app.py +2 -0
- fusion_runtime-0.1.4/fusion_runtime/cli/bench.py +280 -0
- fusion_runtime-0.1.4/fusion_runtime/cli/bench_audio/hello.wav +0 -0
- fusion_runtime-0.1.4/fusion_runtime/cli/bench_audio/order_1042.wav +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/keys.py +4 -6
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/models.py +34 -8
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/up.py +137 -24
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/config.py +27 -2
- fusion_runtime-0.1.4/fusion_runtime/engine/audio_format.py +84 -0
- fusion_runtime-0.1.4/fusion_runtime/engine/audio_watch.py +108 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/barge_in.py +13 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/orchestrator.py +145 -31
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/llm_server.py +148 -18
- fusion_runtime-0.1.4/fusion_runtime/phone/__init__.py +1 -0
- fusion_runtime-0.1.4/fusion_runtime/phone/exotel.py +118 -0
- fusion_runtime-0.1.4/fusion_runtime/phone/link.py +173 -0
- fusion_runtime-0.1.4/fusion_runtime/phone/mulaw.py +45 -0
- fusion_runtime-0.1.4/fusion_runtime/phone/plivo.py +147 -0
- fusion_runtime-0.1.4/fusion_runtime/phone/twilio.py +165 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/resolver.py +3 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/onnx/kokoro.py +19 -6
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/onnx/tts.py +4 -1
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/openai_http/llm.py +44 -1
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/limits.py +23 -6
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/server.py +363 -104
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/metrics.py +6 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/trace.py +10 -0
- fusion_runtime-0.1.4/fusion_runtime/transport.py +139 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/web/fusion-runtime.js +1 -1
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/web/index.html +18 -3
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4/fusion_runtime.egg-info}/PKG-INFO +71 -2
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/SOURCES.txt +20 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/pyproject.toml +2 -2
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_agent.py +1 -1
- fusion_runtime-0.1.4/tests/test_audio_format.py +91 -0
- fusion_runtime-0.1.4/tests/test_audio_watch.py +156 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_banner.py +27 -0
- fusion_runtime-0.1.4/tests/test_bench.py +83 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_cli.py +40 -14
- fusion_runtime-0.1.4/tests/test_degraded.py +96 -0
- fusion_runtime-0.1.4/tests/test_digital_silence.py +61 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_doctor.py +7 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_greeting.py +42 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_http_llm.py +70 -3
- fusion_runtime-0.1.4/tests/test_interruption_switches.py +81 -0
- fusion_runtime-0.1.4/tests/test_knowledge_example.py +48 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_llm_server.py +130 -1
- fusion_runtime-0.1.4/tests/test_phone.py +361 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_runtimes.py +9 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_security.py +30 -1
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_server_telemetry.py +13 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_telemetry.py +14 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_tools.py +41 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/LICENSE +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/NOTICE +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/audio/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/audio/duplex_audio.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/audio/echo_canceller.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/entries.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/gguf.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/catalog/models.toml +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/__main__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/_common.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/_talk_client.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/banner.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/doctor.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/talk.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/cli/version.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/common.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/llm.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/stt.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/tts.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/contract/turn.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/conversation.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/scheduler.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/streaming.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/engine/text.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/env.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/registry.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/ctranslate2/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/ctranslate2/stt.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/llama_cpp/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/llama_cpp/llm.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/onnx/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/runtimes/openai_http/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/auth.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/keys.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/origins.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/security/tokens.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/errors.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/events.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/hub.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/loop_monitor.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/redact.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/telemetry/sinks.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/testing/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/testing/conformance.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/testing/fakes.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/tools.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/turns/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/turns/silence.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/base.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/silero.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/vad/turn.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime/web/__init__.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/dependency_links.txt +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/entry_points.txt +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/requires.txt +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/fusion_runtime.egg-info/top_level.txt +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/setup.cfg +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_barge_in.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_catalog.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_client_barge_in.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_conformance.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_contract.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_conversation.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_core.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_duplex_audio.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_echo_canceller.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_engine_streaming.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_env_file.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_event_loop_blocking.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_languages.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_llama_cpp_stream.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_model_paths.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_registry.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_resolver.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_scheduler.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_self_echo.py +0 -0
- {fusion_runtime-0.1.2 → fusion_runtime-0.1.4}/tests/test_turn_taking.py +0 -0
- {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.
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
265
|
-
|
|
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
|
|
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)
|