fixes tool callong

This commit is contained in:
valenti
2026-10-21 10:44:49 +01:00
parent 79f927d968
commit 1cafe3dd3c
14 changed files with 2308 additions and 61 deletions
+4
View File
@@ -317,6 +317,10 @@ LLM_STREAM_CHUNK_CHARS=240
# `<tool_call>` format: the engine parses it client-side, executes the HTTP
# request and hands the result back to the model (see src/tools.rs). The
# tool-call block is never spoken nor shown in the chat box.
# Runtime override without a restart: POST /api/tools with the same JSON array
# (GET /api/tools reports the active set and its `source`). The override lives
# in memory only — if the endpoint is never called, the LLM_TOOLS_FILE set below
# is used, and a restart restores it. See TOOL_CALLING.md.
# Enabled automatically when LLM_TOOLS_FILE points to a valid tool file.
LLM_TOOLS_ENABLED=false
# JSON file with the tool definitions (name, description, JSON schema, url,
Generated
+314 -3
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -17,6 +23,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -60,6 +75,9 @@ dependencies = [
"tower",
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-swagger-ui",
"zip",
]
[[package]]
@@ -159,6 +177,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -210,6 +237,12 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -244,6 +277,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -254,6 +302,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
@@ -270,14 +327,36 @@ dependencies = [
"zeroize",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
@@ -297,6 +376,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
version = "0.3.14"
@@ -319,6 +404,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -440,6 +535,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hmac-sha256"
version = "1.1.15"
@@ -491,6 +592,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.12.0"
@@ -655,6 +765,18 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -766,6 +888,26 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
@@ -1152,6 +1294,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -1224,6 +1378,41 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust-embed"
version = "8.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.119",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1291,6 +1480,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -1403,7 +1601,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -1431,6 +1640,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1798,6 +2013,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-ident"
version = "1.0.27"
@@ -1870,6 +2091,48 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.119",
]
[[package]]
name = "utoipa-swagger-ui"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db4b5ac679cc6dfc5ea3f2823b0291c777750ffd5e13b21137e0f7ac0e8f9617"
dependencies = [
"axum",
"base64 0.22.1",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"zip",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -1888,6 +2151,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.2"
@@ -2026,6 +2299,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -2229,8 +2511,37 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "zip"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b280484c454e74e5fff658bbf7df8fdbe7a07c6b2de4a53def232c15ef138f3a"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.21",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+11
View File
@@ -26,6 +26,17 @@ anyhow = "1"
async-trait = "0.1"
dotenvy = "0.15"
# --- OpenAPI / Swagger UI ---------------------------------------------------
# `utoipa` derives the OpenAPI document from the handlers/schemas;
# `utoipa-swagger-ui` serves the browsable UI (and the raw JSON) from Axum.
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "8", features = ["axum"] }
# Constrain `zip` for `utoipa-swagger-ui`'s build script (it embeds the UI from
# a zip at compile time). The 8.1 build script targets the older 2.x API
# (`zip::read::ZipFile` was not yet generic; `ZipError::InvalidArchive` took a
# `&str`), which the 2.3+/2.6 line changed — pinning here keeps it building.
zip = { version = ">=2.2.0, <2.3.0", default-features = false, features = ["deflate"] }
# --- Optional HTTPS (see Config::tls_cert / tls_key) ------------------------
# We pull these in with `default-features = false` because the crates are
# already present as transitive deps of `reqwest` (rustls + ring), so enabling
+92 -4
View File
@@ -69,6 +69,14 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
- **Pipelined TTS** — reply chunks are synthesized ahead of playback (up to
`TTS_PIPELINE_DEPTH` at once) and replayed in order, hiding each request's
start-up latency behind the previous chunk's audio.
- **OpenAPI / Swagger UI** — the REST endpoints are documented by an `utoipa`
OpenAPI document rendered as a Swagger UI at `/docs` (raw JSON at
`/api-docs/openapi.json`). See
[API documentation](#api-documentation-swagger-ui).
- **File transcription endpoint** — `POST /api/transcribe` decodes an uploaded
audio file with `ffmpeg` and returns the Parakeet transcript of the whole take
(reusing the live session's STT model). See
[Transcription endpoint](#transcription-endpoint).
- **Double jitter buffer** — server pre-buffer + browser playback buffer absorb
uneven TTS chunk arrival, eliminating drop-outs.
- **Silero VAD (ONNX)** — server-side speech segmentation and fast barge-in.
@@ -80,7 +88,9 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
chain-of-thought (`reasoning_content` / ` thinking`) is stripped from both the
audio and the chat box.
- **Tool calling** — the model can call external HTTP tools described in a JSON
file (`LLM_TOOLS_FILE`) before answering. Calls are parsed client-side from
file (`LLM_TOOLS_FILE`) before answering, or installed at runtime with
`POST /api/tools` (in-memory override; without it the file definitions are
used). Calls are parsed client-side from
either the structured `tool_calls` array or the model's native `<tool_call>`
format (a round that streams back empty is re-asked without streaming),
executed over HTTP and fed back to the model; the tool-call block is never
@@ -129,6 +139,7 @@ cargo run --release
# http://localhost:8787/ playground (TTS / audio test)
# http://localhost:8787/mivocero speech-to-speech agent with chat box
# http://localhost:8787/customvoice custom-voice builder (record / upload a reference)
# http://localhost:8787/docs Swagger UI (REST API documentation)
```
Environment variables can also be `export`ed directly; a `.env` file is loaded
@@ -259,7 +270,7 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `LLM_DIRECT_ANSWERS` | `true` | Append an "answer directly, never narrate your reasoning" directive to the system prompt. |
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Overrides the system prompt. The default asks for short, spoken-friendly replies (no markdown / bullet points). |
| `LLM_TOOLS_ENABLED` | *(auto)* | Master switch for tool calling. Default: enabled when `LLM_TOOLS_FILE` is a non-empty path. |
| `LLM_TOOLS_FILE` | *(empty)* | JSON file with the tool definitions (`name`, `description`, `parameters` JSON schema, `url`, `method`, `headers`). See [Tool calling](#tool-calling) and [`tools.example.json`](tools.example.json). |
| `LLM_TOOLS_FILE` | *(empty)* | JSON file with the tool definitions (`name`, `description`, `parameters` JSON schema, `url`, `method` — any HTTP verb, `headers`). See [Tool calling](#tool-calling) and [`tools.example.json`](tools.example.json). |
| `LLM_TOOL_MAX_ITERATIONS` | `4` | Maximum tool-call rounds per turn (call → execute → re-prompt). Bounds runaway loops. |
| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout of a single tool HTTP request. |
| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Tool responses are truncated to this many characters before being handed back to the model. |
@@ -1023,12 +1034,36 @@ be exposed without touching the code:
```
- `url` may contain `{arg}` placeholders substituted from the call arguments.
- `GET` sends the arguments that were **not** consumed by a placeholder as query
parameters; `POST` (the default) sends them as a JSON body.
- `method` takes **any** HTTP verb, case-insensitive (`GET`, `POST`, `PUT`,
`PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, custom verbs); default `POST`.
The arguments left after the URL placeholders are sent as **query parameters**
for `GET`/`HEAD`/`OPTIONS`/`TRACE`/`DELETE` and as a **JSON body** for every
other verb — so a full REST surface is covered: list/look up (`GET`), add
(`POST`), update (`PUT`/`PATCH`), remove (`DELETE`).
- `headers` are optional and sent verbatim (API keys, etc.).
See [`tools.example.json`](tools.example.json) for a ready-to-use file.
### Runtime definitions (`GET` / `POST /api/tools`)
The file is read once at startup, so `POST /api/tools` installs a different set
without a restart — the body is **the same JSON array** the file holds:
```bash
curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \
-d '[{"name":"get_weather","description":"…","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]'
```
- The override lives **in memory only**: it replaces the `LLM_TOOLS_FILE`
definitions from the next turn on, and a restart brings the file ones back.
When the endpoint is never called, the file definitions stay in use.
- `GET /api/tools` reports the active definitions and their origin
(`source`: `file`, `request` or `none`).
- `400` on an empty/invalid body or on a JSON array with no tool; `503` when the
LLM is disabled. See [`src/tools_api.rs`](src/tools_api.rs).
See [TOOL_CALLING.md](TOOL_CALLING.md) for the full guide.
### Client-side parsing (and the streaming fallback)
A deployment may or may not run a server-side tool parser. When it does, the
@@ -1475,8 +1510,11 @@ audio-agent-engine/
├── vad/ # Silero VAD v5 + onset/offset state machine
├── llm.rs # OpenAI-compatible chat-completions client (streaming + think stripping)
├── tools.rs # configurable HTTP tool calling (native <tool_call> parser + executor)
├── tools_api.rs # GET/POST /api/tools — runtime tool definitions (in-memory override)
├── media.rs # external audio source (URL / radio / YouTube) via ffmpeg + yt-dlp
├── customvoice.rs # custom-voice builder endpoints (record → WAV reference → set_reference)
├── transcribe.rs # POST /api/transcribe — Parakeet transcription of an uploaded audio file
├── apidoc.rs # OpenAPI document (utoipa) rendered by the Swagger UI at /docs
├── audit.rs # per-session JSONL audit log (config snapshot, events, summary)
├── orchestrator/ # turn_manager (EOU/STT → LLM → filler → TTS) + backchannel
├── ws/ # handler, session (per-connection audio loop)
@@ -1493,8 +1531,58 @@ audio-agent-engine/
| `GET /api/customvoice/phrases` | Reading phrases per language + the default language (JSON). |
| `POST /api/customvoice?lang=<code>[&text=<transcript>]` | Upload a recording or an audio file (with its transcript); transcode it to a WAV reference and install it as the active voice. |
| `POST /api/customvoice/clear` | Restore the reference configured at startup. |
| `POST /api/transcribe` | Upload a raw audio file (any ffmpeg-readable container); returns the Parakeet transcript of the whole file (`text`, `duration_ms`, `tokens`). |
| `GET /api/tools` | The active LLM tool definitions and their origin (`source`: `file` / `request` / `none`). |
| `POST /api/tools` | Install LLM tool definitions at runtime (same JSON array as `LLM_TOOLS_FILE`) — in-memory, no restart ([Tool calling](#tool-calling)). |
| `GET /health` | Liveness probe (`ok`). |
| `GET /ws` | WebSocket upgrade → per-session audio loop. |
| `GET /docs` | Swagger UI for the REST surface ([API documentation](#api-documentation-swagger-ui)). |
| `GET /api-docs/openapi.json` | The raw OpenAPI 3 document the Swagger UI renders. |
### API documentation (Swagger UI)
The REST endpoints are documented with an **OpenAPI 3** document derived from
the handlers ([`utoipa`](https://docs.rs/utoipa)) and browsable in a **Swagger
UI** served by the engine itself ([`utoipa-swagger-ui`](https://docs.rs/utoipa-swagger-ui)):
- **UI** — `GET /docs` (→ `/docs/`) renders the interactive Swagger UI.
- **JSON** — `GET /api-docs/openapi.json` serves the raw OpenAPI document.
The document is built in [`src/apidoc.rs`](src/apidoc.rs) (the `ApiDoc`
`#[derive(OpenApi)]`, the response schemas) with per-handler
`#[utoipa::path]` annotations on the endpoints. Only the JSON/REST endpoints are
covered — the WebSocket endpoint (`/ws`) and the HTML client pages are streaming
/ static assets and are intentionally left out.
The `utoipa-swagger-ui` build script embeds the UI assets from a zip at compile
time; the `zip` crate is therefore pinned to the `2.2.x` line in `Cargo.toml`
(the 2.3+ API no longer matches the 8.1 build script).
### Transcription endpoint
`POST /api/transcribe` ([`src/transcribe.rs`](src/transcribe.rs)) turns an
uploaded audio file into text with the same Parakeet TDT model the live session
uses:
```bash
curl -s -X POST http://localhost:8787/api/transcribe \
-H 'Content-Type: application/octet-stream' \
--data-binary @clip.wav
# {"ok":true,"text":"…","duration_ms":14918,"tokens":83}
```
- **Input** — the raw audio bytes in the request body (any container `ffmpeg`
can read: wav, mp3, m4a, ogg, flac…); no multipart, no `lang` parameter —
Parakeet TDT v3 is multilingual and the language is auto-detected.
- **Processing** — `ffmpeg` decodes to mono 16 kHz `s16le`, and the engine's
**already-loaded** STT model ([`AppState::stt`](src/ws/handler.rs)) runs one
inference over the whole file (`spawn_blocking`: the inference is CPU-bound
and holds the ONNX session lock). No second ONNX environment is created.
- **Output** — a single transcript for the entire file (the audio is **not**
segmented): `{ "ok": true, "text": …, "duration_ms": …, "tokens": … }`.
- **Errors** — `400` empty body, `503` STT model not loaded, `502` ffmpeg
decode failed, `500` temp-file / inference failure. Uploads are capped at
32 MB (same as `/api/customvoice`).
---
+1020
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -5,6 +5,150 @@ Entries are written in English and ordered newest-first.
---
## 2026-10-21 — Feat: every HTTP verb for tools (list / get / add / update / delete / …)
### Changed
- **`method` now accepts any HTTP verb** ([`src/tools.rs`](src/tools.rs)),
case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`,
`TRACE` and custom verbs (e.g. `PURGE`). Before this change anything other than
`GET` fell through to `POST`, so `PUT`/`PATCH`/`DELETE` tools silently became
POSTs.
- **Argument placement follows the verb.** The arguments left after the URL
placeholders go to the **query string** for the read-style verbs
(`GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE`) and to the **JSON body** for
every other verb (`POST`, `PUT`, `PATCH`, custom); body-style verbs always send
a body (`{}` when nothing is left). This makes a CRUD surface expressible:
`GET /items?limit=10`, `GET /items/{id}`, `POST /items`,
`PATCH|PUT /items/{id}`, `DELETE /items/{id}`, `HEAD /items/{id}`.
- **New helpers** `http_method()`, `args_in_body()` and `place_args()` in
[`src/tools.rs`](src/tools.rs); `ToolRegistry::execute` now builds the request
through `Client::request(method, url)` instead of separate `get`/`post`
branches.
- An invalid verb is **warned at load** and, at execution time, returned to the
model as `Error: invalid HTTP method '<verb>' for tool '<name>'` (never a
panic, never a blocking failure).
### Notes
- `DELETE` is **query-style**: put the id in the URL (`/items/{id}`) and any
extra argument ends up as a query parameter.
- Tests: two new unit tests (`every_http_verb_is_accepted`,
`args_placement_follows_the_verb`) — `cargo test --bin audio-agent-engine --
tools::` → 9 passed.
- Docs updated: [TOOL_CALLING.md](TOOL_CALLING.md) (IT/EN/ES, per-verb argument
table + full CRUD example set) and [README.md](README.md#tool-calling).
---
## 2026-10-21 — Feat: runtime tool definitions — `GET` / `POST /api/tools`
### Added
- **New REST endpoint `POST /api/tools`** ([`src/tools_api.rs`](src/tools_api.rs)).
The body is the **same JSON array** used by `LLM_TOOLS_FILE`; it installs a new
set of tool definitions at runtime, **without a restart** (until now the file
was only read once at startup). The definitions apply from the **next turn**
on.
- **`GET /api/tools`** reports the active definitions (in the OpenAI `tools`
shape they are advertised to the model with) plus their origin: `source` is
`file`, `request` or `none`.
- **Errors:** `400` empty body / non-UTF-8 / invalid JSON / JSON array with no
tool definition, `503` LLM disabled. Responses use the shared
`{ok, …}` / `{ok:false,error}` shapes.
- **OpenAPI/Swagger UI:** both handlers are documented (tag `tools`, schema
`ToolsResponse`) in [`src/apidoc.rs`](src/apidoc.rs) and appear at `/docs`.
### Changed
- **`ToolRegistry` can be built from a string** — `ToolRegistry::from_json`
([`src/tools.rs`](src/tools.rs)) is the new core builder; `load` now reads the
file and delegates to it, so a body accepted by the endpoint is also valid as
an `LLM_TOOLS_FILE` (and vice versa). Added `len()` / `names()`.
- **The LLM client's registry is swappable** — `LlmClient::tools` moved from
`Option<Arc<ToolRegistry>>` to an `Arc<RwLock<…>>` with `set_tools(…)` /
`tools_from_request()` ([`src/llm.rs`](src/llm.rs)). The registry is re-read on
**every** request and at the start of every turn
([`turn_manager.rs`](src/orchestrator/turn_manager.rs)), so an override takes
effect on the next turn while a session in flight is unaffected.
- Docs: new [TOOL_CALLING.md](TOOL_CALLING.md) (IT/EN/ES operating guide) and a
"Runtime definitions" subsection in [README.md](README.md#tool-calling).
### Notes
- The override lives **in memory only**: it never writes the tools file, and a
restart restores the `LLM_TOOLS_FILE` definitions. When the endpoint is never
called, the file definitions stay in use — that remains the default.
- Verified on the running service: `GET` before any POST →
`{"count":2,"source":"file"}`; after a POST → `{"count":1,"source":"request"}`;
invalid JSON and `[]` → `400`; restart → back to
`{"count":2,"source":"file"}`.
---
## 2026-10-17 — Feat: `POST /api/transcribe` — Parakeet transcription of an uploaded audio file
### Added
- **New REST endpoint `POST /api/transcribe`** ([`src/transcribe.rs`](src/transcribe.rs)).
The body is the raw audio file (any container `ffmpeg` can read: wav, mp3,
m4a, ogg, flac…). The file is decoded to **mono 16-bit PCM at 16 kHz**
(`ffmpeg -ac 1 -ar 16000 -f s16le pipe:1`) and the **already-loaded** Parakeet
TDT model from [`AppState::stt`](src/ws/handler.rs) transcribes the whole take
in a single inference on a blocking thread (the inference is CPU-bound and
holds the ONNX session lock). Response:
`{"ok": true, "text": …, "duration_ms": …, "tokens": …}`.
- **Errors:** `400` empty body, `503` STT model not loaded, `502` ffmpeg decode
failed, `500` temp-file / inference failure. Uploads capped at **32 MB** (same
as `/api/customvoice`).
- **OpenAPI/Swagger UI:** the endpoint is documented (tag `transcribe`, schema
`TranscribeResponse`) in [`src/apidoc.rs`](src/apidoc.rs) and appears at
`/docs`.
### Notes
- **Whole-file, single transcript** by design (no VAD segmentation) and **no
`lang` parameter**: Parakeet TDT v3 is multilingual, the language is
auto-detected.
- No second ONNX environment is created — the model is shared with the live
session (respects the one-environment-per-process constraint). The upload is
spilled to a temp file (ffmpeg needs a seekable input) and removed afterwards.
- Verified end-to-end: `voice_profiles/solis.wav` (14.9 s, Spanish) →
`duration_ms=14918`, `tokens=83`, correct transcript; empty body → `400`.
---
## 2026-10-17 — Feat: OpenAPI document + Swagger UI at `/docs`
### Added
- **Swagger UI for the REST surface.** The engine now serves an interactive
Swagger UI at **`GET /docs`** backed by an OpenAPI 3 document at
**`GET /api-docs/openapi.json`**. The document is derived from the handlers
with [`utoipa`](https://docs.rs/utoipa) (`utoipa = "5"`, `axum_extras`) and
rendered by [`utoipa-swagger-ui`](https://docs.rs/utoipa-swagger-ui) v8
(axum 0.7). New module [`src/apidoc.rs`](src/apidoc.rs) holds the
`#[derive(OpenApi)]` document (`ApiDoc`) and the response schemas
(`PhrasesResponse`, `PhraseEntry`, `SetVoiceResponse`, `ClearResponse`,
`ErrorResponse`); the endpoints carry `#[utoipa::path]` annotations.
- **Documented endpoints:** `GET /health`, `GET /api/customvoice/phrases`,
`POST /api/customvoice` (query params `lang` / `text` via
`#[derive(IntoParams)]` on `SetParams`; raw `application/octet-stream` body),
`POST /api/customvoice/clear`. WebSocket (`/ws`) and the HTML client pages are
intentionally excluded (streaming / static assets).
### Notes
- The `zip` crate is pinned to the **2.2.x** line in `Cargo.toml`: the
`utoipa-swagger-ui` 8.1 build script (which unpacks the embedded UI assets)
targets the older zip 2.x API and does not compile against zip 2.3+/2.6. The
root `axum` stays at 0.7.9 — `utoipa-swagger-ui` 8.1 resolves to it, so no
duplicate Axum is pulled in.
- Swagger UI is only for the REST endpoints; `/docs` requires the same
HTTP/HTTPS origin as the engine.
---
## 2026-10-17 — Feat: custom voice accepts an uploaded audio file + typed transcript
### Added
+141
View File
@@ -0,0 +1,141 @@
//! OpenAPI document and Swagger UI wiring for the REST surface.
//!
//! Only the JSON/REST endpoints are described. The WebSocket endpoint (`/ws`)
//! and the HTML client pages (`/`, `/mivocero`, `/customvoice`) are streaming /
//! static assets and are intentionally left out of the document.
//!
//! The schemas below mirror the JSON bodies the handlers actually emit; they
//! are documentation-only (never constructed) and exist so the Swagger UI shows
//! real field names and examples instead of a generic `object`.
use utoipa::OpenApi;
/// One reading phrase returned by `GET /api/customvoice/phrases`.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct PhraseEntry {
/// ISO 639-1 language code.
#[schema(example = "it")]
pub code: String,
/// Human-readable language name.
#[schema(example = "Italiano")]
pub label: String,
/// The phrase the user is asked to read aloud for this language.
pub phrase: String,
}
/// Response of `GET /api/customvoice/phrases`.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct PhrasesResponse {
/// The configured default language (`LANG_DEFAULT`).
#[schema(example = "it")]
pub default: String,
/// The supported languages and their reading phrases.
pub languages: Vec<PhraseEntry>,
}
/// Response of a successful `POST /api/customvoice`.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct SetVoiceResponse {
/// Always `true` on success.
pub ok: bool,
/// The language the reference was installed for.
pub lang: String,
/// Absolute path of the WAV reference written on the TTS server.
pub audio: String,
/// The transcript stored for the reference (the typed one, or the language's
/// predefined phrase when none was supplied).
pub phrase: String,
}
/// Response of `POST /api/customvoice/clear`.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct ClearResponse {
/// Always `true`.
pub ok: bool,
}
/// Response of a successful `POST /api/transcribe`.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct TranscribeResponse {
/// Always `true` on success.
pub ok: bool,
/// The transcript of the whole file.
pub text: String,
/// Duration of the decoded audio in milliseconds.
pub duration_ms: u64,
/// Number of tokens emitted by the TDT decoder.
pub tokens: usize,
}
/// Response of `GET /api/tools` and `POST /api/tools`.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct ToolsResponse {
/// Always `true` on success.
pub ok: bool,
/// Number of active tool definitions.
#[schema(example = 2)]
pub count: usize,
/// Where the active definitions come from: `file` (the `LLM_TOOLS_FILE`
/// default), `request` (installed by `POST /api/tools`) or `none`.
#[schema(example = "file")]
pub source: String,
/// The active definitions, in the OpenAI `tools` shape they are advertised
/// to the model with.
#[schema(value_type = Vec<Object>)]
pub tools: Vec<serde_json::Value>,
}
/// Error body shared by the endpoints.
#[derive(utoipa::ToSchema)]
#[allow(dead_code)]
pub struct ErrorResponse {
/// Always `false`.
pub ok: bool,
/// Human-readable failure reason.
pub error: String,
}
/// The OpenAPI document for the REST endpoints, served as JSON and rendered by
/// the Swagger UI at `/docs`.
#[derive(OpenApi)]
#[openapi(
info(
title = "audio-agent-engine",
description = "Real-time full-duplex audio agent engine (WebSocket audio \
streaming, Parakeet STT, SGLang TTS, streaming LLM). This \
document covers the REST endpoints only; `/ws` and the HTML \
clients are not part of the OpenAPI surface.",
version = env!("CARGO_PKG_VERSION"),
),
paths(
crate::health,
crate::customvoice::phrases,
crate::customvoice::set_custom_voice,
crate::customvoice::clear_custom_voice,
crate::transcribe::transcribe,
crate::tools_api::set_tools,
crate::tools_api::list_tools,
),
components(schemas(
PhraseEntry,
PhrasesResponse,
SetVoiceResponse,
ClearResponse,
TranscribeResponse,
ToolsResponse,
ErrorResponse,
)),
tags(
(name = "system", description = "Liveness and diagnostics"),
(name = "customvoice", description = "Custom voice builder (voice cloning)"),
(name = "transcribe", description = "Speech-to-text of an uploaded audio file (Parakeet)"),
(name = "tools", description = "Tool definitions for LLM tool calling (runtime override of LLM_TOOLS_FILE)"),
),
)]
pub struct ApiDoc;
+39 -1
View File
@@ -94,6 +94,16 @@ const PHRASES: &[Phrase] = &[
/// `GET /api/customvoice/phrases` — the reading phrases and the configured
/// default language, used to populate the page's language selector.
#[utoipa::path(
get,
path = "/api/customvoice/phrases",
tag = "customvoice",
responses((
status = 200,
description = "The reading phrases (one per supported language) and the configured default language",
body = crate::apidoc::PhrasesResponse,
)),
)]
pub async fn phrases(State(state): State<AppState>) -> Json<Value> {
let languages: Vec<Value> = PHRASES
.iter()
@@ -106,7 +116,8 @@ pub async fn phrases(State(state): State<AppState>) -> Json<Value> {
}
/// Query string of `POST /api/customvoice`.
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct SetParams {
/// ISO 639-1 code of the language the user recorded in.
pub lang: String,
@@ -120,6 +131,23 @@ pub struct SetParams {
/// audio (a browser recording or an uploaded file; whatever container the
/// client sent, ffmpeg probes the content). Transcodes it to a WAV reference,
/// persists the transcript, and installs the voice for the running session.
#[utoipa::path(
post,
path = "/api/customvoice",
tag = "customvoice",
params(SetParams),
request_body(
content = String,
content_type = "application/octet-stream",
description = "Raw audio bytes: a browser recording (Opus/WebM) or an uploaded file (wav, mp3, m4a, ogg, flac…). ffmpeg probes the container.",
),
responses(
(status = 200, description = "Reference transcoded, persisted and installed as the active voice", body = crate::apidoc::SetVoiceResponse),
(status = 400, description = "Unsupported language or empty body", body = crate::apidoc::ErrorResponse),
(status = 500, description = "Could not create the directory or store the upload", body = crate::apidoc::ErrorResponse),
(status = 502, description = "ffmpeg transcode failed", body = crate::apidoc::ErrorResponse),
),
)]
pub async fn set_custom_voice(
State(state): State<AppState>,
Query(params): Query<SetParams>,
@@ -202,6 +230,16 @@ pub async fn set_custom_voice(
/// `POST /api/customvoice/clear` — drop the custom voice and restore the
/// reference configured at startup (the `TTS_REFERENCE_AUDIO` voice), or no
/// reference at all when none is configured.
#[utoipa::path(
post,
path = "/api/customvoice/clear",
tag = "customvoice",
responses((
status = 200,
description = "The configured reference is restored (or cleared when none is configured)",
body = crate::apidoc::ClearResponse,
)),
)]
pub async fn clear_custom_voice(State(state): State<AppState>) -> Json<Value> {
match state.config.tts_reference() {
Some((audio_path, text)) => {
+36 -9
View File
@@ -29,7 +29,8 @@ use futures_util::StreamExt;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
@@ -88,8 +89,12 @@ pub struct LlmClient {
strip_reasoning: bool,
/// Optional tool registry. When set, every request advertises the tools and
/// the orchestrator runs the call → execute → re-prompt loop. See
/// [`crate::tools`].
tools: Option<Arc<ToolRegistry>>,
/// [`crate::tools`]. Swappable at runtime through [`LlmClient::set_tools`]
/// (`POST /api/tools`); until then it holds the `LLM_TOOLS_FILE` definitions.
tools: Arc<RwLock<Option<Arc<ToolRegistry>>>>,
/// Whether the active tools were installed at runtime by `POST /api/tools`
/// (`true`) rather than loaded from `LLM_TOOLS_FILE` at startup.
tools_from_request: Arc<AtomicBool>,
}
impl LlmClient {
@@ -120,7 +125,8 @@ impl LlmClient {
temperature,
enable_thinking,
strip_reasoning,
tools,
tools: Arc::new(RwLock::new(tools)),
tools_from_request: Arc::new(AtomicBool::new(false)),
}
}
@@ -144,10 +150,29 @@ impl LlmClient {
self.strip_reasoning
}
/// The tool registry, if tool calling is configured. Used by the
/// orchestrator to execute parsed calls.
pub fn tools(&self) -> Option<&Arc<ToolRegistry>> {
self.tools.as_ref()
/// The tool registry currently in use, if tool calling is enabled. Used by
/// the orchestrator to execute parsed calls.
pub fn tools(&self) -> Option<Arc<ToolRegistry>> {
self.tools.read().ok().and_then(|guard| guard.clone())
}
/// Install a tool registry at runtime, replacing the one loaded from
/// `LLM_TOOLS_FILE` (`POST /api/tools`). Takes effect on the next request.
/// `None` leaves tool calling disabled.
pub fn set_tools(&self, registry: Option<Arc<ToolRegistry>>) {
match self.tools.write() {
Ok(mut guard) => *guard = registry,
Err(e) => warn!(error = %e, "failed to install the tool definitions"),
}
// Remember where the active definitions come from, so the endpoint can
// report it (`request` vs the `LLM_TOOLS_FILE` default).
self.tools_from_request.store(true, Ordering::Relaxed);
}
/// Whether the active tool definitions were installed by `POST /api/tools`
/// rather than loaded from `LLM_TOOLS_FILE` at startup.
pub fn tools_from_request(&self) -> bool {
self.tools_from_request.load(Ordering::Relaxed)
}
fn request_body(&self, messages: &[ChatMessage], stream: bool) -> serde_json::Value {
@@ -164,7 +189,9 @@ impl LlmClient {
// Advertise the configured tools so the model can call them. The
// server has no tool parser, so the call comes back as text in the
// native `<tool_call>` format — parsed client-side in `crate::tools`.
if let Some(reg) = &self.tools {
// The registry is re-read on every request, so a runtime override
// (`POST /api/tools`) applies to the next request.
if let Some(reg) = self.tools() {
if !reg.is_empty() {
body["tools"] = reg.openai_tools();
body["tool_choice"] = json!("auto");
+34 -1
View File
@@ -2,7 +2,9 @@
//!
//! Axum server exposing a WebSocket endpoint for full-duplex audio streaming,
//! a playground client at `/` and a speech-to-speech chat client at `/mivocero`.
//! A Swagger UI for the REST surface is served at `/docs` (see [`apidoc`]).
mod apidoc;
mod assets;
mod audio;
mod audit;
@@ -16,6 +18,8 @@ mod orchestrator;
mod stt;
mod tls;
mod tools;
mod tools_api;
mod transcribe;
mod tts;
mod vad;
mod web;
@@ -38,6 +42,7 @@ use std::sync::Arc;
use tokio::signal;
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use utoipa::OpenApi;
/// Fallback `RUST_LOG` used when the env var is not set: INFO everywhere,
/// DEBUG for the TTS and VAD subsystems (per-chunk streaming / latency /
@@ -243,8 +248,25 @@ async fn main() -> anyhow::Result<()> {
)
.route("/api/customvoice/clear", post(customvoice::clear_custom_voice))
.route("/api/customvoice/phrases", get(customvoice::phrases))
// Same 32 MB body cap as the custom-voice route: the client may send an
// uncompressed WAV to transcribe.
.route(
"/api/transcribe",
post(transcribe::transcribe).layer(DefaultBodyLimit::max(32 * 1024 * 1024)),
)
.route("/health", get(health))
// Runtime tool definitions: replaces the `LLM_TOOLS_FILE` set until the
// next call (in memory only; a restart restores the file ones).
.route(
"/api/tools",
get(tools_api::list_tools).post(tools_api::set_tools),
)
.route("/ws", get(ws_handler))
// OpenAPI document + Swagger UI for the REST surface (see `apidoc`).
.merge(
utoipa_swagger_ui::SwaggerUi::new("/docs")
.url("/api-docs/openapi.json", apidoc::ApiDoc::openapi()),
)
.with_state(app_state);
// Bind dual-stack on the IPv6 wildcard: with net.ipv6.bindv6only=0 (default
@@ -312,7 +334,18 @@ fn html_response(body: &'static str) -> Response {
}
/// `GET /health` — liveness probe.
async fn health() -> &'static str {
#[utoipa::path(
get,
path = "/health",
tag = "system",
responses((
status = 200,
description = "The process is alive",
body = String,
content_type = "text/plain",
)),
)]
pub async fn health() -> &'static str {
"ok\n"
}
+3 -1
View File
@@ -602,7 +602,9 @@ impl TurnManager {
// is re-asked without streaming, where the server returns the call it
// dropped from the SSE path. Bounded by `LLM_TOOL_MAX_ITERATIONS`. With
// no tools configured the loop runs exactly once.
let registry = llm.tools().cloned();
// Re-read per turn: a runtime override (`POST /api/tools`) is picked up
// by the next turn without a restart.
let registry = llm.tools();
let max_rounds = self.config.llm_tool_max_iterations.max(1);
let mut spoken = String::new();
let mut round = 0usize;
+161 -41
View File
@@ -42,12 +42,20 @@
//! ```
//!
//! - `url` may contain `{arg}` placeholders substituted from the call arguments.
//! - `GET` sends the remaining arguments as query parameters; `POST` (default)
//! sends them as a JSON body.
//! - `method` accepts **any** HTTP verb, case-insensitive (`GET`, `POST`, `PUT`,
//! `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, …), default `POST`.
//! - The arguments left after the URL placeholders travel as **query
//! parameters** for the read-style verbs (`GET`, `HEAD`, `OPTIONS`, `TRACE`,
//! `DELETE`) and as a **JSON body** for every other verb (`POST`, `PUT`,
//! `PATCH`, custom verbs).
//! - `headers` are optional and sent verbatim (API keys, etc.).
//!
//! That covers a REST surface end to end: `GET` for lists/lookups, `POST` to
//! add, `PUT`/`PATCH` to update, `DELETE` to remove (id in the URL), `HEAD` to
//! probe.
use anyhow::{Context, Result};
use reqwest::Client;
use reqwest::{Client, Method};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
@@ -74,7 +82,11 @@ pub struct ToolSpec {
pub parameters: Value,
/// Target URL. May contain `{arg}` placeholders.
pub url: String,
/// HTTP method (`GET` or `POST`; default `POST`).
/// HTTP method — any verb, case-insensitive (`GET`, `POST`, `PUT`, `PATCH`,
/// `DELETE`, `HEAD`, `OPTIONS`, …). Default `POST`. Read-style verbs
/// (`GET`/`HEAD`/`OPTIONS`/`TRACE`/`DELETE`) send the arguments left after
/// the URL placeholders as query parameters, every other verb as a JSON
/// body.
#[serde(default = "default_method")]
pub method: String,
/// Extra headers sent with the request (API keys, content type, …).
@@ -107,26 +119,40 @@ impl ToolRegistry {
pub fn load(path: &Path, timeout_secs: u64, max_result_chars: usize) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading tools file {}", path.display()))?;
let specs: Vec<ToolSpec> =
serde_json::from_str(&raw).with_context(|| format!("parsing tools file {}", path.display()))?;
if specs.is_empty() {
warn!(path = %path.display(), "tools file contains no tool definitions");
let reg = Self::from_json(&raw, timeout_secs, max_result_chars)
.with_context(|| format!("parsing tools file {}", path.display()))?;
info!(
path = %path.display(),
tools = reg.len(),
names = ?reg.names(),
"tool calling enabled"
);
Ok(reg)
}
/// Build a registry from the JSON definitions directly, without a file.
///
/// Used by `POST /api/tools` to install definitions supplied in the request
/// body (see [`crate::tools_api`]); the same array format as
/// `LLM_TOOLS_FILE` is expected.
pub fn from_json(raw: &str, timeout_secs: u64, max_result_chars: usize) -> Result<Self> {
let specs: Vec<ToolSpec> = serde_json::from_str(raw)?;
for spec in &specs {
if spec.name.trim().is_empty() || spec.url.trim().is_empty() {
warn!(name = %spec.name, "tool definition with empty name/url ignored");
}
if http_method(&spec.method).is_none() {
warn!(
name = %spec.name,
method = %spec.method,
"tool definition with an invalid HTTP method — calls will fail"
);
}
}
let http = Client::builder()
.timeout(Duration::from_secs(timeout_secs.max(1)))
.build()
.unwrap_or_default();
info!(
path = %path.display(),
tools = specs.len(),
names = ?specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
"tool calling enabled"
);
Ok(Self { specs, http, max_result_chars })
}
@@ -135,6 +161,16 @@ impl ToolRegistry {
self.specs.is_empty()
}
/// Number of defined tools.
pub fn len(&self) -> usize {
self.specs.len()
}
/// The defined tool names, for logging.
pub fn names(&self) -> Vec<&str> {
self.specs.iter().map(|s| s.name.as_str()).collect()
}
/// The OpenAI `tools` array forwarded in every chat-completions request, so
/// the model knows which functions it may call.
pub fn openai_tools(&self) -> Value {
@@ -169,38 +205,36 @@ impl ToolRegistry {
return format!("Error: unknown tool '{}'", call.name);
};
let args = call.arguments.as_object().cloned().unwrap_or_default();
let method = spec.method.trim().to_ascii_uppercase();
let url = substitute_placeholders(&spec.url, &args);
let mut request = match method.as_str() {
"GET" => {
let mut req = self.http.get(&url);
// Append arguments that were not already consumed by a `{arg}`
// placeholder in the URL.
let mut used: Vec<String> = Vec::new();
for key in args.keys() {
if spec.url.contains(&format!("{{{key}}}")) {
used.push(key.clone());
}
}
for (key, value) in &args {
if !used.contains(key) {
req = req.query(&[(key, scalar_to_string(value))]);
}
}
req
}
_ => {
let body = Value::Object(args.clone());
self.http.post(&url).json(&body)
}
let Some(method) = http_method(&spec.method) else {
warn!(tool = %call.name, method = %spec.method, "tool has an invalid HTTP method");
return format!(
"Error: invalid HTTP method '{}' for tool '{}'",
spec.method, call.name
);
};
let args = call.arguments.as_object().cloned().unwrap_or_default();
let url = substitute_placeholders(&spec.url, &args);
// Arguments consumed by a `{arg}` placeholder in the URL are already in
// `url`; the rest go to the query string or to the JSON body, depending
// on the verb.
let (query, body) = place_args(&method, &spec.url, &args);
let mut request = self.http.request(method.clone(), &url).query(&query);
if let Some(body) = body {
request = request.json(&body);
}
for (key, value) in &spec.headers {
request = request.header(key, substitute_placeholders(value, &args));
}
debug!(tool = %call.name, method = %method, url = %url, "executing tool");
debug!(
tool = %call.name,
method = %method,
url = %url,
query = query.len(),
"executing tool"
);
match request.send().await {
Ok(resp) => {
let status = resp.status();
@@ -221,6 +255,55 @@ impl ToolRegistry {
}
}
/// Parse the configured HTTP method (case-insensitive). An empty value falls
/// back to the default (`POST`); `None` means the string is not a valid HTTP
/// method token.
fn http_method(raw: &str) -> Option<Method> {
let raw = raw.trim();
if raw.is_empty() {
return Some(Method::POST);
}
Method::from_bytes(raw.to_ascii_uppercase().as_bytes()).ok()
}
/// Whether the arguments left after the URL placeholders are sent in the JSON
/// body (`true`) or appended as query parameters (`false`).
///
/// The read-style verbs — the ones a REST API uses to list, look up, probe and
/// remove — go in the query string; every other verb (`POST`, `PUT`, `PATCH`,
/// and any custom verb) carries them in a JSON body.
fn args_in_body(method: &Method) -> bool {
!matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "TRACE" | "DELETE")
}
/// Split the call arguments into the query parameters and the JSON body for
/// `method`.
///
/// Arguments consumed by a `{arg}` placeholder in `url` are dropped — they are
/// already in the URL — and the remaining ones go to the query string or to the
/// body. A body-style verb always gets a body (`{}` when no argument is left),
/// so a bodyless `POST` never surprises the server.
fn place_args(
method: &Method,
url: &str,
args: &Map<String, Value>,
) -> (Vec<(String, String)>, Option<Value>) {
let leftover: Map<String, Value> = args
.iter()
.filter(|(key, _)| !url.contains(&format!("{{{key}}}")))
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
if args_in_body(method) {
(Vec::new(), Some(Value::Object(leftover)))
} else {
let query = leftover
.iter()
.map(|(key, value)| (key.clone(), scalar_to_string(value)))
.collect();
(query, None)
}
}
/// Replace every `{arg}` placeholder in `template` with the matching argument.
fn substitute_placeholders(template: &str, args: &Map<String, Value>) -> String {
let mut out = template.to_string();
@@ -526,4 +609,41 @@ mod tests {
assert_eq!(calls.len(), 1);
assert!(calls[0].arguments.as_object().unwrap().is_empty());
}
#[test]
fn every_http_verb_is_accepted() {
for verb in ["GET", "post", "Put", "paTCH", "DELETE", "head", "OPTIONS", "trace", "PURGE"] {
assert!(http_method(verb).is_some(), "{verb} should be accepted");
}
assert_eq!(http_method("put").unwrap(), Method::PUT);
assert_eq!(http_method(" ").unwrap(), Method::POST); // empty ⇒ default
assert!(http_method("NOT A METHOD").is_none());
}
#[test]
fn args_placement_follows_the_verb() {
let args: Map<String, Value> =
serde_json::from_value(json!({ "id": 5, "force": true })).unwrap();
let expect_query = vec![("force".to_string(), "true".to_string())];
// list / look up / probe / delete: leftovers in the query string, the
// argument consumed by `{id}` dropped from it
for verb in [Method::GET, Method::HEAD, Method::OPTIONS, Method::DELETE] {
let (query, body) = place_args(&verb, "/items/{id}", &args);
assert_eq!(query, expect_query, "{verb}");
assert!(body.is_none(), "{verb}");
}
// add / update / any custom verb: leftovers in the JSON body
for verb in [Method::POST, Method::PUT, Method::PATCH] {
let (query, body) = place_args(&verb, "/items/{id}", &args);
assert!(query.is_empty(), "{verb}");
assert_eq!(body.unwrap(), json!({ "force": true }), "{verb}");
}
// a body-style verb with nothing left over still sends an empty body
let only_id: Map<String, Value> = serde_json::from_value(json!({ "id": 5 })).unwrap();
let (_, body) = place_args(&Method::PUT, "/items/{id}", &only_id);
assert_eq!(body.unwrap(), json!({}));
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Runtime tool-definition endpoint (`POST /api/tools`).
//!
//! The tools the model may call are normally read **once at startup** from the
//! JSON file named by `LLM_TOOLS_FILE` (see [`crate::tools`]). This endpoint
//! installs a different set at runtime, without a restart: the request body is
//! the same JSON array the file holds.
//!
//! The override lives **in memory only, for the life of the process**:
//!
//! - the endpoint is never called → the engine keeps using the
//! `LLM_TOOLS_FILE` definitions (and so it does again after a restart);
//! - the endpoint is called → its definitions replace the file ones from the
//! **next turn** on, until another call replaces them again.
//!
//! `GET /api/tools` reports which definitions are active and where they come
//! from (`file`, `request` or `none`), so the state can be checked without
//! guessing.
use crate::llm::LlmClient;
use crate::tools::ToolRegistry;
use crate::ws::AppState;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
use std::sync::Arc;
use tracing::{info, warn};
/// `POST /api/tools` — replace the active tool definitions with the JSON array
/// in the body (same schema as `LLM_TOOLS_FILE`).
#[utoipa::path(
post,
path = "/api/tools",
tag = "tools",
request_body(
content = String,
content_type = "application/json",
description = "JSON array of tool definitions, same format as LLM_TOOLS_FILE: \
`name`, `description`, `parameters` (JSON schema), `url`, `method`, \
`headers`. Example: \
[{\"name\":\"get_weather\",\"description\":\"…\",\
\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\
\"required\":[\"city\"]},\"url\":\"https://wttr.in/{city}?format=3\",\"method\":\"GET\"}]",
),
responses(
(status = 200, description = "Definitions installed — they apply from the next turn on", body = crate::apidoc::ToolsResponse),
(status = 400, description = "Empty body, non-UTF-8 body, invalid JSON or no tool definition", body = crate::apidoc::ErrorResponse),
(status = 503, description = "LLM disabled — tool calling unavailable", body = crate::apidoc::ErrorResponse),
),
)]
pub async fn set_tools(State(state): State<AppState>, body: Bytes) -> Response {
let Some(llm) = state.llm.clone() else {
return error_response(
StatusCode::SERVICE_UNAVAILABLE,
"LLM disabled — tool calling unavailable",
);
};
if body.is_empty() {
return error_response(
StatusCode::BAD_REQUEST,
"empty body: send the tool definitions as a JSON array",
);
}
let Ok(raw) = std::str::from_utf8(&body) else {
return error_response(StatusCode::BAD_REQUEST, "body is not valid UTF-8");
};
// Same builder as the startup path, so a body that works here works as an
// `LLM_TOOLS_FILE` too (and vice versa).
let registry = match ToolRegistry::from_json(
raw,
state.config.llm_tool_timeout_secs,
state.config.llm_tool_result_max_chars,
) {
Ok(reg) => reg,
Err(e) => {
warn!(error = %e, "rejected tool definitions");
return error_response(
StatusCode::BAD_REQUEST,
&format!("invalid tool definitions: {e}"),
);
}
};
if registry.is_empty() {
return error_response(
StatusCode::BAD_REQUEST,
"no tool definition in the body — omit the call to keep the LLM_TOOLS_FILE ones",
);
}
info!(
tools = registry.len(),
names = ?registry.names(),
"tool definitions installed at runtime"
);
llm.set_tools(Some(Arc::new(registry)));
Json(tool_state(&llm)).into_response()
}
/// `GET /api/tools` — the active definitions and where they come from.
#[utoipa::path(
get,
path = "/api/tools",
tag = "tools",
responses(
(status = 200, description = "Active tool definitions as advertised to the model", body = crate::apidoc::ToolsResponse),
),
)]
pub async fn list_tools(State(state): State<AppState>) -> Response {
match state.llm.clone() {
Some(llm) => Json(tool_state(&llm)).into_response(),
// No LLM: the endpoint reports an empty, explicitly-sourced state
// rather than an error — the answer is simply "no tools".
None => Json(json!({
"ok": true,
"count": 0,
"source": "none",
"tools": [],
}))
.into_response(),
}
}
/// The response body shared by both handlers: the active definitions in the
/// same shape they are sent to the model, plus their origin.
fn tool_state(llm: &LlmClient) -> Value {
let registry = llm.tools();
let source = match (&registry, llm.tools_from_request()) {
(None, _) => "none",
(Some(_), true) => "request",
(Some(_), false) => "file",
};
json!({
"ok": true,
"count": registry.as_ref().map(|r| r.len()).unwrap_or(0),
"source": source,
"tools": registry
.as_ref()
.map(|r| r.openai_tools())
.unwrap_or_else(|| json!([])),
})
}
/// JSON error body shared with the other REST endpoints.
fn error_response(status: StatusCode, msg: &str) -> Response {
(status, Json(json!({ "ok": false, "error": msg }))).into_response()
}
+159
View File
@@ -0,0 +1,159 @@
//! Raw-audio transcription endpoint.
//!
//! `POST /api/transcribe` accepts an audio file in the request body (any
//! container ffmpeg can read: wav, mp3, m4a, ogg, flac…), decodes it to mono
//! 16-bit PCM at [`STT_SAMPLE_RATE`] through ffmpeg, and runs the Parakeet TDT
//! pipeline ([`ParakeetStt::infer`](crate::stt::ParakeetStt::infer)) on the
//! whole take. The file is not segmented: it always returns a single transcript,
//! so a short clip is transcribed verbatim and a longer file is read end-to-end.
//!
//! The endpoint reuses the engine's already-loaded STT model from
//! [`AppState`] (no second ONNX environment is created); when the model is
//! missing it answers `503` instead of failing silently.
use crate::config::{Config, STT_SAMPLE_RATE};
use crate::ws::AppState;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use std::ffi::OsString;
use std::path::Path;
use std::process::Stdio;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::process::Command;
use tracing::{error, info};
/// `POST /api/transcribe` — body is the raw audio; returns the transcript.
#[utoipa::path(
post,
path = "/api/transcribe",
tag = "transcribe",
request_body(
content = String,
content_type = "application/octet-stream",
description = "Raw audio file bytes (wav, mp3, m4a, ogg, flac…). ffmpeg probes the container; the audio is downmixed to mono 16 kHz for Parakeet.",
),
responses(
(status = 200, description = "Transcription of the whole file", body = crate::apidoc::TranscribeResponse),
(status = 400, description = "Empty body", body = crate::apidoc::ErrorResponse),
(status = 500, description = "Temporary-file or inference failure", body = crate::apidoc::ErrorResponse),
(status = 502, description = "ffmpeg decode failed", body = crate::apidoc::ErrorResponse),
(status = 503, description = "STT model not loaded", body = crate::apidoc::ErrorResponse),
),
)]
pub async fn transcribe(State(state): State<AppState>, body: Bytes) -> Response {
let Some(stt) = state.stt.clone() else {
return error_response(StatusCode::SERVICE_UNAVAILABLE, "STT engine not loaded");
};
if body.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "empty audio body");
}
// ffmpeg needs a seekable input for most containers, so spill the body to a
// temp file (removed below) rather than piping it straight in.
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let input_path =
std::env::temp_dir().join(format!("transcribe_{}_{}.bin", std::process::id(), stamp));
if let Err(e) = tokio::fs::write(&input_path, &body).await {
error!(error = %e, "failed to store upload for transcription");
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "cannot store upload");
}
let pcm = decode_to_pcm16(&state.config, &input_path).await;
let _ = tokio::fs::remove_file(&input_path).await;
let pcm = match pcm {
Ok(p) => p,
Err(msg) => {
error!(error = %msg, "transcription decode failed");
return error_response(StatusCode::BAD_GATEWAY, &msg);
}
};
let samples: Vec<f32> = pcm.iter().map(|&s| s as f32 / 32768.0).collect();
let duration_ms = (samples.len() as u64 * 1000) / STT_SAMPLE_RATE as u64;
// Parakeet inference is CPU-bound and holds a lock on the ONNX sessions:
// run it off the async runtime on a blocking thread.
let result = tokio::task::spawn_blocking(move || stt.infer(&samples)).await;
let (tokens, text) = match result {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
error!(error = %e, "Parakeet inference failed");
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "transcription failed");
}
Err(e) => {
error!(error = %e, "transcription task panicked");
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "transcription failed");
}
};
info!(duration_ms, tokens = tokens.len(), chars = text.len(), "transcription ready");
(
StatusCode::OK,
Json(json!({
"ok": true,
"text": text,
"duration_ms": duration_ms,
"tokens": tokens.len(),
})),
)
.into_response()
}
/// Decode `input` to mono 16-bit PCM at [`STT_SAMPLE_RATE`] via ffmpeg, emitting
/// raw `s16le` on stdout (no WAV header to parse).
async fn decode_to_pcm16(config: &Config, input: &Path) -> Result<Vec<i16>, String> {
let args: Vec<OsString> = vec![
"-hide_banner".into(),
"-nostdin".into(),
"-loglevel".into(),
"error".into(),
"-i".into(),
input.into(),
"-ac".into(),
"1".into(),
"-ar".into(),
STT_SAMPLE_RATE.to_string().into(),
"-f".into(),
"s16le".into(),
"-c:a".into(),
"pcm_s16le".into(),
"pipe:1".into(),
];
let out = Command::new(&config.media_ffmpeg_bin)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("failed to spawn ffmpeg ({}): {e}", config.media_ffmpeg_bin))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let last = err.trim().lines().last().unwrap_or("").to_string();
return Err(if last.is_empty() {
format!("ffmpeg exited with {}", out.status)
} else {
format!("ffmpeg: {last}")
});
}
Ok(out
.stdout
.chunks_exact(2)
.map(|b| i16::from_le_bytes([b[0], b[1]]))
.collect())
}
/// JSON error body shared with the other REST endpoints.
fn error_response(status: StatusCode, msg: &str) -> Response {
(status, Json(json!({ "ok": false, "error": msg }))).into_response()
}