Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7cdfc14
add initial tool and reasoning tag migration
Jun 11, 2026
5a053fe
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jun 23, 2026
476e71e
consolidate APIs
Jun 24, 2026
5d13270
Rename GetGenerationTag -> GetTag per review feedback from Scott
Jun 26, 2026
4da60c4
Clean up temp directory before test to avoid leftover collisions
Jun 29, 2026
e1df10c
Refactor: store tag token IDs in model section, expose GetTagId API
Jul 1, 2026
388b009
Merge origin/main into sayanshaw/tool-tags
Jul 1, 2026
f499725
Refactor: move tag IDs to Tokenizer, rename to bot/eot/bor/eor
Jul 8, 2026
43a695d
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jul 8, 2026
6276e87
Address review: simplify fallback, add C#/Java/ObjC/Python bindings
Jul 15, 2026
2260ede
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jul 15, 2026
1cb59fb
Fix clang-format in config.cpp switch/case block from main (unrelated)
Jul 15, 2026
e418c62
Fix phi3 fallback: use <|tool_call|>/<|/tool_call|> (with pipes)
Jul 15, 2026
03bce7b
Add reasoning fallback entries to qwen2 (covers DeepSeek think tags)
Jul 15, 2026
4270829
Use std::optional for tag IDs, extract fallback to tokenizer_tag_util…
Jul 22, 2026
4585871
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jul 22, 2026
c7f6ddf
Update comments: getters throw on unsupported, not return -1
Jul 23, 2026
8933b44
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jul 23, 2026
90feec9
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jul 24, 2026
b1a5aff
Standardize fallback map keys to bot/eot/bor/eor
Jul 24, 2026
b9b8b83
use fallback IDs, remove TokenToTokenId call, add Config::Defaults co…
Jul 24, 2026
26086e6
Merge branch 'main' of https://github.com/microsoft/onnxruntime-genai…
Jul 27, 2026
46677b9
Use Config::Defaults constants in fallback map keys for consistency
Jul 27, 2026
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
8 changes: 8 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,14 @@ struct Model_Element : JSON::Element {
v_.left_context_samples = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "right_context_samples") {
v_.right_context_samples = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "bot_token_id") {
v_.bot_token_id = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "eot_token_id") {
v_.eot_token_id = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "bor_token_id") {
v_.bor_token_id = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "eor_token_id") {
v_.eor_token_id = SafeDoubleToInt(JSON::Get<double>(value), name);
} else {
throw JSON::unknown_value_error{};
}
Expand Down
10 changes: 10 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ struct Config {
int video_token_id{};
int vision_start_token_id{};

// Tool-calling and reasoning token IDs.
// Follows the bos/eos/pad naming convention:
// bot = beginning of tool (call), eot = end of tool (call)
// bor = beginning of reasoning, eor = end of reasoning
// -1 means the model does not define this token.
int bot_token_id{-1};
int eot_token_id{-1};
int bor_token_id{-1};
int eor_token_id{-1};
Comment thread
sayanshaw24 marked this conversation as resolved.
Outdated

int vocab_size{};
int context_length{};

Expand Down
51 changes: 50 additions & 1 deletion src/models/model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
#include <algorithm>
#include <array>
#include <climits>
#include <functional>
#include <random>
#include <set>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>

#include "../generators.h"
#include "../search.h"
Expand Down Expand Up @@ -297,16 +299,63 @@ const std::string& TokenizerStream::Decode(int32_t token) {
return chunk_;
}

namespace {
Comment thread
sayanshaw24 marked this conversation as resolved.
Outdated

// Fallback token strings for models whose genai_config.json doesn't yet include
// bot/eot/bor/eor token IDs in the model section. This exists specifically for
// Foundry Local backward compatibility with older model packages that predate
// these config fields.
// Keyed by model.type string from genai_config.json.
// Inner map: tag_name -> token string (used for vocab lookup to get the ID).
const std::string* GetFallbackTag(const std::string& model_type, const std::string& tag_name) {
static const std::unordered_map<std::string, std::unordered_map<std::string, std::string>> fallback_map = {
{"qwen2", {{"tool_call_start", "<tool_call>"}, {"tool_call_end", "</tool_call>"}}},
{"qwen3", {{"tool_call_start", "<tool_call>"}, {"tool_call_end", "</tool_call>"}, {"reasoning_start", "<think>"}, {"reasoning_end", "</think>"}}},
{"phi3", {{"tool_call_start", "<tool_call>"}, {"tool_call_end", "</tool_call>"}}},
{"gptoss", {{"tool_call_start", "<|start|>"}, {"tool_call_end", "<|call|>"}}},
};
auto type_it = fallback_map.find(model_type);
if (type_it == fallback_map.end()) return nullptr;
auto tag_it = type_it->second.find(tag_name);
if (tag_it == type_it->second.end()) return nullptr;
return &tag_it->second;
}

} // namespace

Tokenizer::Tokenizer(Config& config) : bos_token_id_{config.model.bos_token_id},
eos_token_id_{config.model.eos_token_id},
pad_token_id_{config.model.pad_token_id} {
pad_token_id_{config.model.pad_token_id},
bot_token_id_{config.model.bot_token_id},
eot_token_id_{config.model.eot_token_id},
bor_token_id_{config.model.bor_token_id},
eor_token_id_{config.model.eor_token_id} {
// Default tokenizer options
const char* keys[] = {"add_special_tokens", "skip_special_tokens"};
const char* values[] = {"false", "true"};

// Resolve tokenizer_dir (may be empty, relative, absolute, or "package:"-scheme).
const fs::path tokenizer_dir = config.ResolvePath(config.model.tokenizer_dir);
CheckResult(OrtxCreateTokenizerWithOptions(tokenizer_.Address(), tokenizer_dir.string().c_str(), keys, values, 2));

// Fallback: if bot/eot/bor/eor were not explicitly set in genai_config.json (-1),
// attempt to resolve them by encoding well-known token strings for the model type.
// This provides backward compatibility for Foundry Local when consuming older model
// packages that predate the bot/eot/bor/eor config fields.
if (bot_token_id_ < 0 || eot_token_id_ < 0 || bor_token_id_ < 0 || eor_token_id_ < 0) {
auto try_fallback = [&](int32_t& id, const std::string& tag_name) {
Comment thread
sayanshaw24 marked this conversation as resolved.
Outdated
if (id >= 0) return;
const auto* fallback_str = GetFallbackTag(config.model.type, tag_name);
if (fallback_str && !fallback_str->empty()) {
int32_t resolved = TokenToTokenId(fallback_str->c_str());
if (resolved >= 0) id = resolved;
}
};
try_fallback(bot_token_id_, "tool_call_start");
try_fallback(eot_token_id_, "tool_call_end");
try_fallback(bor_token_id_, "reasoning_start");
try_fallback(eor_token_id_, "reasoning_end");
}
}

std::unique_ptr<TokenizerStream> Tokenizer::CreateStream() const {
Expand Down
14 changes: 14 additions & 0 deletions src/models/model.h
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,26 @@ struct Tokenizer : std::enable_shared_from_this<Tokenizer>, LeakChecked<Tokenize
const std::vector<int32_t>& GetEosTokenIds() const { return eos_token_id_; }
int32_t GetPadTokenId() const { return pad_token_id_; }

// Tool-calling and reasoning token IDs.
// Naming follows the bos/eos/pad convention:
// bot = beginning of tool (call), eot = end of tool (call)
// bor = beginning of reasoning, eor = end of reasoning
// Returns -1 if the model does not define the token.
int32_t GetBotTokenId() const { return bot_token_id_; }
int32_t GetEotTokenId() const { return eot_token_id_; }
int32_t GetBorTokenId() const { return bor_token_id_; }
int32_t GetEorTokenId() const { return eor_token_id_; }

OrtxPtr<OrtxTokenizer> tokenizer_;

private:
int32_t bos_token_id_;
std::vector<int32_t> eos_token_id_;
int32_t pad_token_id_;
int32_t bot_token_id_;
int32_t eot_token_id_;
int32_t bor_token_id_;
int32_t eor_token_id_;
};

struct MultiModalProcessor : std::enable_shared_from_this<MultiModalProcessor>, ExternalRefCounted<MultiModalProcessor> {
Expand Down
26 changes: 26 additions & 0 deletions src/ort_genai.h
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,32 @@ struct OgaTokenizer : OgaAbstract {
return token_id;
}

// Tool-calling and reasoning token IDs (bot/eot/bor/eor).
// Returns -1 if the model does not define the token.
Comment thread
sayanshaw24 marked this conversation as resolved.
Outdated
int32_t GetBotTokenId() const {
int32_t token_id;
OgaCheckResult(OgaTokenizerGetBotTokenId(this, &token_id));
return token_id;
}

int32_t GetEotTokenId() const {
int32_t token_id;
OgaCheckResult(OgaTokenizerGetEotTokenId(this, &token_id));
return token_id;
}

int32_t GetBorTokenId() const {
int32_t token_id;
OgaCheckResult(OgaTokenizerGetBorTokenId(this, &token_id));
return token_id;
}

int32_t GetEorTokenId() const {
int32_t token_id;
OgaCheckResult(OgaTokenizerGetEorTokenId(this, &token_id));
return token_id;
}

void Encode(const char* str, OgaSequences& sequences) const {
OgaCheckResult(OgaTokenizerEncode(this, str, &sequences));
}
Expand Down
28 changes: 28 additions & 0 deletions src/ort_genai_c.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,34 @@ OgaResult* OGA_API_CALL OgaTokenizerGetPadTokenId(const OgaTokenizer* tokenizer,
OGA_CATCH
}

OgaResult* OGA_API_CALL OgaTokenizerGetBotTokenId(const OgaTokenizer* tokenizer, int32_t* out) {
Comment thread
sayanshaw24 marked this conversation as resolved.
OGA_TRY
*out = tokenizer->GetBotTokenId();
return nullptr;
OGA_CATCH
}

OgaResult* OGA_API_CALL OgaTokenizerGetEotTokenId(const OgaTokenizer* tokenizer, int32_t* out) {
OGA_TRY
*out = tokenizer->GetEotTokenId();
return nullptr;
OGA_CATCH
}

OgaResult* OGA_API_CALL OgaTokenizerGetBorTokenId(const OgaTokenizer* tokenizer, int32_t* out) {
OGA_TRY
*out = tokenizer->GetBorTokenId();
return nullptr;
OGA_CATCH
}

OgaResult* OGA_API_CALL OgaTokenizerGetEorTokenId(const OgaTokenizer* tokenizer, int32_t* out) {
OGA_TRY
*out = tokenizer->GetEorTokenId();
return nullptr;
OGA_CATCH
}

OgaResult* OGA_API_CALL OgaTokenizerEncode(const OgaTokenizer* tokenizer, const char* str, OgaSequences* sequences) {
OGA_TRY
sequences->emplace_back(tokenizer->Encode(str));
Expand Down
32 changes: 32 additions & 0 deletions src/ort_genai_c.h
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,38 @@ OGA_EXPORT OgaResult* OGA_API_CALL OgaTokenizerGetEosTokenIds(const OgaTokenizer
*/
OGA_EXPORT OgaResult* OGA_API_CALL OgaTokenizerGetPadTokenId(const OgaTokenizer* tokenizer, int32_t* token_id);

/**
* \brief Return the BOT (beginning of tool call) token id, or -1 if the model does not define one.
* \param[in] tokenizer The tokenizer to read from
* \param[out] token_id The BOT token id
* \return OgaResult containing the error message if the call fails.
*/
OGA_EXPORT OgaResult* OGA_API_CALL OgaTokenizerGetBotTokenId(const OgaTokenizer* tokenizer, int32_t* token_id);

/**
* \brief Return the EOT (end of tool call) token id, or -1 if the model does not define one.
* \param[in] tokenizer The tokenizer to read from
* \param[out] token_id The EOT token id
* \return OgaResult containing the error message if the call fails.
*/
OGA_EXPORT OgaResult* OGA_API_CALL OgaTokenizerGetEotTokenId(const OgaTokenizer* tokenizer, int32_t* token_id);

/**
* \brief Return the BOR (beginning of reasoning) token id, or -1 if the model does not define one.
* \param[in] tokenizer The tokenizer to read from
* \param[out] token_id The BOR token id
* \return OgaResult containing the error message if the call fails.
*/
OGA_EXPORT OgaResult* OGA_API_CALL OgaTokenizerGetBorTokenId(const OgaTokenizer* tokenizer, int32_t* token_id);

/**
* \brief Return the EOR (end of reasoning) token id, or -1 if the model does not define one.
Comment thread
sayanshaw24 marked this conversation as resolved.
Outdated
* \param[in] tokenizer The tokenizer to read from
* \param[out] token_id The EOR token id
* \return OgaResult containing the error message if the call fails.
*/
OGA_EXPORT OgaResult* OGA_API_CALL OgaTokenizerGetEorTokenId(const OgaTokenizer* tokenizer, int32_t* token_id);

/**
* Encodes a single string and adds the encoded sequence of tokens to the OgaSequences. The OgaSequences must be freed with OgaDestroySequences
* when it is no longer needed.
Expand Down
68 changes: 68 additions & 0 deletions test/c_api_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1817,6 +1817,74 @@ TEST(CAPITests, ParakeetTdtTranscribeLong) {
EXPECT_FALSE(transcription.empty());
}

// Test that bot/eot/bor/eor return -1 for models without these tokens configured
TEST(CAPITests, TagId_Unknown) {
Comment thread
sayanshaw24 marked this conversation as resolved.
Outdated
// tiny-random-gpt2 model has type "gpt2" which is NOT in the fallback map → -1
auto model = OgaModel::Create(MODEL_PATH "hf-internal-testing/tiny-random-gpt2-fp32");
auto tokenizer = OgaTokenizer::Create(*model);

EXPECT_EQ(tokenizer->GetBotTokenId(), -1);
EXPECT_EQ(tokenizer->GetEotTokenId(), -1);
EXPECT_EQ(tokenizer->GetBorTokenId(), -1);
EXPECT_EQ(tokenizer->GetEorTokenId(), -1);
}

TEST(CAPITests, TagId_FromConfig) {
// Create a temporary model directory with bot/eot/bor/eor token IDs in model section
auto temp_dir = std::filesystem::temp_directory_path() / "oga_test_tool_tags";
std::filesystem::remove_all(temp_dir); // Clean up any leftover from a previous failed run
std::filesystem::create_directories(temp_dir);
Comment thread
sayanshaw24 marked this conversation as resolved.

// Copy minimal model files from tiny-random-gpt2
std::string src_dir = MODEL_PATH "hf-internal-testing/tiny-random-gpt2-fp32";
for (const auto& entry : std::filesystem::directory_iterator(src_dir)) {
if (entry.path().filename() != "genai_config.json") {
std::filesystem::copy_file(entry.path(), temp_dir / entry.path().filename(),
std::filesystem::copy_options::overwrite_existing);
}
}

// Write genai_config.json with token IDs in model section
{
std::ofstream f((temp_dir / "genai_config.json").string());
f << R"({
"model": {
"type": "gpt2",
"pad_token_id": 98,
"bos_token_id": 98,
"eos_token_id": 98,
"vocab_size": 1000,
"context_length": 512,
"bot_token_id": 151657,
"eot_token_id": 151658,
"bor_token_id": 151659,
"eor_token_id": 151660,
"decoder": {
"session_options": { "provider_options": [] },
"filename": "past.onnx",
"num_key_value_heads": 4,
"head_size": 8,
"num_hidden_layers": 5,
"inputs": { "past_names": "past_%d" },
"outputs": { "present_names": "present_%d" }
}
}
})";
}

auto model = OgaModel::Create(temp_dir.string().c_str());
auto tokenizer = OgaTokenizer::Create(*model);

// Tokenizer returns configured IDs from model section
EXPECT_EQ(tokenizer->GetBotTokenId(), 151657);
EXPECT_EQ(tokenizer->GetEotTokenId(), 151658);
EXPECT_EQ(tokenizer->GetBorTokenId(), 151659);
EXPECT_EQ(tokenizer->GetEorTokenId(), 151660);

// Cleanup
std::filesystem::remove_all(temp_dir);
}

// Regression test for MSRC: malformed audio buffers smaller than the minimum valid
// audio header size must be rejected with an error, not cause a crash.
TEST(CAPITests, LoadAudiosFromBuffersRejectsEmptyBuffer) {
Expand Down
Loading