Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -2922,11 +2922,12 @@ cc_library(
name = "test_llm_output_parser_tests",
linkstatic = 1,
alwayslink = True,
hdrs = ["test/llm/output_parsers/output_parser_test_utils.hpp"],
srcs = glob(["test/llm/output_parsers/*_test.cpp"]),
deps = [
"@com_google_googletest//:gtest",
":test_platform_utils",
"//src/llm:output_parsers",
"//src/llm:text_streamer",
],
copts = COPTS_TESTS,
local_defines = COMMON_LOCAL_DEFINES,
Expand Down
19 changes: 16 additions & 3 deletions src/llm/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ ovms_cc_library(

ovms_cc_library(
name = "io_processing_base_output_parser",
hdrs = ["io_processing/base_output_parser.hpp"],
hdrs = ["io_processing/base_output_parser.hpp",
"io_processing/parsing_config.hpp"],
srcs = ["io_processing/base_output_parser.cpp"],
deps = [
"@com_github_tencent_rapidjson//:rapidjson",
Expand Down Expand Up @@ -485,10 +486,22 @@ ovms_cc_library(
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "text_streamer",
hdrs = ["ovms_text_streamer.hpp"],
srcs = ["ovms_text_streamer.cpp"],
deps = [
"@com_github_tencent_rapidjson//:rapidjson",
"//src:libovmslogging",
"//third_party:genai",
":output_parsers",
],
visibility = ["//visibility:public"],
)

ovms_cc_library(
name = "genai_servables",
hdrs = ["servable.hpp",
"ovms_text_streamer.hpp",
"servable_initializer.hpp",
"language_model/continuous_batching/servable.hpp",
"language_model/continuous_batching/llm_executor.hpp",
Expand All @@ -506,7 +519,6 @@ ovms_cc_library(
"text_utils.hpp"],
srcs = ["servable.cpp",
"servable_initializer.cpp",
"ovms_text_streamer.cpp",
"language_model/continuous_batching/servable.cpp",
"language_model/continuous_batching/servable_initializer.cpp",
"visual_language_model/continuous_batching/servable.cpp",
Expand All @@ -533,6 +545,7 @@ ovms_cc_library(
"//src/filesystem:libovmsfilesystem",
"//src/tokenize:tokenize_parser",
"llmcalculator_cc_proto",
":text_streamer",
":openai_completions_api_handler",
":openai_responses_handler",
":generation_config_builders",
Expand Down
75 changes: 54 additions & 21 deletions src/llm/apis/openai_api_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,6 @@ std::string OpenAIApiHandler::serializeFailedEvent(const std::string& errorMessa
return "";
}

std::vector<int64_t> OpenAIApiHandler::encodeTextToTokens(const std::string& text) {
auto result = tokenizer.encode(text);
auto& input_ids = result.input_ids;
if (input_ids.get_shape().size() != 2)
throw std::runtime_error("input_ids should have 2 dimensions");
if (input_ids.get_shape()[0] != 1)
throw std::runtime_error("input_ids should have 1 batch size");
if (input_ids.get_element_type() != ov::element::i64)
throw std::runtime_error("input_ids should have i64 element type");
int64_t* data = reinterpret_cast<int64_t*>(input_ids.data());
return std::vector<int64_t>(data, data + input_ids.get_shape()[1]);
}

absl::Status OpenAIApiHandler::parseResponseFormat() {
auto it = doc.FindMember("response_format");
if (it != doc.MemberEnd()) {
Expand Down Expand Up @@ -395,15 +382,61 @@ void OpenAIApiHandler::incrementProcessedTokens(size_t numTokens) {
usage.completionTokens += numTokens;
}

ParsedOutput OpenAIApiHandler::parseOutputIfNeeded(const std::vector<int64_t>& generatedIds) {
OVMS_PROFILE_FUNCTION();
ParsedOutput parsedOutput;
if ((endpoint != Endpoint::CHAT_COMPLETIONS && endpoint != Endpoint::RESPONSES) || outputParser == nullptr) {
parsedOutput.content = this->tokenizer.decode(generatedIds, ov::genai::skip_special_tokens(request.skipSpecialTokens));
} else {
parsedOutput = outputParser->parse(generatedIds, this->areToolsAvailable());
std::string OpenAIApiHandler::serializeUnaryResponse(
const std::vector<std::vector<rapidjson::Document>>& allDeltas,
const std::vector<ov::genai::GenerationFinishReason>& finishReasons) {
return serializeUnaryResponse(allDeltas, finishReasons, {});
}

ParsedOutput OpenAIApiHandler::parsedOutputFromDeltas(const std::vector<rapidjson::Document>& deltas) {
ParsedOutput output;
// tool calls keyed by index; index values are expected to be dense starting from 0
std::vector<ToolCall> toolCalls;
for (const auto& doc : deltas) {
if (!doc.IsObject() || !doc.HasMember("delta")) {
continue; // empty finish-only chunk
}
const auto& delta = doc["delta"];
if (!delta.IsObject()) {
continue;
}
if (delta.HasMember("content") && delta["content"].IsString()) {
output.content += delta["content"].GetString();
}
if (delta.HasMember("reasoning_content") && delta["reasoning_content"].IsString()) {
output.reasoning += delta["reasoning_content"].GetString();
}
if (delta.HasMember("tool_calls") && delta["tool_calls"].IsArray()) {
for (const auto& tcEntry : delta["tool_calls"].GetArray()) {
if (!tcEntry.IsObject() || !tcEntry.HasMember("index")) {
continue;
}
const int rawIdx = tcEntry["index"].GetInt();
if (rawIdx < 0) {
continue;
}
const auto idx = static_cast<size_t>(rawIdx);
if (idx >= toolCalls.size()) {
toolCalls.resize(idx + 1);
}
ToolCall& tc = toolCalls[idx];
if (tcEntry.HasMember("id") && tcEntry["id"].IsString()) {
tc.id = tcEntry["id"].GetString();
}
if (tcEntry.HasMember("function") && tcEntry["function"].IsObject()) {
const auto& fn = tcEntry["function"];
if (fn.HasMember("name") && fn["name"].IsString()) {
tc.name = fn["name"].GetString();
}
if (fn.HasMember("arguments") && fn["arguments"].IsString()) {
tc.arguments += fn["arguments"].GetString();
}
}
}
}
}
return parsedOutput;
output.toolCalls = std::move(toolCalls);
return output;
}

// --- Free functions ---
Expand Down
31 changes: 24 additions & 7 deletions src/llm/apis/openai_api_handler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ struct CompletionUsageStatistics {
}
};

// Per-choice raw token data needed to build logprob objects in unary responses.
// populated in GenAiServable::prepareCompleteResponse from GenerationOutput.
struct UnaryChoiceLogprobs {
std::vector<int64_t> generatedIds;
std::vector<float> logProbs;
};

// Abstract base class for OpenAI API handlers.
// Holds common state (request, doc, tokenizer, usage, output parser) and implements
// shared parsing logic. Endpoint-specific parsing and serialization are pure virtual.
Expand Down Expand Up @@ -117,10 +124,10 @@ class OpenAIApiHandler {
absl::Status parseCommonPart(std::optional<uint32_t> maxTokensLimit, uint32_t bestOfLimit, std::optional<uint32_t> maxModelLength);
absl::Status parseResponseFormat();
absl::Status ensureArgumentsInToolCalls(Value& messageObj);
ParsedOutput parseOutputIfNeeded(const std::vector<int64_t>& generatedIds);

// Shared VLM workaround: encode text to tokens using tokenizer, validates shape
std::vector<int64_t> encodeTextToTokens(const std::string& text);
// Assemble a ParsedOutput from a sequence of streaming delta Documents produced by OVMSTextStreamer.
// Each document has the shape {"delta":{...}} as emitted by flush_chunk, or an empty object for
// finish-only chunks. Content, reasoning, and tool-call fragments are accumulated in order.
static ParsedOutput parsedOutputFromDeltas(const std::vector<rapidjson::Document>& deltas);

public:
OpenAIApiHandler(Document& doc, Endpoint endpoint, std::chrono::time_point<std::chrono::system_clock> creationTime,
Expand Down Expand Up @@ -198,9 +205,19 @@ class OpenAIApiHandler {
virtual void incrementProcessedTokens(size_t numTokens = 1);

// Serialization - pure virtual, each handler produces its own response format
virtual std::string serializeUnaryResponse(const std::vector<ov::genai::GenerationOutput>& generationOutputs) = 0;
virtual std::string serializeUnaryResponse(ov::genai::EncodedResults& results) = 0;
virtual std::string serializeUnaryResponse(ov::genai::VLMDecodedResults& results, const std::string& textResponse) = 0;
// Phase 2: delta-based unary serialisation — assembles a complete response from streaming
// delta Documents collected via deltaChannel after OVMSTextStreamer finishes.
// Single-choice variant (used by Legacy servables).
virtual std::string serializeUnaryResponse(const std::vector<rapidjson::Document>& deltas, ov::genai::GenerationFinishReason finishReason) = 0;
// Multi-choice variant: N delta-vectors (one per sequence) + per-sequence finish reasons.
// logprobData may be empty when logprobs are not requested; otherwise its size equals
// allDeltas.size(). Used by ContinuousBatchingServable for both n=1 and n>1.
virtual std::string serializeUnaryResponse(const std::vector<std::vector<rapidjson::Document>>& allDeltas,
const std::vector<ov::genai::GenerationFinishReason>& finishReasons,
const std::vector<UnaryChoiceLogprobs>& logprobData) = 0;
// Convenience overload: no logprobs (delegates to the virtual above with empty logprobData).
std::string serializeUnaryResponse(const std::vector<std::vector<rapidjson::Document>>& allDeltas,
const std::vector<ov::genai::GenerationFinishReason>& finishReasons);
virtual std::string serializeStreamingChunk(rapidjson::Document parsedDelta, ov::genai::GenerationFinishReason finishReason) = 0;
virtual std::string serializeStreamingUsageChunk() = 0;
virtual std::string serializeStreamingHandshakeChunk() = 0;
Expand Down
Loading