diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index 1a070e397..f8b62ce62 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -213,6 +213,8 @@ set(FOUNDRY_LOCAL_SOURCES src/inferencing/generative/audio/pcm_utils.cc src/inferencing/generative/embeddings/embeddings_session.cc src/inferencing/generative/chat/chat_generator.cc + src/inferencing/generative/chat/onnx_chat_engine.cc + src/inferencing/generative/chat/onnx_engine_chat_generator.cc src/inferencing/session/session.cc src/inferencing/session/session_manager.cc src/inferencing/generative/chat/chat_session.cc diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.cc b/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.cc index c90c4d785..416982ff6 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.cc @@ -4,6 +4,10 @@ namespace fl { +std::optional ChatGenerator::GetTurnUsage() const { + return std::nullopt; +} + std::string ChatGenerator::GenerateAll() { std::string result; diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.h b/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.h index 77430d984..9f80b77bc 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.h +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_generator.h @@ -3,9 +3,20 @@ #pragma once #include +#include +#include namespace fl { +class GenAIModelInstance; +struct MessageItem; +struct SearchOptions; + +struct ChatTurnUsage { + int prompt_tokens = 0; + int generated_tokens = 0; +}; + /// Abstract interface for token-by-token text generation. /// One generator per request — not reusable, not thread-safe. /// Follows the classic pull-based iterator pattern: @@ -41,6 +52,21 @@ class ChatGenerator { /// After cancellation, IsDone() should return true on the next check. virtual void Cancel() = 0; + /// Append a new conversational turn to retained model state. + virtual int AppendMessages(const std::vector& new_messages, + GenAIModelInstance& model, + const std::string& tools_json, + const SearchOptions& options) = 0; + + /// Returns whether this backend can rewind retained model state directly. + virtual bool CanRewind() const = 0; + + /// Rewind retained model state to a prior token position. + virtual void RewindTo(int token_count) = 0; + + /// Return exact usage for the most recently completed turn when the backend exposes it. + virtual std::optional GetTurnUsage() const; + protected: ChatGenerator() = default; }; diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc index 7b8c646be..ffbb2ce3d 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc @@ -5,6 +5,7 @@ #include "contracts/chat_completions.h" #include "contracts/chat_completions_converter.h" +#include "inferencing/generative/chat/onnx_engine_chat_generator.h" #include "inferencing/generative/chat/onnx_chat_generator.h" #include "inferencing/generative/chat/reasoning_stream_splitter.h" #include "inferencing/generative/genai_model_instance.h" @@ -48,6 +49,17 @@ void ApplyToolChoiceToContext(std::optional tool_choice, ToolCallC } } +std::unique_ptr CreateTextChatGenerator(const std::vector& messages, + const SearchOptions& options, + GenAIModelInstance& model, + const ToolCallContext& tool_ctx) { + if (model.GetGenAIConfig().GetChatBackendKind() != ChatBackendKind::kGenerator) { + return OnnxEngineChatGenerator::Create(messages, options, model, tool_ctx); + } + + return OnnxChatGenerator::Create(messages, options, model, tool_ctx, /*use_full_context=*/true); +} + } // namespace ChatSession::ChatSession(const fl::Model& catalog_model, GenAIModelInstance& model, ILogger& logger, ITelemetry& telemetry) @@ -71,7 +83,9 @@ ChatSession::ChatSession(ChatSession&& other) noexcept history_(std::move(other.history_)), turns_(std::move(other.turns_)), session_options_(std::move(other.session_options_)), - cached_generator_(std::move(other.cached_generator_)) { + cached_generator_(std::move(other.cached_generator_)), + cached_tool_ctx_(std::move(other.cached_tool_ctx_)), + cached_search_options_(std::move(other.cached_search_options_)) { other.owns_session_ = false; } @@ -457,6 +471,12 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) int prompt_tokens = 0; int pre_turn_token_count = 0; + if (cached_generator_ && + !cached_search_options_.HasSameRetainedGenerationSettings(effective_options)) { + cached_generator_.reset(); + cached_tool_ctx_ = {}; + } + if (cached_generator_) { // Check if guidance requirements changed since the generator was created. Guidance (LARK grammar) is baked into // the OGA generator at creation time and cannot be changed. If tool_choice went from "required" to "auto" (or @@ -467,14 +487,17 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) bool prev_needs_guidance = cached_tool_ctx_.tool_output && !cached_tool_ctx_.text_output; bool curr_needs_guidance = turn_tool_ctx.tool_output && !turn_tool_ctx.text_output; - if (prev_needs_guidance != curr_needs_guidance) { + const bool static_engine = + Model().GetGenAIConfig().GetChatBackendKind() == ChatBackendKind::kStaticEngine; + if (prev_needs_guidance != curr_needs_guidance || static_engine) { // Guidance requirements changed — invalidate. The branch below will rebuild from full history. cached_generator_.reset(); cached_tool_ctx_ = {}; } else { // Continuous decoding: append only the new messages to the existing generator. pre_turn_token_count = cached_generator_->TokenCount(); - prompt_tokens = cached_generator_->AppendMessages(new_messages, Model(), cached_tool_ctx_.tools_json); + prompt_tokens = + cached_generator_->AppendMessages(new_messages, Model(), cached_tool_ctx_.tools_json, effective_options); // Refresh per-turn fields (tool_choice, guidance) while keeping session-level definitions stable. UpdateToolContextForTurn(request, cached_tool_ctx_); @@ -491,7 +514,7 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) all_messages.insert(all_messages.end(), history_.begin(), history_.end()); all_messages.insert(all_messages.end(), new_messages.begin(), new_messages.end()); - std::unique_ptr generator; + std::unique_ptr generator; if (media_turn) { // Media is single-shot: the generator is dropped after the turn (see // CommitTurn cleanup below) because AppendMessages can't extend a @@ -500,18 +523,18 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) // gigabytes (262k tokens × 28 layers × 8 heads × 128 dims for // qwen3-vl-2b ≈ 120 GB). Bound it to prompt + max_output_tokens. generator = OnnxChatGenerator::CreateWithMedia(all_messages, effective_options, Model(), images, audios, - tool_ctx, /*use_full_context*/ false); + tool_ctx, /*use_full_context*/ false); } else { - generator = OnnxChatGenerator::Create(all_messages, effective_options, Model(), tool_ctx, - /*use_full_context*/ true); + generator = CreateTextChatGenerator(all_messages, effective_options, Model(), tool_ctx); } prompt_tokens = generator->PromptTokenCount(); cached_generator_ = std::move(generator); cached_tool_ctx_ = std::move(tool_ctx); + cached_search_options_ = effective_options; } - int max_output = effective_options.max_output_tokens.value_or(0); + const int max_output = ResolveMaxOutputTokens(effective_options); // Generate token-by-token with optional streaming. // Check request.canceled each iteration — a streaming callback returning @@ -608,17 +631,33 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) emit_segments(splitter.Flush()); flush_accumulator(); + if (request.canceled) { + cached_generator_->Cancel(); + } + int total_tokens = cached_generator_->TokenCount(); + if (const auto turn_usage = cached_generator_->GetTurnUsage()) { + prompt_tokens = turn_usage->prompt_tokens; + total_tokens = turn_usage->prompt_tokens + turn_usage->generated_tokens; + } + bool discard_generator = false; if (request.canceled) { - // Rewind the generator to undo this turn's input. The generator remains valid - // for the next attempt — the caller can re-send the same input. - cached_generator_->RewindTo(pre_turn_token_count); + if (cached_generator_->CanRewind()) { + cached_generator_->RewindTo(pre_turn_token_count); + } else { + discard_generator = true; + } } ProcessGeneratedOutput(std::move(text), cached_tool_ctx_, effective_options, request.canceled, response, prompt_tokens, total_tokens, std::move(streamed_tool_calls)); + if (discard_generator) { + cached_generator_.reset(); + cached_tool_ctx_ = {}; + } + // Commit input messages + assistant reply to history only on success (not cancelled) if (!request.canceled) { // LARK grammar (tool-call-only mode) is a single-shot finite parse. If generation was truncated while grammar was @@ -632,7 +671,9 @@ void ChatSession::ProcessRequestImpl(const Request& request, Response& response) bool grammar_was_active = cached_tool_ctx_.tool_output && !cached_tool_ctx_.text_output; bool reasoning_was_active = cached_tool_ctx_.supports_reasoning; - if (grammar_was_active || reasoning_was_active) { + const bool static_engine = + Model().GetGenAIConfig().GetChatBackendKind() == ChatBackendKind::kStaticEngine; + if (grammar_was_active || reasoning_was_active || static_engine) { cached_generator_.reset(); cached_tool_ctx_ = {}; } @@ -713,7 +754,7 @@ void ChatSession::ProcessChatCompletionsJson(const std::string& request_json, co } // Create generator - auto generator = OnnxChatGenerator::Create(messages, options, Model(), tool_ctx); + auto generator = CreateTextChatGenerator(messages, options, Model(), tool_ctx); int prompt_tokens = generator->PromptTokenCount(); auto streaming_callback = CreateCallbackHandler(original_request); @@ -826,7 +867,15 @@ void ChatSession::ProcessChatCompletionsJson(const std::string& request_json, co emit_ready_calls(out.ready_calls); } + if (original_request.canceled) { + generator->Cancel(); + } + int total_tokens = generator->TokenCount(); + if (const auto turn_usage = generator->GetTurnUsage()) { + prompt_tokens = turn_usage->prompt_tokens; + total_tokens = turn_usage->prompt_tokens + turn_usage->generated_tokens; + } // Process the generated output into response items (MessageItem, ToolCallItem, etc.) // This also updates finish_reason, and usage on the response. Streamed-parsed tool calls are reused so call_ids @@ -909,8 +958,11 @@ void ChatSession::UndoTurns(size_t count) { // Undoing all turns — destroy the generator entirely cached_generator_.reset(); cached_tool_ctx_ = {}; - } else { + } else if (cached_generator_->CanRewind()) { cached_generator_->RewindTo(target.pre_turn_token_count); + } else { + cached_generator_.reset(); + cached_tool_ctx_ = {}; } } diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h index 4bd761bc3..24aa79f6b 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h @@ -16,7 +16,7 @@ namespace fl { class GenAIModelInstance; -class OnnxChatGenerator; +class ChatGenerator; /// A chat session that maintains conversation history across turns. /// Designed for multi-turn conversations where message history accumulates @@ -119,11 +119,14 @@ class ChatSession : public Session { // Cached generator for continuous decoding (non-JSON path only). // Null until first non-JSON ProcessRequestImpl call. - std::unique_ptr cached_generator_; + std::unique_ptr cached_generator_; // Tool context used when creating the cached generator. // Reused for subsequent turns to maintain tool definition consistency. ToolCallContext cached_tool_ctx_; + + // Search settings baked into the retained generator or Engine request. + SearchOptions cached_search_options_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.cc b/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.cc index 8fbd3f4ed..3da893bc5 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.cc @@ -63,6 +63,26 @@ std::string BuildChatPrompt(const std::vector& messages, return model.GetPreprocessor().ApplyChatTemplate(messages_str.c_str(), tools_ptr, /*add_generation_prompt=*/true); } +std::string BuildChatContinuationPrompt(const std::vector& messages, + GenAIModelInstance& model, + const std::string& tools_json) { + constexpr std::string_view kAssistantMarker = "__foundry_engine_assistant_boundary__"; + + std::vector marked_messages; + marked_messages.reserve(messages.size() + 1); + marked_messages.emplace_back(FOUNDRY_LOCAL_ROLE_ASSISTANT, std::string(kAssistantMarker)); + marked_messages.insert(marked_messages.end(), messages.begin(), messages.end()); + + auto marked_prompt = BuildChatPrompt(marked_messages, model, tools_json); + const auto marker_position = marked_prompt.find(kAssistantMarker); + if (marker_position == std::string::npos) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + "chat template did not preserve assistant content needed to build an Engine continuation"); + } + + return marked_prompt.substr(marker_position + kAssistantMarker.size()); +} + std::unique_ptr EncodePrompt(const std::string& prompt, GenAIModelInstance& model) { return model.GetPreprocessor().Encode(prompt.c_str()); diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.h b/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.h index 7880e9a12..afc0ffb31 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.h +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_template.h @@ -39,6 +39,12 @@ std::string BuildChatPrompt(const std::vector& messages, GenAIModelInstance& model, const std::string& tools_json = ""); +/// Build the fragment appended after an Engine-generated assistant response. +/// Engine does not retain the generated EOS token, so this includes the template's assistant-turn boundary. +std::string BuildChatContinuationPrompt(const std::vector& messages, + GenAIModelInstance& model, + const std::string& tools_json = ""); + /// Encode a prompt string into token sequences using the model's shared tokenizer (thread-safe). /// Returns a unique_ptr to OgaSequences. Caller takes ownership. /// diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_engine.cc b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_engine.cc new file mode 100644 index 000000000..6cb803f16 --- /dev/null +++ b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_engine.cc @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "inferencing/generative/chat/onnx_chat_engine.h" + +#include "exception.h" +#include "inferencing/generative/genai_model_instance.h" +#include "inferencing/generative/toolcalling/tool_call_context.h" + +#include + +#include +#include +#include + +namespace fl { + +struct OnnxChatEngine::NativeConversation { + std::unique_ptr request; + std::shared_ptr state; +}; + +OnnxChatEngine::OnnxChatEngine(GenAIModelInstance& model) : model_(model) { + std::promise initialized; + auto ready = initialized.get_future(); + worker_ = std::thread(&OnnxChatEngine::WorkerLoop, this, std::move(initialized)); + try { + ready.get(); + } catch (...) { + if (worker_.joinable()) { + worker_.join(); + } + throw; + } +} + +OnnxChatEngine::~OnnxChatEngine() { + { + std::lock_guard lock(command_mutex_); + stopping_ = true; + } + command_cv_.notify_one(); + + if (worker_.joinable()) { + worker_.join(); + } +} + +std::shared_ptr OnnxChatEngine::CreateConversation( + const SearchOptions& options, const ToolCallContext& tool_ctx, int input_token_count) { + auto conversation = std::shared_ptr(new Conversation()); + auto completion = std::make_shared>(); + auto ready = completion->get_future(); + + Enqueue( + [this, conversation, options, tool_ctx, input_token_count, completion]() { + auto params = OgaGeneratorParams::Create(model_.GetOgaModel()); + ApplySearchOptions(options, input_token_count, model_.GetGenAIConfig(), *params, model_.EP(), + /*use_full_context=*/true); + ApplyGuidanceOptions(tool_ctx, *params); + auto request = engine_->CreateRequest(*params); + conversations_.emplace(conversation.get(), + std::make_unique( + NativeConversation{std::move(request), conversation})); + completion->set_value(); + }, + [completion](std::exception_ptr error) { completion->set_exception(error); }); + + ready.get(); + return conversation; +} + +uint64_t OnnxChatEngine::BeginTurn(const std::shared_ptr& conversation, + std::span input_ids, + std::optional max_output_tokens) { + if (input_ids.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "Engine turn input must not be empty"); + } + + auto tokens = std::vector(input_ids.begin(), input_ids.end()); + auto completion = std::make_shared>(); + auto ready = completion->get_future(); + + Enqueue( + [this, conversation, tokens = std::move(tokens), max_output_tokens, completion]() { + auto& native = FindNative(conversation); + std::unique_ptr options; + if (max_output_tokens.has_value()) { + options = native.request->CreateTurnOptions(); + options->SetMaxGeneratedTokens(static_cast(*max_output_tokens)); + } + + { + std::lock_guard lock(conversation->mutex); + if (!conversation->turn_finished) { + throw std::runtime_error("Cannot begin an Engine turn while another turn is active."); + } + conversation->tokens.clear(); + conversation->error = nullptr; + conversation->result = {}; + conversation->turn_finished = false; + } + + const uint64_t turn_id = native.request->BeginTurn(tokens.data(), tokens.size(), options.get()); + { + std::lock_guard lock(conversation->mutex); + conversation->turn_id = turn_id; + conversation->sequence_length += tokens.size(); + } + completion->set_value(turn_id); + }, + [conversation, completion](std::exception_ptr error) { + { + std::lock_guard lock(conversation->mutex); + conversation->error = error; + conversation->turn_finished = true; + } + conversation->cv.notify_all(); + completion->set_exception(error); + }); + + return ready.get(); +} + +std::optional OnnxChatEngine::WaitForToken(const std::shared_ptr& conversation) { + std::unique_lock lock(conversation->mutex); + conversation->cv.wait(lock, [&]() { + return !conversation->tokens.empty() || conversation->turn_finished || conversation->error; + }); + + if (conversation->error) { + std::rethrow_exception(conversation->error); + } + if (conversation->tokens.empty()) { + return std::nullopt; + } + + const int32_t token = conversation->tokens.front(); + conversation->tokens.pop_front(); + return token; +} + +bool OnnxChatEngine::IsTurnFinished(const std::shared_ptr& conversation) const { + std::lock_guard lock(conversation->mutex); + return conversation->turn_finished && conversation->tokens.empty(); +} + +OnnxChatEngine::TurnResult OnnxChatEngine::GetTurnResult( + const std::shared_ptr& conversation) const { + std::unique_lock lock(conversation->mutex); + conversation->cv.wait(lock, [&]() { return conversation->turn_finished || conversation->error; }); + if (conversation->error) { + std::rethrow_exception(conversation->error); + } + return conversation->result; +} + +size_t OnnxChatEngine::SequenceLength(const std::shared_ptr& conversation) const { + std::lock_guard lock(conversation->mutex); + return conversation->sequence_length; +} + +void OnnxChatEngine::Cancel(const std::shared_ptr& conversation) { + Enqueue( + [this, conversation]() { + auto& native = FindNative(conversation); + uint64_t turn_id; + { + std::lock_guard lock(conversation->mutex); + turn_id = conversation->turn_id; + } + if (turn_id != 0) { + native.request->CancelTurn(turn_id); + } + }, + [conversation](std::exception_ptr error) { + std::lock_guard lock(conversation->mutex); + conversation->error = error; + conversation->turn_finished = true; + conversation->cv.notify_all(); + }); +} + +void OnnxChatEngine::Close(const std::shared_ptr& conversation) { + auto completion = std::make_shared>(); + auto ready = completion->get_future(); + Enqueue( + [this, conversation, completion]() { + auto it = conversations_.find(conversation.get()); + if (it != conversations_.end()) { + it->second->request->Close(); + conversations_.erase(it); + } + { + std::lock_guard lock(conversation->mutex); + conversation->closed = true; + conversation->turn_finished = true; + } + conversation->cv.notify_all(); + completion->set_value(); + }, + [completion](std::exception_ptr error) { completion->set_exception(error); }); + ready.get(); +} + +void OnnxChatEngine::Enqueue(std::function command, + std::function fail) { + std::exception_ptr error; + { + std::lock_guard lock(command_mutex_); + error = fatal_error_; + if (stopping_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Engine dispatcher is shutting down"); + } + if (!error) { + commands_.push_back({std::move(command), std::move(fail)}); + } + } + + if (error) { + fail(error); + return; + } + command_cv_.notify_one(); +} + +void OnnxChatEngine::WorkerLoop(std::promise initialized) { + try { + engine_ = OgaEngine::Create(model_.GetOgaModel()); + event_buffer_ = engine_->CreateEventBuffer(model_.GetGenAIConfig().EngineMaxBatchSize().value_or(1) * 2); + initialized.set_value(); + } catch (...) { + event_buffer_.reset(); + engine_.reset(); + initialized.set_exception(std::current_exception()); + return; + } + + try { + while (true) { + std::deque commands; + { + std::unique_lock lock(command_mutex_); + if (commands_.empty() && !engine_->HasPendingRequests() && !stopping_) { + command_cv_.wait(lock, [&]() { return stopping_ || !commands_.empty(); }); + } + commands.swap(commands_); + if (stopping_ && commands.empty() && !engine_->HasPendingRequests()) { + break; + } + } + + for (auto& command : commands) { + try { + command.run(); + } catch (...) { + command.fail(std::current_exception()); + } + } + if (engine_->HasPendingRequests()) { + RouteEvents(); + } + } + } catch (...) { + auto error = std::current_exception(); + std::deque commands; + { + std::lock_guard lock(command_mutex_); + fatal_error_ = error; + stopping_ = true; + commands.swap(commands_); + } + + FailAll(error); + for (auto& command : commands) { + command.fail(error); + } + } + + conversations_.clear(); + event_buffer_.reset(); + engine_.reset(); +} + +void OnnxChatEngine::RouteEvents() { + engine_->Run(*event_buffer_); + for (size_t i = 0; i < event_buffer_->Count(); ++i) { + const auto* event = event_buffer_->Get(i); + const auto request = event->Request(); + if (!request) { + continue; + } + + auto it = std::find_if(conversations_.begin(), conversations_.end(), [&](const auto& entry) { + return entry.second->request.get() == &request->get(); + }); + if (it == conversations_.end()) { + continue; + } + + auto& conversation = it->second->state; + const auto flags = event->Flags(); + { + std::lock_guard lock(conversation->mutex); + if ((flags & OgaEngineEventFlag_Token) != 0) { + conversation->tokens.push_back(event->Token()); + ++conversation->sequence_length; + } + if ((flags & OgaEngineEventFlag_TurnFinished) != 0) { + const auto& usage = event->Usage(); + conversation->result.prompt_tokens = usage.PromptTokens(); + conversation->result.generated_tokens = usage.GeneratedTokens(); + conversation->result.cached_prompt_tokens = usage.CachedPromptTokens(); + conversation->result.finish_reason = event->FinishReason(); + conversation->turn_finished = true; + } + if ((flags & OgaEngineEventFlag_Failed) != 0) { + conversation->error = std::make_exception_ptr( + std::runtime_error("ORT GenAI Engine request failed with error code " + + std::to_string(event->ErrorCode()))); + conversation->turn_finished = true; + } + } + conversation->cv.notify_all(); + } +} + +void OnnxChatEngine::FailAll(std::exception_ptr error) { + for (auto& [_, native] : conversations_) { + { + std::lock_guard lock(native->state->mutex); + native->state->error = error; + native->state->turn_finished = true; + } + native->state->cv.notify_all(); + } +} + +OnnxChatEngine::NativeConversation& OnnxChatEngine::FindNative( + const std::shared_ptr& conversation) { + auto it = conversations_.find(conversation.get()); + if (it == conversations_.end()) { + throw std::runtime_error("Engine conversation is closed or does not belong to this model."); + } + return *it->second; +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_engine.h b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_engine.h new file mode 100644 index 000000000..324ea0136 --- /dev/null +++ b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_engine.h @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "inferencing/generative/chat/search_options.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct OgaEngine; +struct OgaEngineEventBuffer; +struct OgaRequest; + +namespace fl { + +class GenAIModelInstance; +struct ToolCallContext; + +/// Owns one ORT GenAI Engine and serializes every Engine operation onto its owner thread. +class OnnxChatEngine { + public: + struct TurnResult { + uint64_t prompt_tokens = 0; + uint64_t generated_tokens = 0; + uint64_t cached_prompt_tokens = 0; + uint32_t finish_reason = 0; + }; + + class Conversation { + public: + Conversation(const Conversation&) = delete; + Conversation& operator=(const Conversation&) = delete; + + private: + friend class OnnxChatEngine; + Conversation() = default; + + std::mutex mutex; + std::condition_variable cv; + std::deque tokens; + std::exception_ptr error; + TurnResult result; + uint64_t turn_id = 0; + size_t sequence_length = 0; + bool turn_finished = true; + bool closed = false; + }; + + explicit OnnxChatEngine(GenAIModelInstance& model); + ~OnnxChatEngine(); + + OnnxChatEngine(const OnnxChatEngine&) = delete; + OnnxChatEngine& operator=(const OnnxChatEngine&) = delete; + + std::shared_ptr CreateConversation(const SearchOptions& options, + const ToolCallContext& tool_ctx, + int input_token_count); + uint64_t BeginTurn(const std::shared_ptr& conversation, + std::span input_ids, + std::optional max_output_tokens); + std::optional WaitForToken(const std::shared_ptr& conversation); + bool IsTurnFinished(const std::shared_ptr& conversation) const; + TurnResult GetTurnResult(const std::shared_ptr& conversation) const; + size_t SequenceLength(const std::shared_ptr& conversation) const; + void Cancel(const std::shared_ptr& conversation); + void Close(const std::shared_ptr& conversation); + + private: + struct NativeConversation; + struct PendingCommand { + std::function run; + std::function fail; + }; + + void Enqueue(std::function command, std::function fail); + void WorkerLoop(std::promise initialized); + void RouteEvents(); + void FailAll(std::exception_ptr error); + NativeConversation& FindNative(const std::shared_ptr& conversation); + + GenAIModelInstance& model_; + mutable std::mutex command_mutex_; + std::condition_variable command_cv_; + std::deque commands_; + std::exception_ptr fatal_error_; + bool stopping_ = false; + std::thread worker_; + + // Owner-thread-only state. WorkerLoop clears these before it exits. + std::unique_ptr engine_; + std::unique_ptr event_buffer_; + std::unordered_map> conversations_; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.cc b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.cc index 8fd6e786e..493ab8673 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.cc @@ -138,7 +138,8 @@ void OnnxChatGenerator::Cancel() { int OnnxChatGenerator::AppendMessages(const std::vector& new_messages, GenAIModelInstance& model, - const std::string& tools_json) { + const std::string& tools_json, + const SearchOptions& /*options*/) { if (new_messages.empty()) { FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "new_messages must not be empty"); } @@ -357,44 +358,8 @@ std::unique_ptr OnnxChatGenerator::CreateImpl(const std::vect ApplySearchOptions(options, input_token_count, model.GetGenAIConfig(), *gen_params, model.EP(), use_full_context, default_max_output); - // 5. Compute guidance for constrained decoding. - // Priority: user-specified guidance (from response_format) > auto-generated LARK grammar. - // Matches C# GetGuidance() — always compute, then guard application. - std::string guidance_type; - std::string guidance_data; - - if (!tool_ctx.guidance_type.empty() && !tool_ctx.guidance_data.empty()) { - // User specified guidance via response_format - guidance_type = tool_ctx.guidance_type; - guidance_data = tool_ctx.guidance_data; - } else { - // Auto-generate LARK grammar from tool definitions and reasoning state - std::string json_schema; - if (tool_ctx.HasTools()) { - json_schema = BuildToolJsonSchema(tool_ctx); - } - - guidance_data = BuildLarkGrammar(tool_ctx, json_schema); - if (!guidance_data.empty()) { - guidance_type = "lark_grammar"; - } - } - - // Guard: Apply guidance only for tool-call-only mode (tool output requested, no text output). Text-only reasoning - // (cot_text_only) cannot use grammar guidance because a completed grammar signals EOS to the ORT GenAI generator — - // making IsDone() return true immediately on the next turn, breaking multi-turn continuous decoding. For - // tool-call-only mode the generator is typically invalidated after a successful call anyway, so this is acceptable. - // Reasoning content for text-only mode is handled via StripReasoningContent post-processing. - bool tool_call_only = tool_ctx.tool_output && !tool_ctx.text_output; - - if (!guidance_type.empty() && !guidance_data.empty() && tool_call_only) { - try { - gen_params->SetGuidance(guidance_type.c_str(), guidance_data.c_str()); - } catch (const std::runtime_error& e) { - // SetGuidance may not be supported by all models; continue without guidance - (void)e; - } - } + // 5. Apply constrained decoding for tool-only output when supported. + ApplyGuidanceOptions(tool_ctx, *gen_params); // 6. Create the Generator and feed it the prompt. // Text path: append the encoded token sequences. diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.h b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.h index e95b2b873..3e244e78d 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.h +++ b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_chat_generator.h @@ -47,11 +47,13 @@ class OnnxChatGenerator : public ChatGenerator { /// Returns the number of new prompt tokens appended. int AppendMessages(const std::vector& new_messages, GenAIModelInstance& model, - const std::string& tools_json); + const std::string& tools_json, + const SearchOptions& options) override; /// Rewind the generator to a previous token position. /// Used for error recovery — restores the KV cache to the state before the last turn. - void RewindTo(int token_count); + bool CanRewind() const override { return true; } + void RewindTo(int token_count) override; /// Factory: create a text-only chat generator. /// diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_engine_chat_generator.cc b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_engine_chat_generator.cc new file mode 100644 index 000000000..c2a8fea8c --- /dev/null +++ b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_engine_chat_generator.cc @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "inferencing/generative/chat/onnx_engine_chat_generator.h" + +#include "exception.h" +#include "inferencing/generative/chat/chat_template.h" +#include "inferencing/generative/genai_model_instance.h" + +#include + +#include + +namespace fl { + +OnnxEngineChatGenerator::OnnxEngineChatGenerator( + OnnxChatEngine& engine, + std::shared_ptr conversation, + std::unique_ptr stream, + std::unique_ptr stream_with_special, + GenAIModelInstance& model, + int prompt_token_count) + : engine_(engine), + conversation_(std::move(conversation)), + stream_(std::move(stream)), + stream_with_special_(std::move(stream_with_special)), + model_(model), + prompt_token_count_(prompt_token_count) {} + +OnnxEngineChatGenerator::~OnnxEngineChatGenerator() { + try { + engine_.Close(conversation_); + } catch (...) { + } +} + +bool OnnxEngineChatGenerator::IsDone() const { + return cancelled_ || engine_.IsTurnFinished(conversation_); +} + +void OnnxEngineChatGenerator::GenerateNextToken() { + if (cancelled_) { + return; + } + + try { + current_token_ = engine_.WaitForToken(conversation_); + } catch (const std::runtime_error& e) { + if (!cancelled_) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, std::string("Engine token generation failed: ") + e.what()); + } + } +} + +std::string OnnxEngineChatGenerator::Decode() { + if (!current_token_) { + return ""; + } + + const int32_t token_id = *current_token_; + current_token_.reset(); + const char* token_text = stream_->Decode(token_id); + const char* special_text = stream_with_special_->Decode(token_id); + std::string token = token_text ? token_text : ""; + + if (special_text != nullptr && token_text != nullptr && std::string(special_text) != token) { + const std::string special(special_text); + const bool surfaced_special = + special.find("tool_call") != std::string::npos || special.find("think") != std::string::npos; + const auto& eos_ids = model_.GetPreprocessor().GetEosTokenIds(); + const bool eos = std::find(eos_ids.begin(), eos_ids.end(), token_id) != eos_ids.end(); + if (surfaced_special && !eos) { + return special; + } + } + + return token; +} + +int OnnxEngineChatGenerator::TokenCount() const { + return static_cast(engine_.SequenceLength(conversation_)); +} + +int OnnxEngineChatGenerator::PromptTokenCount() const { + return prompt_token_count_; +} + +void OnnxEngineChatGenerator::Cancel() { + cancelled_ = true; + engine_.Cancel(conversation_); +} + +int OnnxEngineChatGenerator::AppendMessages(const std::vector& new_messages, + GenAIModelInstance& model, + const std::string& tools_json, + const SearchOptions& options) { + if (new_messages.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "new_messages must not be empty"); + } + + auto prompt = BuildChatContinuationPrompt(new_messages, model, tools_json); + auto sequences = EncodePrompt(prompt, model); + const int count = static_cast(sequences->SequenceCount(0)); + const auto* data = sequences->SequenceData(0); + engine_.BeginTurn(conversation_, std::span(data, static_cast(count)), + ResolveMaxOutputTokens(options)); + prompt_token_count_ = count; + cancelled_ = false; + return count; +} + +void OnnxEngineChatGenerator::RewindTo(int /*token_count*/) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, + "Engine request rewind is unavailable; recreate the request from retained conversation history"); +} + +std::optional OnnxEngineChatGenerator::GetTurnUsage() const { + const auto result = engine_.GetTurnResult(conversation_); + return ChatTurnUsage{ + static_cast(result.prompt_tokens + result.cached_prompt_tokens), + static_cast(result.generated_tokens), + }; +} + +std::unique_ptr OnnxEngineChatGenerator::Create( + const std::vector& messages, + const SearchOptions& options, + GenAIModelInstance& model, + const ToolCallContext& tool_ctx) { + if (messages.empty()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "messages must not be empty"); + } + + auto* engine = model.GetChatEngine(); + if (!engine) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "model does not own a chat Engine"); + } + + auto prompt = BuildChatPrompt(messages, model, tool_ctx.tools_json); + auto sequences = EncodePrompt(prompt, model); + const int prompt_token_count = static_cast(sequences->SequenceCount(0)); + auto conversation = engine->CreateConversation(options, tool_ctx, prompt_token_count); + const auto* data = sequences->SequenceData(0); + engine->BeginTurn(conversation, std::span(data, static_cast(prompt_token_count)), + ResolveMaxOutputTokens(options)); + + return std::unique_ptr( + new OnnxEngineChatGenerator(*engine, std::move(conversation), + model.GetPreprocessor().CreateTokenizerStream(), + model.GetPreprocessor().CreateSpecialTokenizerStream(), model, + prompt_token_count)); +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/onnx_engine_chat_generator.h b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_engine_chat_generator.h new file mode 100644 index 000000000..3ad56228c --- /dev/null +++ b/sdk_v2/cpp/src/inferencing/generative/chat/onnx_engine_chat_generator.h @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "inferencing/generative/chat/chat_generator.h" +#include "inferencing/generative/chat/onnx_chat_engine.h" +#include "inferencing/generative/chat/search_options.h" +#include "inferencing/generative/toolcalling/tool_call_context.h" + +#include +#include +#include + +struct OgaTokenizerStream; + +namespace fl { + +class GenAIModelInstance; + +/// ChatGenerator adapter for a conversation scheduled by a model-owned ORT GenAI Engine. +class OnnxEngineChatGenerator final : public ChatGenerator { + public: + ~OnnxEngineChatGenerator() override; + + bool IsDone() const override; + void GenerateNextToken() override; + std::string Decode() override; + int TokenCount() const override; + int PromptTokenCount() const override; + void Cancel() override; + int AppendMessages(const std::vector& new_messages, + GenAIModelInstance& model, + const std::string& tools_json, + const SearchOptions& options) override; + bool CanRewind() const override { return false; } + void RewindTo(int token_count) override; + std::optional GetTurnUsage() const override; + + static std::unique_ptr Create( + const std::vector& messages, + const SearchOptions& options, + GenAIModelInstance& model, + const ToolCallContext& tool_ctx); + + private: + OnnxEngineChatGenerator(OnnxChatEngine& engine, + std::shared_ptr conversation, + std::unique_ptr stream, + std::unique_ptr stream_with_special, + GenAIModelInstance& model, + int prompt_token_count); + + OnnxChatEngine& engine_; + std::shared_ptr conversation_; + std::unique_ptr stream_; + std::unique_ptr stream_with_special_; + GenAIModelInstance& model_; + int prompt_token_count_ = 0; + std::optional current_token_; + std::atomic cancelled_{false}; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/search_options.cc b/sdk_v2/cpp/src/inferencing/generative/chat/search_options.cc index 4ae6df6a8..66c2653a9 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/search_options.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/search_options.cc @@ -2,6 +2,8 @@ // Licensed under the MIT License. #include "inferencing/generative/chat/search_options.h" #include "exception.h" +#include "inferencing/generative/toolcalling/grammar.h" +#include "inferencing/generative/toolcalling/tool_call_context.h" #include #include @@ -10,6 +12,44 @@ namespace fl { +int ResolveMaxOutputTokens(const SearchOptions& options, int default_max_output_tokens) { + const int max_output = options.max_output_tokens.value_or(default_max_output_tokens); + if (max_output < 1) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "max_output_tokens must be >= 1"); + } + + return max_output; +} + +void ApplyGuidanceOptions(const ToolCallContext& tool_ctx, OgaGeneratorParams& gen_params) { + std::string guidance_type; + std::string guidance_data; + + if (!tool_ctx.guidance_type.empty() && !tool_ctx.guidance_data.empty()) { + guidance_type = tool_ctx.guidance_type; + guidance_data = tool_ctx.guidance_data; + } else { + std::string json_schema; + if (tool_ctx.HasTools()) { + json_schema = BuildToolJsonSchema(tool_ctx); + } + + guidance_data = BuildLarkGrammar(tool_ctx, json_schema); + if (!guidance_data.empty()) { + guidance_type = "lark_grammar"; + } + } + + const bool tool_call_only = tool_ctx.tool_output && !tool_ctx.text_output; + if (!guidance_type.empty() && !guidance_data.empty() && tool_call_only) { + try { + gen_params.SetGuidance(guidance_type.c_str(), guidance_data.c_str()); + } catch (const std::runtime_error&) { + // Some model/runtime combinations do not implement guidance. Preserve the existing unguided behavior. + } + } +} + int ApplySearchOptions(const SearchOptions& options, int input_token_count, const GenAIConfig& config, @@ -31,10 +71,7 @@ int ApplySearchOptions(const SearchOptions& options, // The catalog's maxOutputTokens is informational metadata only and is intentionally NOT used to clamp generation: // it is commonly a conservative 2048 that would wrongly cap larger contexts (e.g. the 3072 vision default). A // user-supplied max_output_tokens is honored as-is and only rejected if input+output exceeds max_length below. - int max_output = options.max_output_tokens.value_or(default_max_output_tokens); - if (max_output < 1) { - FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "max_output_tokens must be >= 1"); - } + const int max_output = ResolveMaxOutputTokens(options, default_max_output_tokens); // Validate token budget: input + output must not exceed model's max_length int total_required = input_token_count + max_output; @@ -106,14 +143,15 @@ int ApplySearchOptions(const SearchOptions& options, // Preserve a positive model setting. ORT GenAI reports both an absent setting and explicit zero as zero; Foundry // Local intentionally treats both as unset. ORT GenAI decides whether the model consumes the resulting option. - if (gen_params.GetSearchNumber("chunk_size") <= 0) { + if (config.GetChatBackendKind() != ChatBackendKind::kStaticEngine && + gen_params.GetSearchNumber("chunk_size") <= 0) { // The model's resolved EP is kDefault for the common load path, so use the provider declared in // genai_config.json. An empty provider means ORT's CPU fallback. ExecutionProvider effective_ep = ep; if (effective_ep == ExecutionProvider::kDefault) { std::string config_provider = config.DefaultProvider(); effective_ep = config_provider.empty() ? ExecutionProvider::kCPU - : EPUtils::StringtoEP(config_provider); + : EPUtils::StringtoEP(config_provider); } constexpr double kDefaultChunkSize = 2048.0; @@ -200,4 +238,11 @@ std::optional SearchOptions::ParseToolChoice(const KeyValuePairs& "Invalid value for tool_choice: '" + value + "'. Expected 'auto', 'none', or 'required'."); } +bool SearchOptions::HasSameRetainedGenerationSettings(const SearchOptions& other) const { + return temperature == other.temperature && top_p == other.top_p && top_k == other.top_k && + frequency_penalty == other.frequency_penalty && presence_penalty == other.presence_penalty && + seed == other.seed && do_sample == other.do_sample && early_stopping == other.early_stopping && + extra == other.extra; +} + } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/search_options.h b/sdk_v2/cpp/src/inferencing/generative/chat/search_options.h index 7bc162c8a..231514aeb 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/search_options.h +++ b/sdk_v2/cpp/src/inferencing/generative/chat/search_options.h @@ -17,6 +17,8 @@ struct OgaGeneratorParams; namespace fl { +struct ToolCallContext; + /// Parameters extracted from a request that map to ORT GenAI search options. /// Decoupled from any specific request type so both C API and C++ API can use it. struct SearchOptions { @@ -47,8 +49,14 @@ struct SearchOptions { /// Returns std::nullopt when the key is absent. Throws fl::Exception when present /// with a value other than "auto", "none", or "required". static std::optional ParseToolChoice(const KeyValuePairs& params); + + /// Whether settings baked into retained generator/request state match another turn. + bool HasSameRetainedGenerationSettings(const SearchOptions& other) const; }; +/// Return the explicit or default output-token limit for a text generation turn. +int ResolveMaxOutputTokens(const SearchOptions& options, int default_max_output_tokens = 2048); + /// Apply search options to OgaGeneratorParams. /// Validates token budget (input + output vs model max_length from config). /// Returns the computed max_length that was set on the params. @@ -75,4 +83,7 @@ int ApplySearchOptions(const SearchOptions& options, bool use_full_context = false, int default_max_output_tokens = 2048); +/// Applies request-level grammar guidance to generator parameters when the tool context requires tool-only output. +void ApplyGuidanceOptions(const ToolCallContext& tool_ctx, OgaGeneratorParams& gen_params); + } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_config.cc b/sdk_v2/cpp/src/inferencing/generative/genai_config.cc index 11d34d05a..379b06839 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_config.cc +++ b/sdk_v2/cpp/src/inferencing/generative/genai_config.cc @@ -4,9 +4,33 @@ #include "exception.h" #include +#include #include namespace fl { +namespace { + +size_t ParsePositiveSize(const nlohmann::json& object, const char* name, size_t default_value) { + if (!object.contains(name)) { + return default_value; + } + + const auto& value = object[name]; + if (!value.is_number_integer()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + std::string("genai_config.json engine.") + name + " must be a positive integer"); + } + + const auto parsed = value.get(); + if (parsed <= 0 || static_cast(parsed) > std::numeric_limits::max()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + std::string("genai_config.json engine.") + name + " must be a positive integer"); + } + + return static_cast(parsed); +} + +} // namespace bool GenAIConfig::OnnxModel::IsMultiModal() const { return type == "phi3v" || type == "whisper" || type == "phi4mm" || type == "fara" || @@ -33,6 +57,35 @@ std::string GenAIConfig::DefaultProvider() const { return first.begin()->first; } +ChatBackendKind GenAIConfig::GetChatBackendKind() const { + if (!engine) { + return ChatBackendKind::kGenerator; + } + + if (engine->dynamic_batching) { + return ChatBackendKind::kDynamicEngine; + } + + if (engine->static_batching) { + return ChatBackendKind::kStaticEngine; + } + + return ChatBackendKind::kGenerator; +} + +std::optional GenAIConfig::EngineMaxBatchSize() const { + switch (GetChatBackendKind()) { + case ChatBackendKind::kDynamicEngine: + return engine->dynamic_batching->max_batch_size; + case ChatBackendKind::kStaticEngine: + return engine->static_batching->max_batch_size; + case ChatBackendKind::kGenerator: + return std::nullopt; + } + + return std::nullopt; +} + GenAIConfig GenAIConfig::LoadFromFile(const std::string& path) { std::ifstream file(path); if (!file.is_open()) { @@ -113,6 +166,46 @@ GenAIConfig GenAIConfig::LoadFromFile(const std::string& path) { config.search = std::move(search); } + if (j.contains("engine") && j["engine"].is_object()) { + const auto& je = j["engine"]; + Engine engine; + + if (je.contains("dynamic_batching") && !je["dynamic_batching"].is_null()) { + if (!je["dynamic_batching"].is_object()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + "genai_config.json engine.dynamic_batching must be an object"); + } + + const auto& batching = je["dynamic_batching"]; + Engine::DynamicBatching dynamic_batching; + dynamic_batching.max_batch_size = + ParsePositiveSize(batching, "max_batch_size", dynamic_batching.max_batch_size); + dynamic_batching.max_scheduled_tokens = + ParsePositiveSize(batching, "max_scheduled_tokens", dynamic_batching.max_scheduled_tokens); + engine.dynamic_batching = dynamic_batching; + } + + if (je.contains("static_batching") && !je["static_batching"].is_null()) { + if (!je["static_batching"].is_object()) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + "genai_config.json engine.static_batching must be an object"); + } + + const auto& batching = je["static_batching"]; + Engine::StaticBatching static_batching; + static_batching.max_batch_size = + ParsePositiveSize(batching, "max_batch_size", static_batching.max_batch_size); + engine.static_batching = static_batching; + } + + if (engine.dynamic_batching && engine.static_batching) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, + "genai_config.json cannot declare both engine.dynamic_batching and engine.static_batching"); + } + + config.engine = std::move(engine); + } + // hidden_size can appear at the top level or inside model if (j.contains("model") && j["model"].is_object()) { const auto& jm = j["model"]; diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_config.h b/sdk_v2/cpp/src/inferencing/generative/genai_config.h index b7e70dc3d..6a25d986a 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_config.h +++ b/sdk_v2/cpp/src/inferencing/generative/genai_config.h @@ -3,12 +3,19 @@ #pragma once #include +#include #include #include #include namespace fl { +enum class ChatBackendKind { + kGenerator, + kStaticEngine, + kDynamicEngine, +}; + /// Represents the parsed contents of a genai_config.json file. /// Maps the C# GenAIConfig / OnnxModel / OnnxDecoder types. struct GenAIConfig { @@ -36,14 +43,35 @@ struct GenAIConfig { int max_length = 0; }; + struct Engine { + struct DynamicBatching { + size_t max_batch_size = 16; + size_t max_scheduled_tokens = 2048; + }; + + struct StaticBatching { + size_t max_batch_size = 4; + }; + + std::optional dynamic_batching; + std::optional static_batching; + }; + std::optional model; std::optional search; + std::optional engine; std::optional hidden_size; // embedding dimension from genai_config.json /// Returns the first provider key from decoder.session_options.provider_options, /// or empty string if not found. std::string DefaultProvider() const; + /// Selects the chat inference backend declared by the model artifact. + ChatBackendKind GetChatBackendKind() const; + + /// Returns the configured Engine batch capacity, or nullopt for Generator models. + std::optional EngineMaxBatchSize() const; + /// Load and parse a genai_config.json file. Throws fl::Exception on failure. static GenAIConfig LoadFromFile(const std::string& path); }; diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc index 1ee701032..5bb6f832b 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc +++ b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc @@ -3,6 +3,7 @@ #include "inferencing/generative/genai_model_instance.h" #include "exception.h" #include "inferencing/execution_provider.h" +#include "inferencing/generative/chat/onnx_chat_engine.h" #include "utils.h" #include @@ -68,11 +69,25 @@ GenAIModelInstance::GenAIModelInstance(std::string model_id, FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, "failed to create preprocessor for model ", model_id_, ": ", e.what()); } + + if (IsMultiModal() && genai_config_.GetChatBackendKind() != ChatBackendKind::kGenerator) { + FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, + "model ", model_id_, " declares an Engine backend, but Engine is not supported for multimodal models"); + } + + if (genai_config_.GetChatBackendKind() != ChatBackendKind::kGenerator) { + try { + chat_engine_ = std::make_unique(*this); + } catch (const std::runtime_error& e) { + FL_LOG_AND_THROW(logger, FOUNDRY_LOCAL_ERROR_INTERNAL, + "failed to create chat engine for model ", model_id_, ": ", e.what()); + } + } } // Destructor: unique_ptr members are destroyed in reverse declaration order. // OGA objects have custom operator delete that calls OgaDestroy* functions. -// Destruction order: preprocessor → oga_model (correct: dependents first). +// Destruction order: chat engine → preprocessor → OGA model (correct: dependents first). GenAIModelInstance::~GenAIModelInstance() = default; // --------------------------------------------------------------------------- diff --git a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h index 0893dc7ba..74d9f096d 100644 --- a/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h +++ b/sdk_v2/cpp/src/inferencing/generative/genai_model_instance.h @@ -17,6 +17,8 @@ struct OgaModel; namespace fl { +class OnnxChatEngine; + /// A model that has been loaded into the ORT GenAI runtime. /// Owns the OgaModel and its preprocessing resources. /// Non-copyable, non-movable. Owned by ModelLoadManager via std::unique_ptr. @@ -37,6 +39,7 @@ class GenAIModelInstance { /// Access the underlying OGA objects. OgaModel& GetOgaModel(); Preprocessor& GetPreprocessor(); + OnnxChatEngine* GetChatEngine() { return chat_engine_.get(); } /// Get the last-activity timestamp. std::chrono::steady_clock::time_point LastActivity() const { return last_activity_; } @@ -63,6 +66,7 @@ class GenAIModelInstance { ExecutionProvider ep_; std::unique_ptr oga_model_; std::unique_ptr preprocessor_; + std::unique_ptr chat_engine_; std::chrono::steady_clock::time_point last_activity_; mutable std::atomic session_ref_count_{0}; }; diff --git a/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc b/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc index 281f8198a..7448c9e0e 100644 --- a/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc +++ b/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc @@ -21,6 +21,7 @@ #include +#include #include #include #include @@ -144,6 +145,28 @@ TEST_F(ChatSessionTest, RunBasic) { EXPECT_EQ(session.GetHistory()[1].GetSimpleText(), text); } +TEST_F(ChatSessionTest, ConcurrentIndependentSessions) { + auto run_request = [this](std::string prompt) { + ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + Request request; + request.AddOwnedItem(MakeMessage(FOUNDRY_LOCAL_ROLE_USER, prompt)); + request.options.Add("max_output_tokens", "32"); + request.options.Add("temperature", "0"); + + Response response; + session.ProcessRequest(request, response); + return GetAssistantText(response); + }; + + auto first = std::async(std::launch::async, run_request, "What is 2+2? Answer with just the number."); + auto second = std::async(std::launch::async, run_request, "What is 3+3? Answer with just the number."); + + const auto first_text = first.get(); + const auto second_text = second.get(); + EXPECT_NE(first_text.find("4"), std::string::npos) << first_text; + EXPECT_NE(second_text.find("6"), std::string::npos) << second_text; +} + TEST_F(ChatSessionTest, ChatCompletionRejectsAudioInput) { ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); diff --git a/sdk_v2/cpp/test/internal_api/chat/chat_template_test.cc b/sdk_v2/cpp/test/internal_api/chat/chat_template_test.cc index db9bba8b9..442341ed3 100644 --- a/sdk_v2/cpp/test/internal_api/chat/chat_template_test.cc +++ b/sdk_v2/cpp/test/internal_api/chat/chat_template_test.cc @@ -115,6 +115,17 @@ TEST_F(ChatTemplateTest, PromptEndsWithAssistantPrefix) { << "Prompt should end with assistant prefix for generation. Got: " << prompt; } +TEST_F(ChatTemplateTest, EngineContinuationIncludesAssistantTurnBoundary) { + std::vector messages = {{FOUNDRY_LOCAL_ROLE_USER, "What is the codeword?"}}; + + std::string prompt = BuildChatContinuationPrompt(messages, GetModel()); + + EXPECT_EQ(prompt.find("__foundry_engine_assistant_boundary__"), std::string::npos); + EXPECT_NE(prompt.find("<|im_end|>"), std::string::npos) << prompt; + EXPECT_NE(prompt.find("What is the codeword?"), std::string::npos) << prompt; + EXPECT_NE(prompt.find("assistant"), std::string::npos) << prompt; +} + // --------------------------------------------------------------------------- // EncodePrompt tests // --------------------------------------------------------------------------- diff --git a/sdk_v2/cpp/test/internal_api/chat/search_options_test.cc b/sdk_v2/cpp/test/internal_api/chat/search_options_test.cc index eb6ee5a32..3ae70c7b4 100644 --- a/sdk_v2/cpp/test/internal_api/chat/search_options_test.cc +++ b/sdk_v2/cpp/test/internal_api/chat/search_options_test.cc @@ -30,6 +30,30 @@ TEST(SearchOptionsParsingTest, TemperatureOutsideSupportedRangeThrows) { } } +TEST(SearchOptionsParsingTest, ResolvesDefaultAndExplicitOutputLimits) { + SearchOptions defaults; + EXPECT_EQ(ResolveMaxOutputTokens(defaults), 2048); + + SearchOptions explicit_limit; + explicit_limit.max_output_tokens = 64; + EXPECT_EQ(ResolveMaxOutputTokens(explicit_limit), 64); +} + +TEST(SearchOptionsParsingTest, RetainedGenerationSettingsIgnorePerTurnOptions) { + SearchOptions first; + first.temperature = 0.5f; + first.max_output_tokens = 16; + first.tool_choice = FOUNDRY_LOCAL_TOOL_CHOICE_AUTO; + + SearchOptions second = first; + second.max_output_tokens = 64; + second.tool_choice = FOUNDRY_LOCAL_TOOL_CHOICE_REQUIRED; + EXPECT_TRUE(first.HasSameRetainedGenerationSettings(second)); + + second.temperature = 1.0f; + EXPECT_FALSE(first.HasSameRetainedGenerationSettings(second)); +} + // --------------------------------------------------------------------------- // Test fixture: loads the shared test model once per suite // --------------------------------------------------------------------------- diff --git a/sdk_v2/cpp/test/internal_api/genai_config_test.cc b/sdk_v2/cpp/test/internal_api/genai_config_test.cc index fd420920e..74249a3a9 100644 --- a/sdk_v2/cpp/test/internal_api/genai_config_test.cc +++ b/sdk_v2/cpp/test/internal_api/genai_config_test.cc @@ -199,6 +199,89 @@ TEST_F(GenAIConfigTest, LoadMissingOptionalFields) { EXPECT_FALSE(config.model->decoder.has_value()); } +TEST_F(GenAIConfigTest, SelectsGeneratorWhenEngineBatchingIsAbsent) { + auto path = WriteFile("genai_config.json", R"({"engine": {}})"); + + auto config = GenAIConfig::LoadFromFile(path); + + EXPECT_EQ(config.GetChatBackendKind(), ChatBackendKind::kGenerator); + EXPECT_FALSE(config.EngineMaxBatchSize().has_value()); +} + +TEST_F(GenAIConfigTest, ParsesDynamicEngineConfiguration) { + auto path = WriteFile("genai_config.json", R"({ + "engine": { + "dynamic_batching": { + "max_batch_size": 8, + "max_scheduled_tokens": 1024 + } + } + })"); + + auto config = GenAIConfig::LoadFromFile(path); + + ASSERT_TRUE(config.engine.has_value()); + ASSERT_TRUE(config.engine->dynamic_batching.has_value()); + EXPECT_EQ(config.engine->dynamic_batching->max_batch_size, 8u); + EXPECT_EQ(config.engine->dynamic_batching->max_scheduled_tokens, 1024u); + EXPECT_EQ(config.GetChatBackendKind(), ChatBackendKind::kDynamicEngine); + EXPECT_EQ(config.EngineMaxBatchSize(), 8u); +} + +TEST_F(GenAIConfigTest, ParsesStaticEngineConfiguration) { + auto path = WriteFile("genai_config.json", R"({ + "engine": { + "static_batching": { + "max_batch_size": 2 + } + } + })"); + + auto config = GenAIConfig::LoadFromFile(path); + + ASSERT_TRUE(config.engine.has_value()); + ASSERT_TRUE(config.engine->static_batching.has_value()); + EXPECT_EQ(config.engine->static_batching->max_batch_size, 2u); + EXPECT_EQ(config.GetChatBackendKind(), ChatBackendKind::kStaticEngine); + EXPECT_EQ(config.EngineMaxBatchSize(), 2u); +} + +TEST_F(GenAIConfigTest, AppliesEngineDefaults) { + auto dynamic_path = WriteFile("dynamic.json", R"({"engine": {"dynamic_batching": {}}})"); + auto static_path = WriteFile("static.json", R"({"engine": {"static_batching": {}}})"); + + auto dynamic_config = GenAIConfig::LoadFromFile(dynamic_path); + auto static_config = GenAIConfig::LoadFromFile(static_path); + + EXPECT_EQ(dynamic_config.engine->dynamic_batching->max_batch_size, 16u); + EXPECT_EQ(dynamic_config.engine->dynamic_batching->max_scheduled_tokens, 2048u); + EXPECT_EQ(static_config.engine->static_batching->max_batch_size, 4u); +} + +TEST_F(GenAIConfigTest, RejectsBothEngineBatchingModes) { + auto path = WriteFile("genai_config.json", R"({ + "engine": { + "dynamic_batching": {}, + "static_batching": {} + } + })"); + + EXPECT_THROW(GenAIConfig::LoadFromFile(path), fl::Exception); +} + +TEST_F(GenAIConfigTest, RejectsInvalidEngineCapacity) { + auto zero_path = + WriteFile("zero.json", R"({"engine": {"dynamic_batching": {"max_batch_size": 0}}})"); + auto negative_path = + WriteFile("negative.json", R"({"engine": {"static_batching": {"max_batch_size": -1}}})"); + auto wrong_type_path = + WriteFile("wrong_type.json", R"({"engine": {"dynamic_batching": {"max_scheduled_tokens": "bad"}}})"); + + EXPECT_THROW(GenAIConfig::LoadFromFile(zero_path), fl::Exception); + EXPECT_THROW(GenAIConfig::LoadFromFile(negative_path), fl::Exception); + EXPECT_THROW(GenAIConfig::LoadFromFile(wrong_type_path), fl::Exception); +} + TEST_F(GenAIConfigTest, LoadThrowsForMissingFile) { EXPECT_THROW(GenAIConfig::LoadFromFile("/nonexistent/path/genai_config.json"), fl::Exception); diff --git a/sdk_v2/cpp/test/test_main.cc b/sdk_v2/cpp/test/test_main.cc index 2878ee757..9d70df882 100644 --- a/sdk_v2/cpp/test/test_main.cc +++ b/sdk_v2/cpp/test/test_main.cc @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - #include +#include +#include +#if __has_include() +#include +#define FOUNDRY_LOCAL_TEST_HAS_OGA 1 +#endif #include @@ -13,5 +18,9 @@ int main(int argc, char** argv) { #endif ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); + const int result = RUN_ALL_TESTS(); +#ifdef FOUNDRY_LOCAL_TEST_HAS_OGA + OgaShutdown(); +#endif + return result; }