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