mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-06 13:26:11 -04:00
Compare commits
4 Commits
fix/9658-r
...
feat/audio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e485bb251 | ||
|
|
7f97c234ac | ||
|
|
bc1965ad0b | ||
|
|
034ed4223c |
95
backend/cpp/audio-cpp/CMakeLists.txt
Normal file
95
backend/cpp/audio-cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,95 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(audio-cpp-grpc-server LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
set(AUDIO_CPP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/audio.cpp"
|
||||
CACHE PATH "Path to the audio.cpp source tree")
|
||||
set(LOCALAI_BACKEND_PROTO "${CMAKE_CURRENT_SOURCE_DIR}/../../backend.proto"
|
||||
CACHE FILEPATH "Path to the LocalAI backend protocol")
|
||||
|
||||
option(ENGINE_ENABLE_CUDA "Build audio.cpp with CUDA support" OFF)
|
||||
option(ENGINE_ENABLE_VULKAN "Build audio.cpp with Vulkan support" OFF)
|
||||
option(ENGINE_ENABLE_METAL "Build audio.cpp with Metal support" OFF)
|
||||
option(AUDIO_CPP_BUILD_TESTS "Build LocalAI audio.cpp unit tests" OFF)
|
||||
option(AUDIO_CPP_BUILD_GRPC "Build the LocalAI gRPC server" ON)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
if(NOT EXISTS "${AUDIO_CPP_DIR}/CMakeLists.txt")
|
||||
message(FATAL_ERROR
|
||||
"AUDIO_CPP_DIR does not point to an audio.cpp source tree: ${AUDIO_CPP_DIR}")
|
||||
endif()
|
||||
add_subdirectory("${AUDIO_CPP_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/audio.cpp")
|
||||
|
||||
add_library(localai_audio_cpp_runtime STATIC
|
||||
audio_cpp_runtime.cpp
|
||||
model_config.cpp)
|
||||
target_include_directories(localai_audio_cpp_runtime
|
||||
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(localai_audio_cpp_runtime PUBLIC engine_runtime)
|
||||
|
||||
if(AUDIO_CPP_BUILD_GRPC)
|
||||
find_package(Protobuf CONFIG REQUIRED)
|
||||
find_package(gRPC CONFIG REQUIRED)
|
||||
find_program(PROTOC_EXECUTABLE NAMES protoc REQUIRED)
|
||||
find_program(GRPC_CPP_PLUGIN_EXECUTABLE NAMES grpc_cpp_plugin REQUIRED)
|
||||
|
||||
get_filename_component(LOCALAI_BACKEND_PROTO_DIR
|
||||
"${LOCALAI_BACKEND_PROTO}" DIRECTORY)
|
||||
set(LOCALAI_PROTO_SOURCES
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.cc"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.cc")
|
||||
set(LOCALAI_PROTO_HEADERS
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.pb.h"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/backend.grpc.pb.h")
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT ${LOCALAI_PROTO_SOURCES} ${LOCALAI_PROTO_HEADERS}
|
||||
COMMAND "${PROTOC_EXECUTABLE}"
|
||||
ARGS
|
||||
--cpp_out "${CMAKE_CURRENT_BINARY_DIR}"
|
||||
--grpc_out "${CMAKE_CURRENT_BINARY_DIR}"
|
||||
-I "${LOCALAI_BACKEND_PROTO_DIR}"
|
||||
--plugin=protoc-gen-grpc="${GRPC_CPP_PLUGIN_EXECUTABLE}"
|
||||
"${LOCALAI_BACKEND_PROTO}"
|
||||
DEPENDS "${LOCALAI_BACKEND_PROTO}"
|
||||
VERBATIM)
|
||||
|
||||
add_library(localai_backend_proto STATIC
|
||||
${LOCALAI_PROTO_SOURCES}
|
||||
${LOCALAI_PROTO_HEADERS})
|
||||
target_include_directories(localai_backend_proto
|
||||
PUBLIC "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
target_link_libraries(localai_backend_proto
|
||||
PUBLIC protobuf::libprotobuf gRPC::grpc++)
|
||||
|
||||
# Task 2 replaces this generated entry point with the LocalAI service.
|
||||
set(AUDIO_CPP_SERVER_PLACEHOLDER
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/audio-cpp-grpc-server-placeholder.cpp")
|
||||
file(GENERATE OUTPUT "${AUDIO_CPP_SERVER_PLACEHOLDER}"
|
||||
CONTENT "int main() { return 0; }\n")
|
||||
|
||||
add_executable(audio-cpp-grpc-server "${AUDIO_CPP_SERVER_PLACEHOLDER}")
|
||||
target_link_libraries(audio-cpp-grpc-server PRIVATE
|
||||
localai_audio_cpp_runtime
|
||||
engine_runtime
|
||||
localai_backend_proto
|
||||
gRPC::grpc++
|
||||
gRPC::grpc++_reflection)
|
||||
endif()
|
||||
|
||||
if(AUDIO_CPP_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_executable(audio-cpp-runtime-test tests/runtime_tests.cpp)
|
||||
target_link_libraries(audio-cpp-runtime-test PRIVATE
|
||||
localai_audio_cpp_runtime
|
||||
Threads::Threads)
|
||||
add_test(
|
||||
NAME audio-cpp-runtime-test
|
||||
COMMAND audio-cpp-runtime-test "${AUDIO_CPP_DIR}")
|
||||
endif()
|
||||
67
backend/cpp/audio-cpp/Makefile
Normal file
67
backend/cpp/audio-cpp/Makefile
Normal file
@@ -0,0 +1,67 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
AUDIO_CPP_VERSION?=f8fb0c19739193adfad0d9e58da99f25eda65256
|
||||
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
|
||||
AUDIO_CPP_SRC?=
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
BUILD_DIR := build
|
||||
|
||||
BUILD_TYPE ?=
|
||||
JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
||||
UNAME_S := $(shell uname -s)
|
||||
|
||||
CMAKE_ARGS ?= -DCMAKE_BUILD_TYPE=Release
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=OFF
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=OFF
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_METAL=OFF
|
||||
|
||||
ifeq ($(BUILD_TYPE),cublas)
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_CUDA=ON
|
||||
else ifeq ($(BUILD_TYPE),vulkan)
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_VULKAN=ON
|
||||
else ifeq ($(UNAME_S),Darwin)
|
||||
CMAKE_ARGS += -DENGINE_ENABLE_METAL=ON
|
||||
endif
|
||||
|
||||
.PHONY: all grpc-server test test-unit clean purge
|
||||
|
||||
all: grpc-server
|
||||
|
||||
audio.cpp:
|
||||
ifneq ($(AUDIO_CPP_SRC),)
|
||||
ln -sfn $(abspath $(AUDIO_CPP_SRC)) audio.cpp
|
||||
else
|
||||
mkdir -p audio.cpp
|
||||
cd audio.cpp && \
|
||||
git init -q && \
|
||||
git remote add origin $(AUDIO_CPP_REPO) && \
|
||||
git fetch --depth 1 origin $(AUDIO_CPP_VERSION) && \
|
||||
git checkout --detach FETCH_HEAD && \
|
||||
git submodule update --init --recursive --depth 1
|
||||
endif
|
||||
|
||||
grpc-server: audio.cpp
|
||||
mkdir -p $(BUILD_DIR)
|
||||
cd $(BUILD_DIR) && cmake $(CMAKE_ARGS) $(CURRENT_MAKEFILE_DIR)
|
||||
cmake --build $(BUILD_DIR) --config Release \
|
||||
--target audio-cpp-grpc-server -j $(JOBS)
|
||||
cp $(BUILD_DIR)/audio-cpp-grpc-server grpc-server
|
||||
|
||||
test:
|
||||
bash tests/build_contract_test.sh
|
||||
|
||||
test-unit: audio.cpp
|
||||
mkdir -p $(BUILD_DIR)-unit
|
||||
cd $(BUILD_DIR)-unit && cmake $(CMAKE_ARGS) \
|
||||
-DAUDIO_CPP_BUILD_TESTS=ON -DAUDIO_CPP_BUILD_GRPC=OFF \
|
||||
$(CURRENT_MAKEFILE_DIR)
|
||||
cmake --build $(BUILD_DIR)-unit --config Release \
|
||||
--target audio-cpp-runtime-test -j $(JOBS)
|
||||
ctest --test-dir $(BUILD_DIR)-unit --output-on-failure
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) $(BUILD_DIR)-unit grpc-server
|
||||
|
||||
purge: clean
|
||||
rm -rf audio.cpp
|
||||
178
backend/cpp/audio-cpp/audio_cpp_runtime.cpp
Normal file
178
backend/cpp/audio-cpp/audio_cpp_runtime.cpp
Normal file
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "audio_cpp_runtime.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace audio_cpp {
|
||||
namespace {
|
||||
|
||||
using engine::runtime::CapabilitySet;
|
||||
using engine::runtime::RunMode;
|
||||
using engine::runtime::TaskSpec;
|
||||
|
||||
const engine::runtime::TaskCapability * find_task_capability(
|
||||
const CapabilitySet & capabilities,
|
||||
const TaskSpec & task) {
|
||||
const auto it = std::find_if(
|
||||
capabilities.supported_tasks.begin(),
|
||||
capabilities.supported_tasks.end(),
|
||||
[&](const engine::runtime::TaskCapability & capability) {
|
||||
return capability.task == task.task;
|
||||
});
|
||||
return it == capabilities.supported_tasks.end() ? nullptr : &*it;
|
||||
}
|
||||
|
||||
void validate_capability(
|
||||
const engine::runtime::ILoadedVoiceModel & model,
|
||||
const AudioCppModelConfig & config) {
|
||||
if (model.metadata().family != config.family) {
|
||||
throw std::runtime_error(
|
||||
"loaded audio.cpp model family '" + model.metadata().family +
|
||||
"' does not match requested family '" + config.family + "'");
|
||||
}
|
||||
|
||||
const auto * capability = find_task_capability(
|
||||
model.capabilities(),
|
||||
config.task);
|
||||
if (capability == nullptr) {
|
||||
throw std::runtime_error(
|
||||
"loaded audio.cpp model does not support requested task '" +
|
||||
std::string(engine::runtime::to_string(config.task.task)) + "'");
|
||||
}
|
||||
if (std::find(
|
||||
capability->modes.begin(),
|
||||
capability->modes.end(),
|
||||
config.task.mode) == capability->modes.end()) {
|
||||
throw std::runtime_error(
|
||||
"loaded audio.cpp model does not support requested mode '" +
|
||||
std::string(engine::runtime::to_string(config.task.mode)) +
|
||||
"' for task '" +
|
||||
std::string(engine::runtime::to_string(config.task.task)) + "'");
|
||||
}
|
||||
}
|
||||
|
||||
void validate_session(
|
||||
const engine::runtime::IVoiceTaskSession & session,
|
||||
const AudioCppModelConfig & config) {
|
||||
if (session.family() != config.family) {
|
||||
throw std::runtime_error("audio.cpp session returned the wrong family");
|
||||
}
|
||||
if (session.task_kind() != config.task.task) {
|
||||
throw std::runtime_error("audio.cpp session returned the wrong task");
|
||||
}
|
||||
if (session.run_mode() != config.task.mode) {
|
||||
throw std::runtime_error("audio.cpp session returned the wrong mode");
|
||||
}
|
||||
if (config.task.mode == RunMode::Offline &&
|
||||
dynamic_cast<const engine::runtime::IOfflineVoiceTaskSession *>(&session) == nullptr) {
|
||||
throw std::runtime_error("audio.cpp session does not implement offline execution");
|
||||
}
|
||||
if (config.task.mode == RunMode::Streaming &&
|
||||
dynamic_cast<const engine::runtime::IStreamingVoiceTaskSession *>(&session) == nullptr) {
|
||||
throw std::runtime_error("audio.cpp session does not implement streaming execution");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioCppRuntime::AudioCppRuntime()
|
||||
: registry_(engine::runtime::make_default_registry()) {}
|
||||
|
||||
AudioCppRuntime::AudioCppRuntime(engine::runtime::ModelRegistry registry)
|
||||
: registry_(std::move(registry)) {}
|
||||
|
||||
AudioCppRuntime::~AudioCppRuntime() {
|
||||
free();
|
||||
}
|
||||
|
||||
void AudioCppRuntime::load(const AudioCppModelConfig & config) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
auto candidate_model = registry_.load(config.load);
|
||||
if (candidate_model == nullptr) {
|
||||
throw std::runtime_error("audio.cpp registry returned a null model");
|
||||
}
|
||||
validate_capability(*candidate_model, config);
|
||||
|
||||
auto candidate_session = candidate_model->create_task_session(
|
||||
config.task,
|
||||
config.session);
|
||||
if (candidate_session == nullptr) {
|
||||
throw std::runtime_error("audio.cpp model returned a null session");
|
||||
}
|
||||
validate_session(*candidate_session, config);
|
||||
|
||||
free_locked();
|
||||
model_ = std::move(candidate_model);
|
||||
session_ = std::move(candidate_session);
|
||||
}
|
||||
|
||||
void AudioCppRuntime::free() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
free_locked();
|
||||
}
|
||||
|
||||
engine::runtime::TaskResult AudioCppRuntime::run(
|
||||
const engine::runtime::TaskRequest & request) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto & session = require_session_locked();
|
||||
session.prepare(engine::runtime::build_preparation_request(request));
|
||||
return require_offline_locked().run(request);
|
||||
}
|
||||
|
||||
void AudioCppRuntime::start_stream(
|
||||
const engine::runtime::TaskRequest & request) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto & session = require_session_locked();
|
||||
session.prepare(engine::runtime::build_preparation_request(request));
|
||||
require_streaming_locked().start_stream(request);
|
||||
}
|
||||
|
||||
engine::runtime::StreamEvent AudioCppRuntime::process_audio_chunk(
|
||||
const engine::runtime::AudioChunk & chunk) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return require_streaming_locked().process_audio_chunk(chunk);
|
||||
}
|
||||
|
||||
engine::runtime::TaskResult AudioCppRuntime::finish_stream() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return require_streaming_locked().finish_stream();
|
||||
}
|
||||
|
||||
engine::runtime::IVoiceTaskSession & AudioCppRuntime::require_session_locked() {
|
||||
if (session_ == nullptr) {
|
||||
throw std::runtime_error("audio.cpp runtime has no loaded session");
|
||||
}
|
||||
return *session_;
|
||||
}
|
||||
|
||||
engine::runtime::IOfflineVoiceTaskSession &
|
||||
AudioCppRuntime::require_offline_locked() {
|
||||
auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(
|
||||
&require_session_locked());
|
||||
if (offline == nullptr) {
|
||||
throw std::runtime_error("loaded audio.cpp session is not offline");
|
||||
}
|
||||
return *offline;
|
||||
}
|
||||
|
||||
engine::runtime::IStreamingVoiceTaskSession &
|
||||
AudioCppRuntime::require_streaming_locked() {
|
||||
auto * streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(
|
||||
&require_session_locked());
|
||||
if (streaming == nullptr) {
|
||||
throw std::runtime_error("loaded audio.cpp session is not streaming");
|
||||
}
|
||||
return *streaming;
|
||||
}
|
||||
|
||||
void AudioCppRuntime::free_locked() {
|
||||
session_.reset();
|
||||
model_.reset();
|
||||
}
|
||||
|
||||
} // namespace audio_cpp
|
||||
46
backend/cpp/audio-cpp/audio_cpp_runtime.h
Normal file
46
backend/cpp/audio-cpp/audio_cpp_runtime.h
Normal file
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "model_config.h"
|
||||
|
||||
#include "engine/framework/runtime/registry.h"
|
||||
#include "engine/framework/runtime/session.h"
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
namespace audio_cpp {
|
||||
|
||||
class AudioCppRuntime {
|
||||
public:
|
||||
AudioCppRuntime();
|
||||
explicit AudioCppRuntime(engine::runtime::ModelRegistry registry);
|
||||
~AudioCppRuntime();
|
||||
|
||||
AudioCppRuntime(const AudioCppRuntime &) = delete;
|
||||
AudioCppRuntime & operator=(const AudioCppRuntime &) = delete;
|
||||
|
||||
void load(const AudioCppModelConfig & config);
|
||||
void free();
|
||||
|
||||
engine::runtime::TaskResult run(
|
||||
const engine::runtime::TaskRequest & request);
|
||||
void start_stream(const engine::runtime::TaskRequest & request);
|
||||
engine::runtime::StreamEvent process_audio_chunk(
|
||||
const engine::runtime::AudioChunk & chunk);
|
||||
engine::runtime::TaskResult finish_stream();
|
||||
|
||||
private:
|
||||
engine::runtime::IVoiceTaskSession & require_session_locked();
|
||||
engine::runtime::IOfflineVoiceTaskSession & require_offline_locked();
|
||||
engine::runtime::IStreamingVoiceTaskSession & require_streaming_locked();
|
||||
void free_locked();
|
||||
|
||||
std::mutex mutex_;
|
||||
engine::runtime::ModelRegistry registry_;
|
||||
std::unique_ptr<engine::runtime::ILoadedVoiceModel> model_;
|
||||
std::unique_ptr<engine::runtime::IVoiceTaskSession> session_;
|
||||
};
|
||||
|
||||
} // namespace audio_cpp
|
||||
156
backend/cpp/audio-cpp/model_config.cpp
Normal file
156
backend/cpp/audio-cpp/model_config.cpp
Normal file
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "model_config.h"
|
||||
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace audio_cpp {
|
||||
namespace {
|
||||
|
||||
using engine::core::BackendType;
|
||||
using engine::runtime::RunMode;
|
||||
using engine::runtime::VoiceTaskKind;
|
||||
|
||||
std::string require_option(
|
||||
const std::unordered_map<std::string, std::string> & options,
|
||||
const std::string & name) {
|
||||
const auto it = options.find(name);
|
||||
if (it == options.end() || it->second.empty()) {
|
||||
throw std::invalid_argument("audio.cpp model config requires " + name);
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
VoiceTaskKind parse_task(const std::string & value) {
|
||||
static const std::unordered_map<std::string, VoiceTaskKind> tasks = {
|
||||
{"vad", VoiceTaskKind::Vad},
|
||||
{"asr", VoiceTaskKind::Asr},
|
||||
{"diarization", VoiceTaskKind::Diarization},
|
||||
{"source-separation", VoiceTaskKind::SourceSeparation},
|
||||
{"audio-generation", VoiceTaskKind::AudioGeneration},
|
||||
{"tts", VoiceTaskKind::Tts},
|
||||
{"voice-cloning", VoiceTaskKind::VoiceCloning},
|
||||
{"voice-conversion", VoiceTaskKind::VoiceConversion},
|
||||
{"speech-to-speech", VoiceTaskKind::SpeechToSpeech},
|
||||
{"alignment", VoiceTaskKind::Alignment},
|
||||
{"voice-design", VoiceTaskKind::VoiceDesign},
|
||||
{"speaker-recognition", VoiceTaskKind::SpeakerRecognition},
|
||||
{"svc", VoiceTaskKind::Svc},
|
||||
};
|
||||
const auto it = tasks.find(value);
|
||||
if (it == tasks.end()) {
|
||||
throw std::invalid_argument("unsupported audio.cpp task: " + value);
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
RunMode parse_mode(const std::string & value) {
|
||||
if (value == "offline") {
|
||||
return RunMode::Offline;
|
||||
}
|
||||
if (value == "streaming") {
|
||||
return RunMode::Streaming;
|
||||
}
|
||||
throw std::invalid_argument("unsupported audio.cpp mode: " + value);
|
||||
}
|
||||
|
||||
BackendType parse_backend(const std::string & value) {
|
||||
if (value == "cpu") {
|
||||
return BackendType::Cpu;
|
||||
}
|
||||
if (value == "cuda") {
|
||||
return BackendType::Cuda;
|
||||
}
|
||||
if (value == "vulkan") {
|
||||
return BackendType::Vulkan;
|
||||
}
|
||||
if (value == "metal") {
|
||||
return BackendType::Metal;
|
||||
}
|
||||
if (value == "best") {
|
||||
return BackendType::BestAvailable;
|
||||
}
|
||||
throw std::invalid_argument("unsupported audio.cpp backend: " + value);
|
||||
}
|
||||
|
||||
int parse_integer(
|
||||
const std::string & name,
|
||||
const std::string & value,
|
||||
int minimum) {
|
||||
size_t parsed = 0;
|
||||
long result = 0;
|
||||
try {
|
||||
result = std::stol(value, &parsed);
|
||||
} catch (const std::exception &) {
|
||||
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
|
||||
}
|
||||
if (parsed != value.size() ||
|
||||
result < minimum ||
|
||||
result > std::numeric_limits<int>::max()) {
|
||||
throw std::invalid_argument("invalid audio.cpp " + name + ": " + value);
|
||||
}
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
|
||||
void copy_namespaced_option(
|
||||
const std::string & key,
|
||||
const std::string & prefix,
|
||||
const std::string & value,
|
||||
std::unordered_map<std::string, std::string> & destination) {
|
||||
const std::string name = key.substr(prefix.size());
|
||||
if (name.empty()) {
|
||||
throw std::invalid_argument("audio.cpp option namespace requires a name: " + key);
|
||||
}
|
||||
destination[name] = value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioCppModelConfig parse_model_config(
|
||||
const std::filesystem::path & model_path,
|
||||
const std::unordered_map<std::string, std::string> & options) {
|
||||
AudioCppModelConfig config;
|
||||
config.model_path = model_path;
|
||||
config.family = require_option(options, "family");
|
||||
config.task.task = parse_task(require_option(options, "task"));
|
||||
config.task.mode = RunMode::Offline;
|
||||
config.load.model_path = model_path;
|
||||
config.load.family_hint = config.family;
|
||||
config.session.backend.type = BackendType::Cpu;
|
||||
|
||||
if (const auto it = options.find("mode"); it != options.end()) {
|
||||
config.task.mode = parse_mode(it->second);
|
||||
}
|
||||
if (const auto it = options.find("backend"); it != options.end()) {
|
||||
config.session.backend.type = parse_backend(it->second);
|
||||
}
|
||||
if (const auto it = options.find("device"); it != options.end()) {
|
||||
config.session.backend.device = parse_integer("device", it->second, 0);
|
||||
}
|
||||
if (const auto it = options.find("threads"); it != options.end()) {
|
||||
config.session.backend.threads = parse_integer("threads", it->second, 1);
|
||||
}
|
||||
if (const auto it = options.find("model_spec"); it != options.end()) {
|
||||
config.load.model_spec_override = std::filesystem::path(it->second);
|
||||
}
|
||||
if (const auto it = options.find("config_id"); it != options.end()) {
|
||||
config.load.config_id = it->second;
|
||||
}
|
||||
if (const auto it = options.find("weight_id"); it != options.end()) {
|
||||
config.load.weight_id = it->second;
|
||||
}
|
||||
|
||||
for (const auto & [key, value] : options) {
|
||||
if (key.rfind("load.", 0) == 0) {
|
||||
copy_namespaced_option(key, "load.", value, config.load.options);
|
||||
} else if (key.rfind("session.", 0) == 0) {
|
||||
copy_namespaced_option(key, "session.", value, config.session.options);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
} // namespace audio_cpp
|
||||
26
backend/cpp/audio-cpp/model_config.h
Normal file
26
backend/cpp/audio-cpp/model_config.h
Normal file
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "engine/framework/runtime/model.h"
|
||||
#include "engine/framework/runtime/session.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace audio_cpp {
|
||||
|
||||
struct AudioCppModelConfig {
|
||||
std::filesystem::path model_path;
|
||||
std::string family;
|
||||
engine::runtime::TaskSpec task;
|
||||
engine::runtime::ModelLoadRequest load;
|
||||
engine::runtime::SessionOptions session;
|
||||
};
|
||||
|
||||
AudioCppModelConfig parse_model_config(
|
||||
const std::filesystem::path & model_path,
|
||||
const std::unordered_map<std::string, std::string> & options);
|
||||
|
||||
} // namespace audio_cpp
|
||||
164
backend/cpp/audio-cpp/tests/build_contract_test.sh
Executable file
164
backend/cpp/audio-cpp/tests/build_contract_test.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
backend_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||
|
||||
fixture_dir="${tmp_dir}/audio.cpp"
|
||||
prefix_dir="${tmp_dir}/prefix"
|
||||
tools_dir="${tmp_dir}/tools"
|
||||
mkdir -p "${fixture_dir}" "${prefix_dir}/lib/cmake/Protobuf" \
|
||||
"${prefix_dir}/lib/cmake/gRPC" "${tools_dir}"
|
||||
|
||||
cat >"${fixture_dir}/engine_runtime.cpp" <<'EOF'
|
||||
void audio_cpp_build_contract_fixture() {}
|
||||
EOF
|
||||
|
||||
cat >"${fixture_dir}/CMakeLists.txt" <<'EOF'
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(AudioCppBuildContractFixture LANGUAGES CXX)
|
||||
|
||||
option(ENGINE_ENABLE_CUDA "Build with CUDA" OFF)
|
||||
option(ENGINE_ENABLE_VULKAN "Build with Vulkan" OFF)
|
||||
option(ENGINE_ENABLE_METAL "Build with Metal" OFF)
|
||||
|
||||
foreach(wrong_option IN ITEMS
|
||||
AUDIO_CPP_ENABLE_CUDA
|
||||
AUDIO_CPP_ENABLE_VULKAN
|
||||
AUDIO_CPP_ENABLE_METAL
|
||||
AUDIOCPP_ENABLE_CUDA
|
||||
AUDIOCPP_ENABLE_VULKAN
|
||||
AUDIOCPP_ENABLE_METAL
|
||||
ENGINE_CUDA
|
||||
ENGINE_VULKAN
|
||||
ENGINE_METAL
|
||||
GGML_CUDA
|
||||
GGML_VULKAN
|
||||
GGML_METAL)
|
||||
if(DEFINED ${wrong_option})
|
||||
message(FATAL_ERROR "legacy or unsupported audio.cpp option: ${wrong_option}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
add_library(engine_runtime STATIC engine_runtime.cpp)
|
||||
EOF
|
||||
|
||||
cat >"${prefix_dir}/lib/cmake/Protobuf/ProtobufConfig.cmake" <<'EOF'
|
||||
set(Protobuf_FOUND TRUE)
|
||||
set(Protobuf_VERSION 0.0.0)
|
||||
if(NOT TARGET protobuf::libprotobuf)
|
||||
add_library(protobuf::libprotobuf INTERFACE IMPORTED)
|
||||
endif()
|
||||
EOF
|
||||
|
||||
cat >"${prefix_dir}/lib/cmake/gRPC/gRPCConfig.cmake" <<'EOF'
|
||||
set(gRPC_FOUND TRUE)
|
||||
if(NOT TARGET gRPC::grpc++)
|
||||
add_library(gRPC::grpc++ INTERFACE IMPORTED)
|
||||
endif()
|
||||
if(NOT TARGET gRPC::grpc++_reflection)
|
||||
add_library(gRPC::grpc++_reflection INTERFACE IMPORTED)
|
||||
endif()
|
||||
EOF
|
||||
|
||||
cat >"${tools_dir}/protoc" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
exit 0
|
||||
EOF
|
||||
cat >"${tools_dir}/grpc_cpp_plugin" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "${tools_dir}/protoc" "${tools_dir}/grpc_cpp_plugin"
|
||||
|
||||
assert_cache_bool() {
|
||||
local cache_file="$1"
|
||||
local name="$2"
|
||||
local expected="$3"
|
||||
grep -q "^${name}:BOOL=${expected}$" "${cache_file}" || {
|
||||
echo "expected ${name}:BOOL=${expected} in ${cache_file}" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
configure_case() {
|
||||
local name="$1"
|
||||
local cuda="$2"
|
||||
local vulkan="$3"
|
||||
local metal="$4"
|
||||
local build_dir="${tmp_dir}/build-${name}"
|
||||
|
||||
PATH="${tools_dir}:${PATH}" cmake \
|
||||
-S "${backend_dir}" \
|
||||
-B "${build_dir}" \
|
||||
-DCMAKE_PREFIX_PATH="${prefix_dir}" \
|
||||
-DAUDIO_CPP_DIR="${fixture_dir}" \
|
||||
-DENGINE_ENABLE_CUDA="${cuda}" \
|
||||
-DENGINE_ENABLE_VULKAN="${vulkan}" \
|
||||
-DENGINE_ENABLE_METAL="${metal}" \
|
||||
>/dev/null
|
||||
|
||||
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_CUDA "${cuda}"
|
||||
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_VULKAN "${vulkan}"
|
||||
assert_cache_bool "${build_dir}/CMakeCache.txt" ENGINE_ENABLE_METAL "${metal}"
|
||||
|
||||
grep -q 'engine_runtime' \
|
||||
"${build_dir}/CMakeFiles/audio-cpp-grpc-server.dir/link.txt" || {
|
||||
echo "audio-cpp-grpc-server does not link engine_runtime" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
configure_case cpu OFF OFF OFF
|
||||
configure_case cuda ON OFF OFF
|
||||
configure_case vulkan OFF ON OFF
|
||||
configure_case metal OFF OFF ON
|
||||
|
||||
if PATH="${tools_dir}:${PATH}" cmake \
|
||||
-S "${backend_dir}" \
|
||||
-B "${tmp_dir}/build-wrong-option" \
|
||||
-DCMAKE_PREFIX_PATH="${prefix_dir}" \
|
||||
-DAUDIO_CPP_DIR="${fixture_dir}" \
|
||||
-DGGML_CUDA=ON \
|
||||
>/dev/null 2>&1; then
|
||||
echo "strict audio.cpp fixture accepted legacy GGML_CUDA option" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
make_database="${tmp_dir}/make-database"
|
||||
make -C "${backend_dir}" -pn >"${make_database}"
|
||||
audio_cpp_version="$(
|
||||
sed -n 's/^AUDIO_CPP_VERSION = //p' "${make_database}" | head -n 1
|
||||
)"
|
||||
[[ "${audio_cpp_version}" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "AUDIO_CPP_VERSION must be a pinned 40-character commit" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fetch_plan="${tmp_dir}/fetch-plan"
|
||||
make -C "${backend_dir}" -Bn audio.cpp >"${fetch_plan}"
|
||||
grep -q 'github.com/0xShug0/audio.cpp' "${fetch_plan}"
|
||||
grep -q "${audio_cpp_version}" "${fetch_plan}"
|
||||
|
||||
cat >"${tools_dir}/uname" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "-s" ]; then
|
||||
echo Darwin
|
||||
exit 0
|
||||
fi
|
||||
echo "build contract requires uname -s" >&2
|
||||
exit 64
|
||||
EOF
|
||||
chmod +x "${tools_dir}/uname"
|
||||
|
||||
darwin_plan="${tmp_dir}/darwin-plan"
|
||||
PATH="${tools_dir}:${PATH}" make -C "${backend_dir}" -n \
|
||||
AUDIO_CPP_SRC="${fixture_dir}" grpc-server >"${darwin_plan}"
|
||||
grep -q -- '-DENGINE_ENABLE_CUDA=OFF' "${darwin_plan}"
|
||||
grep -q -- '-DENGINE_ENABLE_VULKAN=OFF' "${darwin_plan}"
|
||||
grep -q -- '-DENGINE_ENABLE_METAL=ON' "${darwin_plan}"
|
||||
|
||||
echo "audio.cpp build contract: PASS"
|
||||
490
backend/cpp/audio-cpp/tests/runtime_tests.cpp
Normal file
490
backend/cpp/audio-cpp/tests/runtime_tests.cpp
Normal file
@@ -0,0 +1,490 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "audio_cpp_runtime.h"
|
||||
#include "model_config.h"
|
||||
|
||||
#include "engine/framework/runtime/model.h"
|
||||
#include "engine/framework/runtime/registry.h"
|
||||
#include "engine/framework/runtime/session.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using engine::runtime::AudioChunk;
|
||||
using engine::runtime::CapabilitySet;
|
||||
using engine::runtime::ILoadedVoiceModel;
|
||||
using engine::runtime::IOfflineVoiceTaskSession;
|
||||
using engine::runtime::IStreamingVoiceTaskSession;
|
||||
using engine::runtime::IVoiceModelLoader;
|
||||
using engine::runtime::IVoiceTaskSession;
|
||||
using engine::runtime::ModelInspection;
|
||||
using engine::runtime::ModelLoadRequest;
|
||||
using engine::runtime::ModelMetadata;
|
||||
using engine::runtime::RunMode;
|
||||
using engine::runtime::SessionOptions;
|
||||
using engine::runtime::SessionPreparationRequest;
|
||||
using engine::runtime::StreamEvent;
|
||||
using engine::runtime::TaskCapability;
|
||||
using engine::runtime::TaskRequest;
|
||||
using engine::runtime::TaskResult;
|
||||
using engine::runtime::TaskSpec;
|
||||
using engine::runtime::VoiceTaskKind;
|
||||
|
||||
void require(bool condition, const std::string & message) {
|
||||
if (!condition) {
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
void require_throws(Function && function, const std::string & expected) {
|
||||
try {
|
||||
function();
|
||||
} catch (const std::exception & error) {
|
||||
require(
|
||||
std::string(error.what()).find(expected) != std::string::npos,
|
||||
"expected error containing '" + expected + "', got '" + error.what() + "'");
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("expected exception containing '" + expected + "'");
|
||||
}
|
||||
|
||||
struct SessionGate {
|
||||
std::mutex mutex;
|
||||
std::condition_variable condition;
|
||||
bool first_entered = false;
|
||||
bool release_first = false;
|
||||
std::atomic<int> entries{0};
|
||||
};
|
||||
|
||||
struct FakeState {
|
||||
std::mutex mutex;
|
||||
std::vector<std::string> events;
|
||||
CapabilitySet capabilities;
|
||||
bool fail_load = false;
|
||||
bool fail_session = false;
|
||||
int generation = 0;
|
||||
std::shared_ptr<SessionGate> gate;
|
||||
|
||||
void record(std::string event) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
events.push_back(std::move(event));
|
||||
}
|
||||
};
|
||||
|
||||
class FakeSession final
|
||||
: public IOfflineVoiceTaskSession,
|
||||
public IStreamingVoiceTaskSession {
|
||||
public:
|
||||
FakeSession(
|
||||
std::shared_ptr<FakeState> state,
|
||||
int generation,
|
||||
TaskSpec task,
|
||||
SessionOptions options)
|
||||
: state_(std::move(state)),
|
||||
generation_(generation),
|
||||
task_(task),
|
||||
options_(std::move(options)) {}
|
||||
|
||||
~FakeSession() override {
|
||||
state_->record("session-" + std::to_string(generation_) + "-destroyed");
|
||||
}
|
||||
|
||||
std::string family() const override { return "fake-family"; }
|
||||
VoiceTaskKind task_kind() const override { return task_.task; }
|
||||
RunMode run_mode() const override { return task_.mode; }
|
||||
|
||||
void prepare(const SessionPreparationRequest & request) override {
|
||||
prepared_ = request;
|
||||
}
|
||||
|
||||
TaskResult run(const TaskRequest &) override {
|
||||
if (state_->gate != nullptr) {
|
||||
const int entry = ++state_->gate->entries;
|
||||
if (entry == 1) {
|
||||
std::unique_lock<std::mutex> lock(state_->gate->mutex);
|
||||
state_->gate->first_entered = true;
|
||||
state_->gate->condition.notify_all();
|
||||
state_->gate->condition.wait(
|
||||
lock,
|
||||
[&] { return state_->gate->release_first; });
|
||||
}
|
||||
}
|
||||
|
||||
TaskResult result;
|
||||
result.text_output = engine::runtime::Transcript{
|
||||
"generation-" + std::to_string(generation_),
|
||||
"en",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
engine::runtime::StreamingPolicy streaming_policy() const override {
|
||||
engine::runtime::StreamingPolicy policy;
|
||||
policy.input = engine::runtime::StreamingInputKind::AudioChunks;
|
||||
policy.output = engine::runtime::StreamingOutputKind::PullEvents;
|
||||
policy.preferred_audio_chunk_samples = 160;
|
||||
return policy;
|
||||
}
|
||||
|
||||
void start_stream(const TaskRequest &) override {
|
||||
streaming_ = true;
|
||||
}
|
||||
|
||||
std::optional<StreamEvent> next_stream_event() override {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void set_stream_event_sink(engine::runtime::StreamEventCallback sink) override {
|
||||
sink_ = std::move(sink);
|
||||
}
|
||||
|
||||
TaskResult finish_stream() override {
|
||||
streaming_ = false;
|
||||
return run({});
|
||||
}
|
||||
|
||||
void reset() override {
|
||||
streaming_ = false;
|
||||
}
|
||||
|
||||
StreamEvent process_audio_chunk(const AudioChunk & chunk) override {
|
||||
require(streaming_, "stream was not started");
|
||||
StreamEvent event;
|
||||
event.audio_output = engine::runtime::AudioBuffer{
|
||||
chunk.sample_rate,
|
||||
chunk.channels,
|
||||
chunk.samples,
|
||||
};
|
||||
if (sink_) {
|
||||
sink_(event);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
TaskResult finalize() override {
|
||||
streaming_ = false;
|
||||
return run({});
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<FakeState> state_;
|
||||
int generation_;
|
||||
TaskSpec task_;
|
||||
SessionOptions options_;
|
||||
SessionPreparationRequest prepared_;
|
||||
engine::runtime::StreamEventCallback sink_;
|
||||
bool streaming_ = false;
|
||||
};
|
||||
|
||||
class FakeLoadedModel final : public ILoadedVoiceModel {
|
||||
public:
|
||||
FakeLoadedModel(std::shared_ptr<FakeState> state, int generation)
|
||||
: state_(std::move(state)),
|
||||
generation_(generation) {
|
||||
metadata_.family = "fake-family";
|
||||
metadata_.variant = "complete-fake";
|
||||
metadata_.description = "complete test implementation";
|
||||
metadata_.config_candidates = {"config.json"};
|
||||
metadata_.weight_candidates = {"weights.gguf"};
|
||||
}
|
||||
|
||||
~FakeLoadedModel() override {
|
||||
state_->record("model-" + std::to_string(generation_) + "-destroyed");
|
||||
}
|
||||
|
||||
const ModelMetadata & metadata() const noexcept override {
|
||||
return metadata_;
|
||||
}
|
||||
|
||||
const CapabilitySet & capabilities() const noexcept override {
|
||||
return state_->capabilities;
|
||||
}
|
||||
|
||||
std::unique_ptr<IVoiceTaskSession> create_task_session(
|
||||
const TaskSpec & task,
|
||||
const SessionOptions & options) const override {
|
||||
if (state_->fail_session) {
|
||||
throw std::runtime_error("session creation failed");
|
||||
}
|
||||
return std::make_unique<FakeSession>(state_, generation_, task, options);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<FakeState> state_;
|
||||
int generation_;
|
||||
ModelMetadata metadata_;
|
||||
};
|
||||
|
||||
class FakeLoader final : public IVoiceModelLoader {
|
||||
public:
|
||||
explicit FakeLoader(std::shared_ptr<FakeState> state)
|
||||
: state_(std::move(state)) {}
|
||||
|
||||
std::string family() const override { return "fake-family"; }
|
||||
|
||||
bool can_load(const ModelLoadRequest & request) const override {
|
||||
return request.family_hint == family();
|
||||
}
|
||||
|
||||
ModelInspection inspect(const ModelLoadRequest & request) const override {
|
||||
ModelInspection inspection;
|
||||
inspection.metadata.family = family();
|
||||
inspection.metadata.variant = "complete-fake";
|
||||
inspection.metadata.description = "complete test loader";
|
||||
inspection.metadata.config_candidates = {"config.json"};
|
||||
inspection.metadata.weight_candidates = {"weights.gguf"};
|
||||
inspection.capabilities = state_->capabilities;
|
||||
inspection.model_root = request.model_path;
|
||||
return inspection;
|
||||
}
|
||||
|
||||
std::unique_ptr<ILoadedVoiceModel> load(
|
||||
const ModelLoadRequest &) const override {
|
||||
if (state_->fail_load) {
|
||||
throw std::runtime_error("model load failed");
|
||||
}
|
||||
const int generation = ++state_->generation;
|
||||
return std::make_unique<FakeLoadedModel>(state_, generation);
|
||||
}
|
||||
|
||||
CapabilitySet advertised_capabilities() const override {
|
||||
return state_->capabilities;
|
||||
}
|
||||
|
||||
std::string advertised_instructions_policy() const override {
|
||||
return "explicit";
|
||||
}
|
||||
|
||||
std::vector<std::string> advertised_api_endpoints() const override {
|
||||
return {"/v1/audio/transcriptions"};
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<FakeState> state_;
|
||||
};
|
||||
|
||||
audio_cpp::AudioCppModelConfig offline_asr_config(
|
||||
const std::filesystem::path & model_path) {
|
||||
return audio_cpp::parse_model_config(
|
||||
model_path,
|
||||
{
|
||||
{"family", "fake-family"},
|
||||
{"task", "asr"},
|
||||
{"mode", "offline"},
|
||||
{"backend", "cpu"},
|
||||
{"device", "2"},
|
||||
{"threads", "3"},
|
||||
{"load.cache", "memory"},
|
||||
{"session.language", "en"},
|
||||
});
|
||||
}
|
||||
|
||||
std::unique_ptr<audio_cpp::AudioCppRuntime> make_runtime(
|
||||
const std::shared_ptr<FakeState> & state) {
|
||||
engine::runtime::ModelRegistry registry;
|
||||
registry.register_loader(std::make_shared<FakeLoader>(state));
|
||||
return std::make_unique<audio_cpp::AudioCppRuntime>(std::move(registry));
|
||||
}
|
||||
|
||||
void test_model_config(const std::filesystem::path & model_path) {
|
||||
const auto config = offline_asr_config(model_path);
|
||||
require(config.model_path == model_path, "model path was not preserved");
|
||||
require(config.family == "fake-family", "family was not parsed");
|
||||
require(config.task.task == VoiceTaskKind::Asr, "task was not parsed");
|
||||
require(config.task.mode == RunMode::Offline, "mode was not parsed");
|
||||
require(
|
||||
config.session.backend.type == engine::core::BackendType::Cpu,
|
||||
"backend was not parsed");
|
||||
require(config.session.backend.device == 2, "device was not parsed");
|
||||
require(config.session.backend.threads == 3, "threads were not parsed");
|
||||
require(config.load.options.at("cache") == "memory", "load option prefix was not stripped");
|
||||
require(
|
||||
config.session.options.at("language") == "en",
|
||||
"session option prefix was not stripped");
|
||||
|
||||
require_throws(
|
||||
[&] { audio_cpp::parse_model_config(model_path, {{"task", "asr"}}); },
|
||||
"family");
|
||||
require_throws(
|
||||
[&] { audio_cpp::parse_model_config(model_path, {{"family", "fake-family"}}); },
|
||||
"task");
|
||||
require_throws(
|
||||
[&] {
|
||||
audio_cpp::parse_model_config(
|
||||
model_path,
|
||||
{{"family", "fake-family"}, {"task", "asr"}, {"mode", "batch"}});
|
||||
},
|
||||
"mode");
|
||||
require_throws(
|
||||
[&] {
|
||||
audio_cpp::parse_model_config(
|
||||
model_path,
|
||||
{{"family", "fake-family"}, {"task", "asr"}, {"backend", "tpu"}});
|
||||
},
|
||||
"backend");
|
||||
}
|
||||
|
||||
void test_capability_validation(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Tts, {RunMode::Offline}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"task");
|
||||
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Streaming}},
|
||||
};
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"mode");
|
||||
}
|
||||
|
||||
void test_atomic_replacement(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Offline}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
runtime->load(offline_asr_config(model_path));
|
||||
|
||||
state->fail_load = true;
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"model load failed");
|
||||
require(
|
||||
runtime->run({}).text_output->text == "generation-1",
|
||||
"old model was not retained after load failure");
|
||||
|
||||
state->fail_load = false;
|
||||
state->fail_session = true;
|
||||
require_throws(
|
||||
[&] { runtime->load(offline_asr_config(model_path)); },
|
||||
"session creation failed");
|
||||
require(
|
||||
runtime->run({}).text_output->text == "generation-1",
|
||||
"old model was not retained after session creation failure");
|
||||
}
|
||||
|
||||
void test_teardown_order(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Offline}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
runtime->load(offline_asr_config(model_path));
|
||||
runtime->free();
|
||||
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
require(state->events.size() == 2, "expected one session and one model teardown");
|
||||
require(
|
||||
state->events[0] == "session-1-destroyed",
|
||||
"session was not destroyed before model");
|
||||
require(
|
||||
state->events[1] == "model-1-destroyed",
|
||||
"model teardown event was not second");
|
||||
}
|
||||
|
||||
void test_runtime_serializes_calls(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Offline}},
|
||||
};
|
||||
state->gate = std::make_shared<SessionGate>();
|
||||
auto runtime = make_runtime(state);
|
||||
runtime->load(offline_asr_config(model_path));
|
||||
|
||||
auto first = std::async(std::launch::async, [&] { return runtime->run({}); });
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state->gate->mutex);
|
||||
state->gate->condition.wait(
|
||||
lock,
|
||||
[&] { return state->gate->first_entered; });
|
||||
}
|
||||
|
||||
std::promise<void> release_second;
|
||||
std::shared_future<void> second_barrier = release_second.get_future().share();
|
||||
std::promise<void> second_attempted_promise;
|
||||
auto second_attempted = second_attempted_promise.get_future();
|
||||
auto second = std::async(std::launch::async, [&] {
|
||||
second_barrier.wait();
|
||||
second_attempted_promise.set_value();
|
||||
return runtime->run({});
|
||||
});
|
||||
|
||||
release_second.set_value();
|
||||
second_attempted.wait();
|
||||
require(
|
||||
second.wait_for(std::chrono::milliseconds(50)) == std::future_status::timeout,
|
||||
"second call completed while first call held the runtime");
|
||||
require(
|
||||
state->gate->entries.load() == 1,
|
||||
"second call entered the upstream session concurrently");
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->gate->mutex);
|
||||
state->gate->release_first = true;
|
||||
}
|
||||
state->gate->condition.notify_all();
|
||||
first.get();
|
||||
second.get();
|
||||
require(state->gate->entries.load() == 2, "second call never reached the session");
|
||||
}
|
||||
|
||||
void test_streaming_surface(const std::filesystem::path & model_path) {
|
||||
auto state = std::make_shared<FakeState>();
|
||||
state->capabilities.supported_tasks = {
|
||||
{VoiceTaskKind::Asr, {RunMode::Streaming}},
|
||||
};
|
||||
auto runtime = make_runtime(state);
|
||||
auto config = offline_asr_config(model_path);
|
||||
config.task.mode = RunMode::Streaming;
|
||||
runtime->load(config);
|
||||
runtime->start_stream({});
|
||||
const auto event = runtime->process_audio_chunk({16000, 1, 0, {0.25f}});
|
||||
require(event.audio_output.has_value(), "streaming chunk result was lost");
|
||||
require(
|
||||
event.audio_output->samples == std::vector<float>{0.25f},
|
||||
"streaming chunk samples changed");
|
||||
require(
|
||||
runtime->finish_stream().text_output->text == "generation-1",
|
||||
"streaming final result was lost");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
try {
|
||||
require(argc == 2, "runtime_test requires an existing model path argument");
|
||||
const std::filesystem::path model_path(argv[1]);
|
||||
test_model_config(model_path);
|
||||
test_capability_validation(model_path);
|
||||
test_atomic_replacement(model_path);
|
||||
test_teardown_order(model_path);
|
||||
test_runtime_serializes_calls(model_path);
|
||||
test_streaming_surface(model_path);
|
||||
std::cout << "audio.cpp runtime unit tests: PASS\n";
|
||||
return 0;
|
||||
} catch (const std::exception & error) {
|
||||
std::cerr << "audio.cpp runtime unit tests: FAIL: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1654,12 +1654,6 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
var currentReasoningContentIndex int
|
||||
var reasoningTokens int
|
||||
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
|
||||
// router classifies each streamed token into reasoning vs message deltas
|
||||
// and decides which output item they target. It encapsulates the
|
||||
// sticky-preferAutoparser fallback and the reasoningDelta-based gate that
|
||||
// fix issue #9658 (live reasoning was mis-routed onto the msg_ item and
|
||||
// only re-classified as a reasoning item after the stream completed).
|
||||
router := newStreamReasoningRouter(extractor)
|
||||
|
||||
// Collect all output items for storage
|
||||
var collectedOutputItems []schema.ORItemField
|
||||
@@ -1683,7 +1677,7 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
// Reset reasoning and tool-call state for re-inference so reasoning
|
||||
// extraction runs again on subsequent iterations
|
||||
inToolCallMode = false
|
||||
router.resetForIteration()
|
||||
extractor.Reset()
|
||||
currentMessageID = ""
|
||||
lastEmittedToolCallCount = 0
|
||||
currentReasoningID = ""
|
||||
@@ -1844,101 +1838,110 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
|
||||
// If no tool calls detected yet, handle reasoning and text
|
||||
if !inToolCallMode {
|
||||
routing := router.route(token, tokenUsage)
|
||||
var reasoningDelta, contentDelta string
|
||||
goReasoning, goContent := extractor.ProcessToken(token)
|
||||
|
||||
// Handle reasoning item. The reasoning item is opened lazily
|
||||
// on the first reasoning delta - gating on routing, not
|
||||
// extractor.Reasoning() (issue #9658): when the C++
|
||||
// autoparser drives reasoning via reasoning_content,
|
||||
// extractor.Reasoning() stays empty and the old gate dropped
|
||||
// the live reasoning item.
|
||||
if routing.OpenReasoningItem {
|
||||
outputIndex++
|
||||
currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String())
|
||||
reasoningItem := &schema.ORItemField{
|
||||
Type: "reasoning",
|
||||
ID: currentReasoningID,
|
||||
Status: "in_progress",
|
||||
if tokenUsage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
|
||||
// Handle reasoning item
|
||||
if extractor.Reasoning() != "" {
|
||||
// Check if we need to create reasoning item
|
||||
if currentReasoningID == "" {
|
||||
outputIndex++
|
||||
currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String())
|
||||
reasoningItem := &schema.ORItemField{
|
||||
Type: "reasoning",
|
||||
ID: currentReasoningID,
|
||||
Status: "in_progress",
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: reasoningItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added for reasoning
|
||||
currentReasoningContentIndex = 0
|
||||
emptyPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Part: &emptyPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: reasoningItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added for reasoning
|
||||
currentReasoningContentIndex = 0
|
||||
emptyPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Part: &emptyPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
|
||||
// Emit reasoning delta against the reasoning_ item id.
|
||||
if routing.ReasoningDelta != "" {
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Delta: strPtr(routing.ReasoningDelta),
|
||||
Logprobs: emptyLogprobs(),
|
||||
})
|
||||
sequenceNumber++
|
||||
c.Response().Flush()
|
||||
}
|
||||
|
||||
// Open the message item lazily on the first content delta.
|
||||
if routing.OpenMessageItem {
|
||||
outputIndex++
|
||||
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
|
||||
messageItem := &schema.ORItemField{
|
||||
Type: "message",
|
||||
ID: currentMessageID,
|
||||
Status: "in_progress",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{},
|
||||
// Emit reasoning delta if there's new content
|
||||
if reasoningDelta != "" {
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Delta: strPtr(reasoningDelta),
|
||||
Logprobs: emptyLogprobs(),
|
||||
})
|
||||
sequenceNumber++
|
||||
c.Response().Flush()
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added
|
||||
currentContentIndex = 0
|
||||
emptyPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &emptyPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
|
||||
// Emit text delta against the msg_ item id.
|
||||
if routing.ContentDelta != "" {
|
||||
// Only emit message content if there's actual content (not just reasoning)
|
||||
if contentDelta != "" {
|
||||
if currentMessageID == "" {
|
||||
// Emit output_item.added for message
|
||||
outputIndex++
|
||||
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
|
||||
messageItem := &schema.ORItemField{
|
||||
Type: "message",
|
||||
ID: currentMessageID,
|
||||
Status: "in_progress",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{},
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added
|
||||
currentContentIndex = 0
|
||||
emptyPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &emptyPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
|
||||
// Emit text delta
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Delta: strPtr(routing.ContentDelta),
|
||||
Delta: strPtr(contentDelta),
|
||||
Logprobs: emptyLogprobs(),
|
||||
})
|
||||
sequenceNumber++
|
||||
@@ -2335,109 +2338,112 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-tool-call streaming path.
|
||||
//
|
||||
// The message output item is created LAZILY on the first content delta
|
||||
// (mirroring the tool-call path), not eagerly before the first token.
|
||||
// Issue #9658: an eager msg_ item forced reasoning to a higher output
|
||||
// index and made mis-split <think> text land on the pre-existing message,
|
||||
// so the thinking monologue streamed as message text instead of reasoning.
|
||||
var messageItem *schema.ORItemField
|
||||
// Non-tool-call streaming path
|
||||
// Emit output_item.added for message
|
||||
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
|
||||
messageItem := &schema.ORItemField{
|
||||
Type: "message",
|
||||
ID: currentMessageID,
|
||||
Status: "in_progress",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{},
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added
|
||||
currentContentIndex = 0
|
||||
emptyTextPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &emptyTextPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Stream text deltas with reasoning extraction
|
||||
tokenCallback := func(token string, tokenUsage backend.TokenUsage) bool {
|
||||
accumulatedText += token
|
||||
|
||||
routing := router.route(token, tokenUsage)
|
||||
var reasoningDelta, contentDelta string
|
||||
goReasoning, goContent := extractor.ProcessToken(token)
|
||||
|
||||
// Open the reasoning item lazily on the first reasoning delta.
|
||||
if routing.OpenReasoningItem {
|
||||
outputIndex++
|
||||
currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String())
|
||||
reasoningItem := &schema.ORItemField{
|
||||
Type: "reasoning",
|
||||
ID: currentReasoningID,
|
||||
Status: "in_progress",
|
||||
if tokenUsage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
|
||||
// Handle reasoning item
|
||||
if extractor.Reasoning() != "" {
|
||||
// Check if we need to create reasoning item
|
||||
if currentReasoningID == "" {
|
||||
outputIndex++
|
||||
currentReasoningID = fmt.Sprintf("reasoning_%s", uuid.New().String())
|
||||
reasoningItem := &schema.ORItemField{
|
||||
Type: "reasoning",
|
||||
ID: currentReasoningID,
|
||||
Status: "in_progress",
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: reasoningItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added for reasoning
|
||||
currentReasoningContentIndex = 0
|
||||
emptyPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Part: &emptyPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: reasoningItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added for reasoning
|
||||
currentReasoningContentIndex = 0
|
||||
emptyPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Part: &emptyPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
|
||||
// Emit reasoning delta against the reasoning_ item id.
|
||||
if routing.ReasoningDelta != "" {
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Delta: strPtr(routing.ReasoningDelta),
|
||||
Logprobs: emptyLogprobs(),
|
||||
})
|
||||
sequenceNumber++
|
||||
c.Response().Flush()
|
||||
}
|
||||
|
||||
// Open the message item lazily on the first content delta.
|
||||
if routing.OpenMessageItem {
|
||||
outputIndex++
|
||||
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
|
||||
messageItem = &schema.ORItemField{
|
||||
Type: "message",
|
||||
ID: currentMessageID,
|
||||
Status: "in_progress",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{},
|
||||
// Emit reasoning delta if there's new content
|
||||
if reasoningDelta != "" {
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentReasoningID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tReasoningContentIndex,
|
||||
Delta: strPtr(reasoningDelta),
|
||||
Logprobs: emptyLogprobs(),
|
||||
})
|
||||
sequenceNumber++
|
||||
c.Response().Flush()
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.added
|
||||
currentContentIndex = 0
|
||||
emptyTextPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &emptyTextPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
|
||||
// Emit text delta against the msg_ item id.
|
||||
if routing.ContentDelta != "" {
|
||||
// Only emit message content if there's actual content (not just reasoning)
|
||||
if contentDelta != "" {
|
||||
// Emit text delta
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.delta",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Delta: strPtr(routing.ContentDelta),
|
||||
Delta: strPtr(contentDelta),
|
||||
Logprobs: emptyLogprobs(),
|
||||
})
|
||||
sequenceNumber++
|
||||
@@ -2562,78 +2568,40 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
// Convert logprobs for streaming events
|
||||
mcpStreamLogprobs := convertLogprobsForStreaming(noToolLogprobs)
|
||||
|
||||
// The message item is created lazily on the first content delta (issue
|
||||
// #9658). If no content streamed but final extraction produced text (e.g.
|
||||
// the autoparser delivered everything at once), open the message item now
|
||||
// so the closing events below are valid. A pure-reasoning turn (no content
|
||||
// at all) leaves messageItem nil and emits no message item.
|
||||
if messageItem == nil && result != "" {
|
||||
outputIndex++
|
||||
currentMessageID = fmt.Sprintf("msg_%s", uuid.New().String())
|
||||
messageItem = &schema.ORItemField{
|
||||
Type: "message",
|
||||
ID: currentMessageID,
|
||||
Status: "in_progress",
|
||||
Role: "assistant",
|
||||
Content: []schema.ORContentPart{},
|
||||
}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
// Emit output_text.done
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.done",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Text: strPtr(result),
|
||||
Logprobs: logprobsPtr(mcpStreamLogprobs),
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
currentContentIndex = 0
|
||||
emptyTextPart := makeOutputTextPart("")
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.added",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &emptyTextPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
// Emit content_part.done (with actual logprobs)
|
||||
resultPart := makeOutputTextPartWithLogprobs(result, noToolLogprobs)
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.done",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &resultPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
if messageItem != nil {
|
||||
// Emit output_text.done
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_text.done",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Text: strPtr(result),
|
||||
Logprobs: logprobsPtr(mcpStreamLogprobs),
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit content_part.done (with actual logprobs)
|
||||
resultPart := makeOutputTextPartWithLogprobs(result, noToolLogprobs)
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.content_part.done",
|
||||
SequenceNumber: sequenceNumber,
|
||||
ItemID: currentMessageID,
|
||||
OutputIndex: &outputIndex,
|
||||
ContentIndex: ¤tContentIndex,
|
||||
Part: &resultPart,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit output_item.done (with actual logprobs)
|
||||
messageItem.Status = "completed"
|
||||
messageItem.Content = []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, noToolLogprobs)}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.done",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
}
|
||||
// Emit output_item.done (with actual logprobs)
|
||||
messageItem.Status = "completed"
|
||||
messageItem.Content = []schema.ORContentPart{makeOutputTextPartWithLogprobs(result, noToolLogprobs)}
|
||||
sendSSEEvent(c, &schema.ORStreamEvent{
|
||||
Type: "response.output_item.done",
|
||||
SequenceNumber: sequenceNumber,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: messageItem,
|
||||
})
|
||||
sequenceNumber++
|
||||
|
||||
// Emit function_call items from automatic tool parsing fallback
|
||||
for _, fc := range streamFallbackToolCalls {
|
||||
@@ -2670,13 +2638,10 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
// Emit response.completed
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Collect final output items, ordered reasoning -> message -> tool calls.
|
||||
// Issue #9658: reasoning is emitted as its own item ahead of the message,
|
||||
// matching the streamed order (reasoning item is opened before the message
|
||||
// item when the model thinks first).
|
||||
// Collect final output items (reasoning first, then messages, then tool calls)
|
||||
var finalOutputItems []schema.ORItemField
|
||||
// Add reasoning item if one was streamed.
|
||||
if router.ReasoningStreamed() && finalReasoning != "" {
|
||||
// Add reasoning item if it exists
|
||||
if currentReasoningID != "" && finalReasoning != "" {
|
||||
finalOutputItems = append(finalOutputItems, schema.ORItemField{
|
||||
Type: "reasoning",
|
||||
ID: currentReasoningID,
|
||||
@@ -2684,12 +2649,18 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
Content: []schema.ORContentPart{makeOutputTextPart(finalReasoning)},
|
||||
})
|
||||
}
|
||||
// Add the message item if one was produced (created lazily, so it may be
|
||||
// nil for a pure-reasoning turn).
|
||||
if messageItem != nil {
|
||||
// Add message item
|
||||
if len(collectedOutputItems) > 0 {
|
||||
// Use collected items (may include reasoning already)
|
||||
for _, item := range collectedOutputItems {
|
||||
if item.Type == "message" {
|
||||
finalOutputItems = append(finalOutputItems, item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finalOutputItems = append(finalOutputItems, *messageItem)
|
||||
}
|
||||
// Add function_call items from fallback parsing.
|
||||
// Add function_call items from fallback
|
||||
for _, item := range collectedOutputItems {
|
||||
if item.Type == "function_call" {
|
||||
finalOutputItems = append(finalOutputItems, item)
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package openresponses
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
reason "github.com/mudler/LocalAI/pkg/reasoning"
|
||||
)
|
||||
|
||||
// streamTokenRouting describes how a single streamed token's deltas should be
|
||||
// routed to Open Responses output items: the reasoning/content split and
|
||||
// whether a new reasoning or message output item must be opened before the
|
||||
// corresponding delta can be emitted.
|
||||
type streamTokenRouting struct {
|
||||
ReasoningDelta string
|
||||
ContentDelta string
|
||||
// OpenReasoningItem is true when a reasoning output item must be created
|
||||
// before emitting ReasoningDelta (the first reasoning delta of the stream).
|
||||
OpenReasoningItem bool
|
||||
// OpenMessageItem is true when a message output item must be created before
|
||||
// emitting ContentDelta (the first content delta of the stream).
|
||||
OpenMessageItem bool
|
||||
}
|
||||
|
||||
// streamReasoningRouter classifies streamed tokens into reasoning vs message
|
||||
// deltas and tracks which output items have been opened, so the SSE-emitting
|
||||
// code in handleOpenResponsesStream becomes a thin shell over a unit-testable
|
||||
// decision.
|
||||
//
|
||||
// It mirrors the sticky-preferAutoparser logic in the OpenAI chat streaming
|
||||
// worker (core/http/endpoints/openai/chat_stream_workers.go, processStream):
|
||||
// once the C++ autoparser has surfaced reasoning_content, we trust its
|
||||
// classification for the rest of the stream; until then we fall back to the
|
||||
// Go-side reasoning extractor so a pure-content autoparser (the non-jinja PEG
|
||||
// fallback, issue #9985) does not leak <think>...</think> tokens into content.
|
||||
//
|
||||
// Crucially, the decision to open and target a reasoning item keys off the
|
||||
// per-token reasoningDelta, NOT extractor.Reasoning(): the autoparser path
|
||||
// computes reasoning through ProcessChatDeltaReasoning, which updates a
|
||||
// separate accumulator that extractor.Reasoning() never exposes. Gating on
|
||||
// extractor.Reasoning() (issue #9658) dropped live reasoning whenever the
|
||||
// autoparser drove it via reasoning_content, surfacing it only after the
|
||||
// stream completed and mis-routing earlier deltas onto the msg_ item.
|
||||
type streamReasoningRouter struct {
|
||||
extractor *reason.ReasoningExtractor
|
||||
preferAutoparser bool
|
||||
reasoningOpened bool
|
||||
messageOpened bool
|
||||
}
|
||||
|
||||
func newStreamReasoningRouter(extractor *reason.ReasoningExtractor) *streamReasoningRouter {
|
||||
return &streamReasoningRouter{extractor: extractor}
|
||||
}
|
||||
|
||||
// classify splits a token into reasoning/content deltas using the sticky
|
||||
// preferAutoparser preference. Once the C++ autoparser has surfaced
|
||||
// reasoning_content we trust it for the rest of the stream; until then we fall
|
||||
// back to the Go-side extractor so a pure-content autoparser (zero
|
||||
// reasoning_content, issue #9985) does not leak <think>...</think> tokens into
|
||||
// content.
|
||||
func (r *streamReasoningRouter) classify(token string, usage backend.TokenUsage) (reasoningDelta, contentDelta string) {
|
||||
goReasoning, goContent := r.extractor.ProcessToken(token)
|
||||
if usage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
|
||||
if rawReasoning != "" {
|
||||
r.preferAutoparser = true
|
||||
}
|
||||
if r.preferAutoparser {
|
||||
contentDelta = cd
|
||||
reasoningDelta = r.extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
return reasoningDelta, contentDelta
|
||||
}
|
||||
|
||||
// route classifies a token and decides which output items its deltas target,
|
||||
// flipping the opened-flags as items are created.
|
||||
//
|
||||
// The reasoning gate keys off reasoningDelta, NOT extractor.Reasoning(): the
|
||||
// autoparser path computes reasoning via ProcessChatDeltaReasoning into a
|
||||
// separate accumulator that extractor.Reasoning() never reflects (issue #9658).
|
||||
func (r *streamReasoningRouter) route(token string, usage backend.TokenUsage) streamTokenRouting {
|
||||
reasoningDelta, contentDelta := r.classify(token, usage)
|
||||
out := streamTokenRouting{ReasoningDelta: reasoningDelta, ContentDelta: contentDelta}
|
||||
if reasoningDelta != "" && !r.reasoningOpened {
|
||||
out.OpenReasoningItem = true
|
||||
r.reasoningOpened = true
|
||||
}
|
||||
if contentDelta != "" && !r.messageOpened {
|
||||
out.OpenMessageItem = true
|
||||
r.messageOpened = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resetForIteration clears the per-stream routing state for an MCP re-inference
|
||||
// iteration, mirroring extractor.Reset() on the underlying extractor.
|
||||
func (r *streamReasoningRouter) resetForIteration() {
|
||||
r.preferAutoparser = false
|
||||
r.reasoningOpened = false
|
||||
r.messageOpened = false
|
||||
r.extractor.Reset()
|
||||
}
|
||||
|
||||
// ReasoningStreamed reports whether a reasoning output item was opened during
|
||||
// the stream. The end-of-stream closing blocks key off this rather than a
|
||||
// reasoning-id string so the ordering (reasoning before message) is explicit.
|
||||
func (r *streamReasoningRouter) ReasoningStreamed() bool {
|
||||
return r.reasoningOpened
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package openresponses
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/backend"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
reason "github.com/mudler/LocalAI/pkg/reasoning"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// usageWithChatDeltas builds a TokenUsage carrying a single C++ autoparser
|
||||
// ChatDelta with the given content / reasoning_content split.
|
||||
func usageWithChatDeltas(content, reasoningContent string) backend.TokenUsage {
|
||||
return backend.TokenUsage{
|
||||
ChatDeltas: []*pb.ChatDelta{
|
||||
{Content: content, ReasoningContent: reasoningContent},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Regression tests for issue #9658: in the /v1/responses streaming handler the
|
||||
// thinking monologue from a reasoning model was streamed to the client as a
|
||||
// normal message (msg_ item, output_text.delta) instead of as a reasoning
|
||||
// item, and was only re-classified into a reasoning item AFTER the stream
|
||||
// completed.
|
||||
//
|
||||
// Root cause: the live reasoning item was gated on extractor.Reasoning(),
|
||||
// which is only updated by the Go-side raw-tag parser (ProcessToken). When the
|
||||
// C++ autoparser drives reasoning through reasoning_content ChatDeltas, the
|
||||
// reasoning is computed via ProcessChatDeltaReasoning into a SEPARATE
|
||||
// accumulator, so extractor.Reasoning() stays empty and the gate never fires.
|
||||
var _ = Describe("streamReasoningRouter", func() {
|
||||
Context("autoparser drives reasoning via reasoning_content (issue #9658)", func() {
|
||||
It("opens a reasoning item during streaming and targets it (not the message)", func() {
|
||||
extractor := reason.NewReasoningExtractor("", reason.Config{})
|
||||
router := newStreamReasoningRouter(extractor)
|
||||
|
||||
// The raw token is empty: the autoparser carries the reasoning in
|
||||
// ChatDelta.ReasoningContent, so the Go-side extractor's
|
||||
// Reasoning() stays "" — exactly the state in which the buggy
|
||||
// extractor.Reasoning() gate failed to open a reasoning item.
|
||||
routing := router.route("", usageWithChatDeltas("", "Let me think about this"))
|
||||
|
||||
Expect(routing.ReasoningDelta).To(Equal("Let me think about this"),
|
||||
"the autoparser's reasoning_content must surface as a reasoning delta during streaming")
|
||||
Expect(routing.OpenReasoningItem).To(BeTrue(),
|
||||
"a reasoning output item must be opened live, not deferred to end-of-stream (#9658)")
|
||||
Expect(routing.ContentDelta).To(BeEmpty())
|
||||
Expect(routing.OpenMessageItem).To(BeFalse(),
|
||||
"reasoning deltas must target the reasoning_ item, never open/route to a msg_ item")
|
||||
})
|
||||
|
||||
It("does not re-open the reasoning item on subsequent reasoning deltas", func() {
|
||||
extractor := reason.NewReasoningExtractor("", reason.Config{})
|
||||
router := newStreamReasoningRouter(extractor)
|
||||
|
||||
_ = router.route("", usageWithChatDeltas("", "first "))
|
||||
routing := router.route("", usageWithChatDeltas("", "second"))
|
||||
|
||||
Expect(routing.ReasoningDelta).To(Equal("second"))
|
||||
Expect(routing.OpenReasoningItem).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("pure content stream", func() {
|
||||
It("never opens a reasoning item", func() {
|
||||
extractor := reason.NewReasoningExtractor("", reason.Config{})
|
||||
router := newStreamReasoningRouter(extractor)
|
||||
|
||||
// Content-only with no reasoning_content: the autoparser is in its
|
||||
// pure-content mode, so the router stays on the Go-side extractor,
|
||||
// which sees the content via the raw token.
|
||||
routing := router.route("hello world", usageWithChatDeltas("hello world", ""))
|
||||
|
||||
Expect(routing.ContentDelta).To(Equal("hello world"))
|
||||
Expect(routing.OpenMessageItem).To(BeTrue())
|
||||
Expect(routing.OpenReasoningItem).To(BeFalse(),
|
||||
"a content-only stream must never open a reasoning item")
|
||||
Expect(router.ReasoningStreamed()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("content-only autoparser with embedded <think> (issue #9985 fallback)", func() {
|
||||
It("falls back to Go-side extraction instead of leaking <think> into content", func() {
|
||||
extractor := reason.NewReasoningExtractor("", reason.Config{})
|
||||
router := newStreamReasoningRouter(extractor)
|
||||
|
||||
// The autoparser is in its non-jinja pure-content fallback: it
|
||||
// surfaces the whole string as Content with zero reasoning_content,
|
||||
// tags and all. The router must NOT trust it (preferAutoparser must
|
||||
// stay false) and instead use the Go-side split.
|
||||
routing := router.route("<think>reasoning here</think>answer",
|
||||
usageWithChatDeltas("<think>reasoning here</think>answer", ""))
|
||||
|
||||
Expect(routing.ContentDelta).To(Equal("answer"),
|
||||
"content must be the cleaned answer, not the raw <think>...</think> string")
|
||||
Expect(routing.ReasoningDelta).To(Equal("reasoning here"))
|
||||
Expect(routing.OpenReasoningItem).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user