diff --git a/.env.example b/.env.example index d1cbbc2..16da6a4 100644 --- a/.env.example +++ b/.env.example @@ -317,6 +317,10 @@ LLM_STREAM_CHUNK_CHARS=240 # `` 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, diff --git a/Cargo.lock b/Cargo.lock index 04e231f..b8f7f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index bbf6f66..e91a4df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index 6dbaaff..50893a7 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 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=[&text=]` | 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`). --- diff --git a/TOOL_CALLING.md b/TOOL_CALLING.md new file mode 100644 index 0000000..66d4b89 --- /dev/null +++ b/TOOL_CALLING.md @@ -0,0 +1,1020 @@ +# Tool calling — Guida operativa / Operating guide / Guía de uso + +Documento unico, tre lingue: [Italiano](#italiano) · [English](#english) · [Español](#español). +Riferimenti al codice: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs), +[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs), +[`tools.example.json`](tools.example.json). + +--- + +## Italiano + +### 1. Cosa fa + +Il modello LLM del motore audio può **chiamare servizi HTTP esterni** prima di +rispondere. Un "tool" non è codice: è la **descrizione JSON di una richiesta +HTTP** (URL, metodo, header, schema degli argomenti). Aggiungere un tool significa +scrivere qualche riga in un file JSON — **nessuna modifica al codice Rust**. + +Flusso per ogni turno utente: + +1. Il motore allega l'array `tools` (formato OpenAI) a **ogni** richiesta di + completamento, con `tool_choice: "auto"`. +2. Il modello, se serve, emette la chiamata. Il server LLM di produzione può non + avere un parser di tool: la chiamata torna **come testo** nel formato nativo + Qwen (`…`), che il motore **parsa + client-side**. +3. Il motore esegue la richiesta HTTP, ne tronca il risultato e lo rimanda al + modello come messaggio di ruolo `tool`. +4. Il modello produce la risposta finale, che viene sintetizzata in voce. + +Il blocco `` viene **sempre rimosso** dal testo parlato e mostrato in +chat: l'utente non lo sente e non lo vede mai. + +### 2. Abilitazione e override runtime + +| Variabile | Default | Significato | +|---|---|---| +| `LLM_TOOLS_ENABLED` | auto | Interruttore generale. Se non impostato vale `true` quando `LLM_TOOLS_FILE` è un percorso non vuoto. | +| `LLM_TOOLS_FILE` | *(vuoto)* | Percorso del file JSON con le definizioni. Vuoto o assente ⇒ tool calling **disattivato**. | +| `LLM_TOOL_MAX_ITERATIONS` | `4` | Numero massimo di round di tool per turno (chiamata → esecuzione → ri-prompt). Limita i loop infiniti. | +| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout di **una singola** richiesta HTTP del tool. | +| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Il risultato del tool viene troncato a questo numero di caratteri prima di tornare al modello. | + +```dotenv +LLM_TOOLS_ENABLED=true +LLM_TOOLS_FILE=tools.example.json +LLM_TOOL_MAX_ITERATIONS=4 +LLM_TOOL_TIMEOUT_SECS=15 +LLM_TOOL_RESULT_MAX_CHARS=2000 +``` + +Attenzione: il file dei tool viene letto **una sola volta all'avvio** +(`ToolRegistry::load`). Dopo aver modificato il JSON serve un **riavvio del +servizio** perché le nuove definizioni siano attive. + +#### Override a runtime — `POST /api/tools` + +Per cambiare le definizioni **senza riavvio**, invia lo stesso array JSON a +`POST /api/tools`: + +```bash +curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \ + -d '[{"name":"get_weather","description":"Meteo di una città","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]' +``` + +- Le definizioni inviate **sostituiscono** quelle del file dal **turno + successivo** (nessun riavvio). Se l'endpoint **non viene mai chiamato**, il + motore continua a usare quelle di `LLM_TOOLS_FILE`: è il comportamento + predefinito, e un riavvio lo ripristina. +- L'override vive **solo in memoria**: non scrive sul file e non sopravvive al + riavvio del processo. +- `GET /api/tools` riporta le definizioni attive e la loro origine: `source` vale + `file` (default da `.env`), `request` (installate via endpoint) oppure `none`. +- Risposte: `200` con `{ok,count,source,tools}`; `400` se il body è vuoto, non + UTF-8, non è un array JSON valido o non contiene alcuna definizione; `503` se + l'LLM è disattivato. +- Il body accetta **lo stesso formato del file**: un array che funziona qui + funziona anche come `LLM_TOOLS_FILE`. + +```bash +curl -s localhost:8787/api/tools +# {"ok":true,"count":2,"source":"file","tools":[ … ]} +``` + +Implementazione: [`src/tools_api.rs`](src/tools_api.rs). Entrambi gli endpoint +compaiono anche nella Swagger UI (`/docs`, tag `tools`). + +### 3. Definire un tool + +Il file è un array JSON. Ogni oggetto ha i campi seguenti: + +| Campo | Obbligatorio | Descrizione | +|---|---|---| +| `name` | sì | Nome della funzione che il modello invoca. Deve combaciare con lo `name` della definizione. | +| `description` | consigliato | **È ciò che il modello legge per decidere se e quando chiamare il tool**: scrivila esplicita e orientata all'uso ("chiama questo tool quando l'utente chiede…"). | +| `parameters` | no | JSON Schema degli argomenti. Se assente vale `{"type":"object","properties":{}}`. | +| `url` | sì | URL di destinazione. Può contenere segnaposto `{argomento}`. | +| `method` | no | **Qualsiasi** verbo HTTP, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, e verbi custom (es. `PURGE`). Default `POST`. | +| `headers` | no | Header inviati letteralmente (chiavi API, `Content-Type`, …). I valori possono contenere segnaposto `{argomento}`. | + +#### Sostituzione dei segnaposto + +In `url` **e** in `headers`, ogni occorrenza di `{nome_argomento}` viene +sostituita con il valore corrispondente della chiamata (stringhe senza virgolette, +numeri/booleani come testo). + +#### Dove finiscono gli argomenti (per verbo) + +Gli argomenti **già consumati** da un segnaposto `{arg}` nell'URL *non* vengono +re-inviati: sono già nel path. I rimanenti seguono il verbo: + +| Verbo | Argomenti rimanenti | +|---|---| +| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (nessun body) | +| `POST`, `PUT`, `PATCH` e verbi custom | **body JSON** (in assenza di argomenti: `{}`) | + +### 4. Esempi + +**GET con segnaposto nell'URL** (il `city` finisce nel path, non nella query): + +```json +{ + "name": "get_weather", + "description": "Meteo e temperatura attuali di una città. Chiamalo ogni volta che l'utente chiede il meteo, la temperatura o le previsioni di un luogo specifico.", + "parameters": { + "type": "object", + "properties": { "city": { "type": "string", "description": "Nome della città, es. 'Roma'" } }, + "required": ["city"] + }, + "url": "https://wttr.in/{city}?format=3", + "method": "GET" +} +``` + +**POST con header autenticato** (segreto preso dagli argomenti): + +```json +{ + "name": "create_note", + "description": "Salva una nota nel gestionale interno.", + "parameters": { + "type": "object", + "properties": { + "token": { "type": "string", "description": "Token di sessione dell'utente" }, + "text": { "type": "string", "description": "Contenuto della nota" } + }, + "required": ["token", "text"] + }, + "url": "https://gestione.interno.local/api/notes", + "method": "POST", + "headers": { "Authorization": "Bearer {token}" } +} +``` + +**POST semplice (webhook)**: + +```json +{ + "name": "send_webhook", + "description": "Invia un payload JSON all'endpoint webhook configurato (notifiche / home automation).", + "parameters": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + "url": "https://httpbin.org/post", + "method": "POST", + "headers": { "X-Tool": "audio-agent-engine" } +} +``` + +**Superficie REST completa** — un tool per verbo sullo stesso endpoint +(`lista` → `GET`, `dettaglio` → `GET` con `{id}`, `aggiungi` → `POST`, +`aggiorna` → `PATCH`, `sostituisci` → `PUT`, `elimina` → `DELETE`, +`esiste?` → `HEAD`): + +```json +[ + { + "name": "list_items", + "description": "Elenca gli articoli, con filtri opzionali.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer" }, + "search": { "type": "string" } + } + }, + "url": "https://api.example.local/api/items", + "method": "GET" + }, + { + "name": "get_item", + "description": "Dettaglio di un articolo per id.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "GET" + }, + { + "name": "add_item", + "description": "Crea un nuovo articolo.", + "parameters": { + "type": "object", + "properties": { "name": { "type": "string" }, "qty": { "type": "integer" } }, + "required": ["name"] + }, + "url": "https://api.example.local/api/items", + "method": "POST" + }, + { + "name": "update_item", + "description": "Aggiorna parzialmente un articolo (solo i campi inviati).", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "PATCH" + }, + { + "name": "delete_item", + "description": "Elimina un articolo per id.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "DELETE" + }, + { + "name": "item_exists", + "description": "Verifica l'esistenza di un articolo (solo status, nessun body).", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "HEAD" + } +] +``` + +Richieste risultanti: `GET /api/items?limit=10`, `GET /api/items/5`, +`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`, +`DELETE /api/items/5`, `HEAD /api/items/5`. + +Parti da [`tools.example.json`](tools.example.json): copialo, adattalo e punta +`LLM_TOOLS_FILE` al tuo file. + +### 5. Ciclo multi-round e fallback in streaming + +- Ogni round produce **un** completamento; le chiamate trovate vengono eseguite e + i risultati rinviati al modello nel round successivo (round-trip standard + OpenAI: messaggio `assistant` con la chiamata, poi un messaggio `tool` per + ciascun risultato). +- Il ciclo si ferma quando non ci sono più chiamate, oppure al raggiungimento di + `LLM_TOOL_MAX_ITERATIONS`. +- **Fallback in streaming**: alcuni server OpenAI-compatible (vLLM con + `--tool-call-parser` non allineato al modello) **mangiano** la chiamata sul + percorso SSE: il round finisce vuoto, senza errore HTTP. Se un round torna + vuoto e i tool sono configurati, il motore **rifà la richiesta una volta senza + streaming** (`chat_reply`) e recupera la chiamata da lì. Se nemmeno lì c'è una + chiamata ma c'è del testo, quel testo viene parlato. Questo fallback si attiva + **solo** sui round vuoti: nessun costo sui turni normali. + +### 6. Errori e troncamento + +L'esecuzione di un tool **non fallisce mai** in modo bloccante: l'errore diventa +testo che il modello può leggere e da cui può recuperare. + +| Situazione | Testo consegnato al modello | +|---|---| +| Tool inesistente | `Error: unknown tool ''` | +| Verbo HTTP non valido nella definizione | `Error: invalid HTTP method '' for tool ''` | +| Status HTTP non 2xx | `Error: HTTP — ` | +| Errore di rete/timeout | `Error: tool request failed: ` | +| Risultato più lungo del limite | Troncato a `LLM_TOOL_RESULT_MAX_CHARS`, con suffisso `… [truncated]` | + +### 7. Nota importante sul parser lato server + +Se il server LLM gira **con** un parser di tool, il flag deve combaciare con il +formato nativo del modello (`--enable-auto-tool-choice --tool-call-parser …`). +Il disallineamento è **silenzioso**: un parser `hermes` (che si aspetta JSON dentro +``) contro un modello Qwen che emette XML fa fallire il parse, la +chiamata viene scartata e in streaming la risposta torna vuota (nei log vLLM: +`Error in extracting tool call from response`). Per questo modello il parser +corretto è `qwen3_coder`. Il motore è comunque robusto: gestisce **entrambi** i +formati, l'array strutturato `message.tool_calls[]` **e** il testo nativo +``, più il fallback descritto al punto 5. + +Sintomo tipico di disallineamento: l'agente tace, oppure "narra" la chiamata senza +eseguirla. + +### 8. Verifica end-to-end + +Con il servizio attivo su `ws://localhost:8787/ws`, invia un testo utente che +deve innescare il tool e verifica gli eventi: + +```python +import asyncio, json, websockets + +async def main(): + async with websockets.connect("ws://localhost:8787/ws") as ws: + await ws.send(json.dumps({"type": "user_text", "text": "Che tempo fa a Roma?"})) + while True: + msg = json.loads(await ws.recv()) + if msg.get("type") == "tool_call": + print("TOOL:", msg["name"], msg["arguments"]) + if msg.get("type") == "assistant_end": + print("turno concluso") + break + +asyncio.run(main()) +``` + +Eventi attesi sul WebSocket, in ordine: `tool_call` (con `name` e `arguments`) e +poi `assistant_end`. Il blocco `` non deve comparire in nessun +`assistant_delta`. + +### 9. Limiti e buone pratiche + +- Le chiamate non vengono trasmesse in streaming al client: la risposta dell'agente + arriva **dopo** l'esecuzione del tool. Sui tool lenti alza + `LLM_TOOL_TIMEOUT_SECS` solo quanto serve o riduci il lavoro lato server. +- La qualità del `description` determina l'uso del tool: il system prompt di + default non parla dei tool, decide solo il modello leggendo le descrizioni. +- Tieni i risultati **brevi**: oltre `LLM_TOOL_RESULT_MAX_CHARS` vengono troncati e + il modello può perdere informazione utile. +- Non esporre segreti in chiaro nel `url`: usa `headers` con segnaposto, oppure un + endpoint interno. +- Il file dei tool è letto a **caldo una sola volta**: dopo una modifica riavvia il + servizio. + +--- + +## English + +### 1. What it does + +The engine's LLM can **call external HTTP services** before answering. A "tool" is +not code: it is the **JSON description of an HTTP request** (URL, method, headers, +argument schema). Adding a tool means writing a few lines in a JSON file — +**no Rust changes**. + +Per user turn: + +1. The engine attaches the OpenAI-shaped `tools` array to **every** completion + request, with `tool_choice: "auto"`. +2. If needed, the model emits the call. The production LLM server may run without a + tool parser, so the call comes back **as text** in the Qwen native format + (`…`), which the engine **parses + client-side**. +3. The engine executes the HTTP request, truncates the result and hands it back to + the model as a `tool` role message. +4. The model produces the final answer, which is synthesised to speech. + +The `` block is **always stripped** from both the spoken and the +displayed text: the user never hears or sees it. + +### 2. Enabling it and runtime override + +| Variable | Default | Meaning | +|---|---|---| +| `LLM_TOOLS_ENABLED` | auto | Master switch. When unset it is `true` if `LLM_TOOLS_FILE` is a non-empty path. | +| `LLM_TOOLS_FILE` | *(empty)* | Path to the JSON definitions file. Empty or missing ⇒ tool calling **disabled**. | +| `LLM_TOOL_MAX_ITERATIONS` | `4` | Maximum tool rounds per turn (call → execute → re-prompt). Bounds a runaway loop. | +| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout of **one** tool HTTP request. | +| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | The tool result is truncated to this many characters before going back to the model. | + +```dotenv +LLM_TOOLS_ENABLED=true +LLM_TOOLS_FILE=tools.example.json +LLM_TOOL_MAX_ITERATIONS=4 +LLM_TOOL_TIMEOUT_SECS=15 +LLM_TOOL_RESULT_MAX_CHARS=2000 +``` + +Note: the tools file is read **once at startup** (`ToolRegistry::load`). After +editing the JSON, **restart the service** for the new definitions to take effect. + +#### Runtime override — `POST /api/tools` + +To change the definitions **without a restart**, POST the same JSON array to +`POST /api/tools`: + +```bash +curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \ + -d '[{"name":"get_weather","description":"Current weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]' +``` + +- The definitions you send **replace** the file ones from the **next turn** on + (no restart). If the endpoint is **never called**, the engine keeps using the + `LLM_TOOLS_FILE` definitions — that is the default, and a restart restores it. +- The override lives **in memory only**: it does not write the file and does not + survive a process restart. +- `GET /api/tools` reports the active definitions and their origin: `source` is + `file` (the `.env` default), `request` (installed through the endpoint) or + `none`. +- Responses: `200` with `{ok,count,source,tools}`; `400` when the body is empty, + is not UTF-8, is not a valid JSON array, or holds no tool definition; `503` + when the LLM is disabled. +- The body uses **the same format as the file**: an array that works here also + works as an `LLM_TOOLS_FILE`. + +```bash +curl -s localhost:8787/api/tools +# {"ok":true,"count":2,"source":"file","tools":[ … ]} +``` + +Implementation: [`src/tools_api.rs`](src/tools_api.rs). Both endpoints also show +up in the Swagger UI (`/docs`, tag `tools`). + +### 3. Defining a tool + +The file is a JSON array. Each object has these fields: + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Name of the function the model calls. Must match the `name` in the definition. | +| `description` | recommended | **This is what the model reads to decide whether and when to call the tool**: make it explicit and usage-oriented ("call this tool when the user asks…"). | +| `parameters` | no | JSON Schema for the arguments. When absent it defaults to `{"type":"object","properties":{}}`. | +| `url` | yes | Target URL. May contain `{arg}` placeholders. | +| `method` | no | **Any** HTTP verb, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, and custom verbs (e.g. `PURGE`). Default `POST`. | +| `headers` | no | Headers sent verbatim (API keys, `Content-Type`, …). Values may contain `{arg}` placeholders. | + +#### Placeholder substitution + +In `url` **and** `headers`, every `{argument_name}` is replaced with the matching +call argument (strings unquoted, numbers/booleans rendered as text). + +#### Where the arguments go (by verb) + +Arguments **already consumed** by a `{arg}` placeholder in the URL are *not* sent +again: they are already in the path. The remaining ones follow the verb: + +| Verb | Remaining arguments | +|---|---| +| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (no body) | +| `POST`, `PUT`, `PATCH` and custom verbs | **JSON body** (`{}` when nothing is left) | + +### 4. Examples + +**GET with a URL placeholder** (`city` goes into the path, not the query): + +```json +{ + "name": "get_weather", + "description": "Current weather and temperature for a city. Call this whenever the user asks about the weather, the temperature or the forecast for a specific place.", + "parameters": { + "type": "object", + "properties": { "city": { "type": "string", "description": "City name, e.g. 'Paris' or 'Roma'" } }, + "required": ["city"] + }, + "url": "https://wttr.in/{city}?format=3", + "method": "GET" +} +``` + +**POST with an authenticated header** (secret taken from the arguments): + +```json +{ + "name": "create_note", + "description": "Save a note into the internal management system.", + "parameters": { + "type": "object", + "properties": { + "token": { "type": "string", "description": "User session token" }, + "text": { "type": "string", "description": "Note content" } + }, + "required": ["token", "text"] + }, + "url": "https://internal.example.local/api/notes", + "method": "POST", + "headers": { "Authorization": "Bearer {token}" } +} +``` + +**Plain POST (webhook)**: + +```json +{ + "name": "send_webhook", + "description": "POST a JSON payload to the configured webhook endpoint (notification / home-automation hook).", + "parameters": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + "url": "https://httpbin.org/post", + "method": "POST", + "headers": { "X-Tool": "audio-agent-engine" } +} +``` + +**A full REST surface** — one tool per verb on the same endpoint +(`list` → `GET`, `detail` → `GET` with `{id}`, `add` → `POST`, +`update` → `PATCH`, `replace` → `PUT`, `delete` → `DELETE`, `exists?` → `HEAD`): + +```json +[ + { + "name": "list_items", + "description": "List the items, with optional filters.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer" }, + "search": { "type": "string" } + } + }, + "url": "https://api.example.local/api/items", + "method": "GET" + }, + { + "name": "get_item", + "description": "Details of one item by id.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "GET" + }, + { + "name": "add_item", + "description": "Create a new item.", + "parameters": { + "type": "object", + "properties": { "name": { "type": "string" }, "qty": { "type": "integer" } }, + "required": ["name"] + }, + "url": "https://api.example.local/api/items", + "method": "POST" + }, + { + "name": "update_item", + "description": "Partially update an item (only the fields sent).", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "PATCH" + }, + { + "name": "replace_item", + "description": "Replace an item entirely.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, + "required": ["id", "name"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "PUT" + }, + { + "name": "delete_item", + "description": "Delete an item by id.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "DELETE" + }, + { + "name": "item_exists", + "description": "Check whether an item exists (status only, no body).", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "HEAD" + } +] +``` + +Resulting requests: `GET /api/items?limit=10`, `GET /api/items/5`, +`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`, +`PUT /api/items/5` + body `{"name":"…"}`, `DELETE /api/items/5`, +`HEAD /api/items/5`. + +Start from [`tools.example.json`](tools.example.json): copy it, adapt it and point +`LLM_TOOLS_FILE` at your file. + +### 5. Multi-round loop and streaming fallback + +- Each round produces **one** completion; the calls found are executed and their + results are handed back for the next round (standard OpenAI round-trip: an + `assistant` message with the call, then one `tool` message per result). +- The loop stops when there are no more calls, or at `LLM_TOOL_MAX_ITERATIONS`. +- **Streaming fallback**: some OpenAI-compatible servers (vLLM with a + `--tool-call-parser` that does not match the model) **swallow** the call on the + SSE path: the round ends empty, with no HTTP error. When a round comes back empty + and tools are configured, the engine **re-asks once without streaming** + (`chat_reply`) and takes the call from there. If even that yields no call but + holds text, that text is spoken. The fallback only triggers on empty rounds, so + it costs nothing on normal turns. + +### 6. Errors and truncation + +Tool execution **never fails** in a blocking way: an error becomes text the model +can read and recover from. + +| Situation | Text handed to the model | +|---|---| +| Unknown tool | `Error: unknown tool ''` | +| Invalid HTTP verb in the definition | `Error: invalid HTTP method '' for tool ''` | +| Non-2xx HTTP status | `Error: HTTP — ` | +| Network error / timeout | `Error: tool request failed: ` | +| Result longer than the limit | Truncated to `LLM_TOOL_RESULT_MAX_CHARS`, suffixed with `… [truncated]` | + +### 7. Important note on the server-side parser + +If the LLM server runs **with** a tool parser, the flag must match the model's +native format (`--enable-auto-tool-choice --tool-call-parser …`). A mismatch is +**silent**: an `hermes` parser (expecting JSON inside ``) against a Qwen +model that emits XML makes the parse throw, the call is dropped and, in streaming, +the reply comes back empty (vLLM logs: `Error in extracting tool call from +response`). The correct parser for this model is `qwen3_coder`. The engine is +robust either way: it handles **both** formats, the structured +`message.tool_calls[]` array **and** the native `` text, plus the +fallback described in section 5. + +Typical mismatch symptom: the agent goes silent, or "narrates" the call without +executing it. + +### 8. End-to-end verification + +With the service running on `ws://localhost:8787/ws`, send user text that must +trigger the tool and check the events: + +```python +import asyncio, json, websockets + +async def main(): + async with websockets.connect("ws://localhost:8787/ws") as ws: + await ws.send(json.dumps({"type": "user_text", "text": "What's the weather in Paris?"})) + while True: + msg = json.loads(await ws.recv()) + if msg.get("type") == "tool_call": + print("TOOL:", msg["name"], msg["arguments"]) + if msg.get("type") == "assistant_end": + print("turn finished") + break + +asyncio.run(main()) +``` + +Expected WebSocket events, in order: `tool_call` (with `name` and `arguments`) then +`assistant_end`. The `` block must not appear in any `assistant_delta`. + +### 9. Limits and best practices + +- Calls are not streamed to the client: the agent's answer arrives **after** the + tool execution. For slow tools, raise `LLM_TOOL_TIMEOUT_SECS` only as much as + needed, or reduce server-side work. +- The `description` drives tool usage: the default system prompt does not mention + tools, the model decides solely from the descriptions. +- Keep results **short**: beyond `LLM_TOOL_RESULT_MAX_CHARS` they are truncated and + the model may lose useful information. +- Do not put secrets in cleartext in the `url`: use `headers` with placeholders, or + an internal endpoint. +- The tools file is read **once**: restart the service after editing it. + +--- + +## Español + +### 1. Qué hace + +El LLM del motor puede **llamar a servicios HTTP externos** antes de responder. Un +"tool" no es código: es la **descripción JSON de una petición HTTP** (URL, método, +cabeceras, esquema de argumentos). Añadir un tool significa escribir unas líneas en +un archivo JSON — **sin tocar el código Rust**. + +Por cada turno del usuario: + +1. El motor adjunta el array `tools` (formato OpenAI) a **cada** petición de + completion, con `tool_choice: "auto"`. +2. Si hace falta, el modelo emite la llamada. El servidor LLM de producción puede + no tener parser de tools: la llamada vuelve **como texto** en el formato nativo + de Qwen (`…`), que el motor **parsea en el + cliente**. +3. El motor ejecuta la petición HTTP, trunca el resultado y lo devuelve al modelo + como mensaje de rol `tool`. +4. El modelo produce la respuesta final, que se sintetiza a voz. + +El bloque `` se **elimina siempre** del texto hablado y del mostrado en +el chat: el usuario nunca lo oye ni lo ve. + +### 2. Activación y override en runtime + +| Variable | Por defecto | Significado | +|---|---|---| +| `LLM_TOOLS_ENABLED` | auto | Interruptor general. Si no se define vale `true` cuando `LLM_TOOLS_FILE` es una ruta no vacía. | +| `LLM_TOOLS_FILE` | *(vacío)* | Ruta del archivo JSON con las definiciones. Vacío o inexistente ⇒ tool calling **desactivado**. | +| `LLM_TOOL_MAX_ITERATIONS` | `4` | Número máximo de rondas de tool por turno (llamada → ejecución → re-prompt). Limita bucles infinitos. | +| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout de **una** petición HTTP del tool. | +| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | El resultado del tool se trunca a este número de caracteres antes de volver al modelo. | + +```dotenv +LLM_TOOLS_ENABLED=true +LLM_TOOLS_FILE=tools.example.json +LLM_TOOL_MAX_ITERATIONS=4 +LLM_TOOL_TIMEOUT_SECS=15 +LLM_TOOL_RESULT_MAX_CHARS=2000 +``` + +Atención: el archivo de tools se lee **una sola vez al arrancar** +(`ToolRegistry::load`). Tras modificar el JSON hay que **reiniciar el servicio** +para que las nuevas definiciones estén activas. + +#### Override en runtime — `POST /api/tools` + +Para cambiar las definiciones **sin reiniciar**, envía el mismo array JSON a +`POST /api/tools`: + +```bash +curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \ + -d '[{"name":"get_weather","description":"Clima de una ciudad","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]' +``` + +- Las definiciones enviadas **sustituyen** las del archivo desde el **siguiente + turno** (sin reinicio). Si el endpoint **nunca se llama**, el motor sigue + usando las de `LLM_TOOLS_FILE`: es el comportamiento por defecto, y un reinicio + lo restaura. +- El override vive **solo en memoria**: no escribe el archivo y no sobrevive al + reinicio del proceso. +- `GET /api/tools` informa de las definiciones activas y su origen: `source` vale + `file` (el valor por defecto de `.env`), `request` (instaladas por el endpoint) + o `none`. +- Respuestas: `200` con `{ok,count,source,tools}`; `400` si el body está vacío, + no es UTF-8, no es un array JSON válido o no contiene ninguna definición; + `503` si el LLM está desactivado. +- El body acepta **el mismo formato que el archivo**: un array que funciona aquí + también funciona como `LLM_TOOLS_FILE`. + +```bash +curl -s localhost:8787/api/tools +# {"ok":true,"count":2,"source":"file","tools":[ … ]} +``` + +Implementación: [`src/tools_api.rs`](src/tools_api.rs). Ambos endpoints aparecen +también en la Swagger UI (`/docs`, tag `tools`). + +### 3. Definir un tool + +El archivo es un array JSON. Cada objeto tiene estos campos: + +| Campo | Obligatorio | Descripción | +|---|---|---| +| `name` | sí | Nombre de la función que invoca el modelo. Debe coincidir con el `name` de la definición. | +| `description` | recomendado | **Es lo que el modelo lee para decidir si y cuándo llamar al tool**: escríbela explícita y orientada al uso ("llama a este tool cuando el usuario pregunte…"). | +| `parameters` | no | JSON Schema de los argumentos. Si falta vale `{"type":"object","properties":{}}`. | +| `url` | sí | URL de destino. Puede contener marcadores `{argumento}`. | +| `method` | no | **Cualquier** verbo HTTP, sin distinguir mayúsculas: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` y verbos personalizados (p. ej. `PURGE`). Por defecto `POST`. | +| `headers` | no | Cabeceras enviadas literalmente (claves API, `Content-Type`, …). Los valores pueden contener marcadores `{argumento}`. | + +#### Sustitución de marcadores + +En `url` **y** en `headers`, cada `{nombre_argumento}` se sustituye por el valor +correspondiente de la llamada (cadenas sin comillas, números/booleanos como texto). + +#### Dónde van los argumentos (según el verbo) + +Los argumentos **ya consumidos** por un marcador `{arg}` de la URL *no* se envían +otra vez: ya están en el path. Los restantes siguen al verbo: + +| Verbo | Argumentos restantes | +|---|---| +| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **parámetros de query** (sin body) | +| `POST`, `PUT`, `PATCH` y verbos personalizados | **cuerpo JSON** (`{}` si no queda ninguno) | + +### 4. Ejemplos + +**GET con marcador en la URL** (`city` va al path, no a la query): + +```json +{ + "name": "get_weather", + "description": "Clima y temperatura actuales de una ciudad. Llámalo cada vez que el usuario pregunte por el clima, la temperatura o el pronóstico de un lugar concreto.", + "parameters": { + "type": "object", + "properties": { "city": { "type": "string", "description": "Nombre de la ciudad, p. ej. 'Madrid'" } }, + "required": ["city"] + }, + "url": "https://wttr.in/{city}?format=3", + "method": "GET" +} +``` + +**POST con cabecera autenticada** (secreto tomado de los argumentos): + +```json +{ + "name": "create_note", + "description": "Guarda una nota en el sistema interno de gestión.", + "parameters": { + "type": "object", + "properties": { + "token": { "type": "string", "description": "Token de sesión del usuario" }, + "text": { "type": "string", "description": "Contenido de la nota" } + }, + "required": ["token", "text"] + }, + "url": "https://interno.example.local/api/notes", + "method": "POST", + "headers": { "Authorization": "Bearer {token}" } +} +``` + +**POST simple (webhook)**: + +```json +{ + "name": "send_webhook", + "description": "Envía un payload JSON al endpoint webhook configurado (notificaciones / home automation).", + "parameters": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }, + "url": "https://httpbin.org/post", + "method": "POST", + "headers": { "X-Tool": "audio-agent-engine" } +} +``` + +**Superficie REST completa** — un tool por verbo sobre el mismo endpoint +(`lista` → `GET`, `detalle` → `GET` con `{id}`, `añade` → `POST`, +`actualiza` → `PATCH`, `reemplaza` → `PUT`, `elimina` → `DELETE`, +`¿existe?` → `HEAD`): + +```json +[ + { + "name": "list_items", + "description": "Lista los artículos, con filtros opcionales.", + "parameters": { + "type": "object", + "properties": { + "limit": { "type": "integer" }, + "search": { "type": "string" } + } + }, + "url": "https://api.example.local/api/items", + "method": "GET" + }, + { + "name": "get_item", + "description": "Detalle de un artículo por id.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "GET" + }, + { + "name": "add_item", + "description": "Crea un artículo nuevo.", + "parameters": { + "type": "object", + "properties": { "name": { "type": "string" }, "qty": { "type": "integer" } }, + "required": ["name"] + }, + "url": "https://api.example.local/api/items", + "method": "POST" + }, + { + "name": "update_item", + "description": "Actualiza parcialmente un artículo (solo los campos enviados).", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "PATCH" + }, + { + "name": "replace_item", + "description": "Reemplaza un artículo por completo.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, + "required": ["id", "name"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "PUT" + }, + { + "name": "delete_item", + "description": "Elimina un artículo por id.", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "DELETE" + }, + { + "name": "item_exists", + "description": "Comprueba si existe un artículo (solo status, sin body).", + "parameters": { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }, + "url": "https://api.example.local/api/items/{id}", + "method": "HEAD" + } +] +``` + +Peticiones resultantes: `GET /api/items?limit=10`, `GET /api/items/5`, +`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`, +`PUT /api/items/5` + body `{"name":"…"}`, `DELETE /api/items/5`, +`HEAD /api/items/5`. + +Parte de [`tools.example.json`](tools.example.json): cópialo, adáptalo y apunta +`LLM_TOOLS_FILE` a tu archivo. + +### 5. Bucle multi-ronda y fallback en streaming + +- Cada ronda produce **un** completion; las llamadas encontradas se ejecutan y sus + resultados se devuelven al modelo en la ronda siguiente (round-trip estándar + OpenAI: mensaje `assistant` con la llamada, luego un mensaje `tool` por cada + resultado). +- El bucle se detiene cuando no hay más llamadas, o al alcanzar + `LLM_TOOL_MAX_ITERATIONS`. +- **Fallback en streaming**: algunos servidores compatibles con OpenAI (vLLM con un + `--tool-call-parser` que no coincide con el modelo) **se tragan** la llamada en la + ruta SSE: la ronda termina vacía, sin error HTTP. Si una ronda vuelve vacía y hay + tools configurados, el motor **repite la petición una vez sin streaming** + (`chat_reply`) y recupera la llamada de ahí. Si tampoco hay llamada pero hay + texto, ese texto se habla. El fallback se activa **solo** en rondas vacías: no + cuesta nada en los turnos normales. + +### 6. Errores y truncado + +La ejecución de un tool **nunca falla** de forma bloqueante: el error se convierte +en texto que el modelo puede leer y del que puede recuperarse. + +| Situación | Texto entregado al modelo | +|---|---| +| Tool inexistente | `Error: unknown tool ''` | +| Verbo HTTP no válido en la definición | `Error: invalid HTTP method '' for tool ''` | +| Estado HTTP distinto de 2xx | `Error: HTTP — ` | +| Error de red / timeout | `Error: tool request failed: ` | +| Resultado más largo que el límite | Truncado a `LLM_TOOL_RESULT_MAX_CHARS`, con sufijo `… [truncated]` | + +### 7. Nota importante sobre el parser del servidor + +Si el servidor LLM funciona **con** un parser de tools, el flag debe coincidir con +el formato nativo del modelo (`--enable-auto-tool-choice --tool-call-parser …`). El +desajuste es **silencioso**: un parser `hermes` (que espera JSON dentro de +``) contra un modelo Qwen que emite XML hace fallar el parseo, la +llamada se descarta y, en streaming, la respuesta vuelve vacía (logs de vLLM: +`Error in extracting tool call from response`). El parser correcto para este modelo +es `qwen3_coder`. El motor es robusto en cualquier caso: maneja **ambos** formatos, +el array estructurado `message.tool_calls[]` **y** el texto nativo ``, +además del fallback descrito en la sección 5. + +Síntoma típico del desajuste: el agente calla, o "narra" la llamada sin ejecutarla. + +### 8. Verificación end-to-end + +Con el servicio activo en `ws://localhost:8787/ws`, envía texto de usuario que deba +disparar el tool y comprueba los eventos: + +```python +import asyncio, json, websockets + +async def main(): + async with websockets.connect("ws://localhost:8787/ws") as ws: + await ws.send(json.dumps({"type": "user_text", "text": "¿Qué tiempo hace en Madrid?"})) + while True: + msg = json.loads(await ws.recv()) + if msg.get("type") == "tool_call": + print("TOOL:", msg["name"], msg["arguments"]) + if msg.get("type") == "assistant_end": + print("turno terminado") + break + +asyncio.run(main()) +``` + +Eventos WebSocket esperados, en orden: `tool_call` (con `name` y `arguments`) y +después `assistant_end`. El bloque `` no debe aparecer en ningún +`assistant_delta`. + +### 9. Límites y buenas prácticas + +- Las llamadas no se transmiten en streaming al cliente: la respuesta del agente + llega **después** de la ejecución del tool. Para tools lentos, sube + `LLM_TOOL_TIMEOUT_SECS` solo lo necesario o reduce el trabajo en el servidor. +- La `description` determina el uso del tool: el system prompt por defecto no habla + de tools, decide solo el modelo leyendo las descripciones. +- Mantén los resultados **cortos**: más allá de `LLM_TOOL_RESULT_MAX_CHARS` se + truncan y el modelo puede perder información útil. +- No pongas secretos en claro en la `url`: usa `headers` con marcadores, o un + endpoint interno. +- El archivo de tools se lee **una sola vez**: reinicia el servicio tras editarlo. diff --git a/UPDATES.md b/UPDATES.md index dc72ceb..d724c53 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -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 '' for tool ''` (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>` to an `Arc>` 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 diff --git a/src/apidoc.rs b/src/apidoc.rs new file mode 100644 index 0000000..11d1a12 --- /dev/null +++ b/src/apidoc.rs @@ -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, +} + +/// 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)] + pub tools: Vec, +} + +/// 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; diff --git a/src/customvoice.rs b/src/customvoice.rs index cf978e3..2dfbb18 100644 --- a/src/customvoice.rs +++ b/src/customvoice.rs @@ -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) -> Json { let languages: Vec = PHRASES .iter() @@ -106,7 +116,8 @@ pub async fn phrases(State(state): State) -> Json { } /// 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, Query(params): Query, @@ -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) -> Json { match state.config.tts_reference() { Some((audio_path, text)) => { diff --git a/src/llm.rs b/src/llm.rs index 63d587c..d2271ed 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -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>, + /// [`crate::tools`]. Swappable at runtime through [`LlmClient::set_tools`] + /// (`POST /api/tools`); until then it holds the `LLM_TOOLS_FILE` definitions. + tools: Arc>>>, + /// 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, } 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> { - 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> { + 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>) { + 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 `` 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"); diff --git a/src/main.rs b/src/main.rs index ddebe1d..52923ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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" } diff --git a/src/orchestrator/turn_manager.rs b/src/orchestrator/turn_manager.rs index 9a93184..05f4086 100644 --- a/src/orchestrator/turn_manager.rs +++ b/src/orchestrator/turn_manager.rs @@ -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; diff --git a/src/tools.rs b/src/tools.rs index af03043..d12577d 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -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 { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading tools file {}", path.display()))?; - let specs: Vec = - 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 { + let specs: Vec = 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::>(), - "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 = 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 { + 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, +) -> (Vec<(String, String)>, Option) { + let leftover: Map = 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 { 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 = + 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 = serde_json::from_value(json!({ "id": 5 })).unwrap(); + let (_, body) = place_args(&Method::PUT, "/items/{id}", &only_id); + assert_eq!(body.unwrap(), json!({})); + } } diff --git a/src/tools_api.rs b/src/tools_api.rs new file mode 100644 index 0000000..7bc99e1 --- /dev/null +++ b/src/tools_api.rs @@ -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, 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) -> 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 (®istry, 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() +} diff --git a/src/transcribe.rs b/src/transcribe.rs new file mode 100644 index 0000000..e19b8e1 --- /dev/null +++ b/src/transcribe.rs @@ -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, 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 = 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, String> { + let args: Vec = 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() +}