Read Tool & Reasoning Tokens from GenAI API instead of Catalog Metadata - #838
Draft
sayanshaw24 wants to merge 10 commits into
Draft
Read Tool & Reasoning Tokens from GenAI API instead of Catalog Metadata#838sayanshaw24 wants to merge 10 commits into
sayanshaw24 wants to merge 10 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
skottmckay
reviewed
Jun 24, 2026
skottmckay
reviewed
Jun 24, 2026
…uildToolCallContext
added 2 commits
July 8, 2026 13:39
…o sayanshaw/fl-tool-tags
added 2 commits
July 22, 2026 15:44
baijumeswani
pushed a commit
to microsoft/onnxruntime-genai
that referenced
this pull request
Jul 27, 2026
…with Fallback Map (#2215) # Add tool-calling and reasoning token IDs to GenAI Tokenizer ## Summary Adds four new token ID fields to the `model` section of `genai_config.json` — `bot_token_id`, `eot_token_id`, `bor_token_id`, `eor_token_id` — and exposes them on the **Tokenizer** (alongside `bos`, `eos`, `pad`), with a backward-compatible fallback for older model packages. This establishes a new naming convention for generation-level special tokens: | Abbreviation | Expansion | Purpose | |---|---|---| | **bot** | beginning of tool (call) | marks start of tool-call content | | **eot** | end of tool (call) | marks end of tool-call content | | **bor** | beginning of reasoning | marks start of reasoning/thinking content | | **eor** | end of reasoning | marks end of reasoning/thinking content | The naming mirrors `bos`/`eos`/`pad` — short, unambiguous, and follows the same access pattern (`GetBotTokenId()` alongside `GetBosTokenId()`). ## Changes ### Config parsing (`config.h` / `config.cpp`) - Added to `Config::Model`: `bot_token_id`, `eot_token_id`, `bor_token_id`, `eor_token_id` (int, default -1) - Parsed in `Model_Element::OnValue()` alongside existing token ID fields (bos, eos, pad) ### Tokenizer (`models/model.h` / `models/model.cpp`) - New private members: `int32_t bot_token_id_`, `eot_token_id_`, `bor_token_id_`, `eor_token_id_` - New public getters: `GetBotTokenId()`, `GetEotTokenId()`, `GetBorTokenId()`, `GetEorTokenId()` - Initialized in the Tokenizer constructor from config (identical pattern to bos/eos/pad) - **Fallback logic**: if any ID is -1 after reading config, attempts to resolve it by encoding known model-family-specific token strings through the vocabulary. This provides backward compatibility for Foundry Local consuming older model packages that predate these config fields. - Fallback map keyed by `model.type`: - `qwen2`/`qwen3`/`phi3` → `<tool_call>` / `</tool_call>` - `gptoss` → `<|start|>` / `<|call|>` - `qwen3` → `<think>` / `</think>` (reasoning) ### Public C API (`ort_genai_c.h` / `ort_genai_c.cpp`) - `OgaTokenizerGetBotTokenId(tokenizer, &token_id)` — returns BOT token id or -1 - `OgaTokenizerGetEotTokenId(tokenizer, &token_id)` — returns EOT token id or -1 - `OgaTokenizerGetBorTokenId(tokenizer, &token_id)` — returns BOR token id or -1 - `OgaTokenizerGetEorTokenId(tokenizer, &token_id)` — returns EOR token id or -1 Same pattern as `OgaTokenizerGetBosTokenId` / `OgaTokenizerGetPadTokenId`. ### C++ wrapper (`ort_genai.h`) - `int32_t OgaTokenizer::GetBotTokenId()` - `int32_t OgaTokenizer::GetEotTokenId()` - `int32_t OgaTokenizer::GetBorTokenId()` - `int32_t OgaTokenizer::GetEorTokenId()` ### C# (`Tokenizer.cs`) - `int Tokenizer.GetBotTokenId()` - `int Tokenizer.GetEotTokenId()` - `int Tokenizer.GetBorTokenId()` - `int Tokenizer.GetEorTokenId()` ### Java (`Tokenizer.java`) - `int tokenizer.getBotTokenId()` - `int tokenizer.getEotTokenId()` - `int tokenizer.getBorTokenId()` - `int tokenizer.getEorTokenId()` ### Objective-C (`ort_genai_objc.h`) - `- (int32_t)getBotTokenId:(NSError**)error` - `- (int32_t)getEotTokenId:(NSError**)error` - `- (int32_t)getBorTokenId:(NSError**)error` - `- (int32_t)getEorTokenId:(NSError**)error` ### Python (`python.cpp`) - `tokenizer.bot_token_id` (read-only property) - `tokenizer.eot_token_id` - `tokenizer.bor_token_id` - `tokenizer.eor_token_id` ### Tests (`test/c_api_tests.cpp`) - `TagId_Unknown` — verifies `gpt2` model (not in fallback map, no config IDs) returns -1 for all four - `TagId_FromConfig` — creates a temp model dir with token IDs in the `model` section, verifies correct parsing via the tokenizer ## genai_config.json format ```json { "model": { "type": "qwen3", "bos_token_id": 151643, "eos_token_id": 151645, "bot_token_id": 151657, "eot_token_id": 151658, "bor_token_id": 151659, "eor_token_id": 151660, "decoder": { ... } } } ``` All four fields are optional. If absent, the fallback map attempts to encode the known string via the tokenizer vocabulary. If the model type isn't in the fallback map either, -1 is returned (model doesn't support tool calling / reasoning). ## Motivation This is required for the Foundry Local Catalog v2 migration where tool/reasoning metadata is being removed from catalog. Also, the Foundry Local C++ SDK currently detects tool-call and reasoning tokens by **double-decoding every token** through both a normal and a special-token tokenizer stream, then doing `string.find("tool_call")` / `string.find("think")` per token. This is expensive. With token IDs exposed by GenAI on the Tokenizer, FL can do a single integer comparison per token in the decode loop — eliminating the special stream entirely for models with configured IDs. ## Design Decisions ### Why on the Tokenizer (not Model)? - BOS, EOS, and PAD token IDs are already accessed from the Tokenizer - Token IDs are a tokenizer-level concept — they describe the vocabulary - Follows existing pattern: `Tokenizer::GetBosTokenId()` → `Tokenizer::GetBotTokenId()` - No lazy init needed; resolved once in the constructor ### Why bot/eot/bor/eor naming? - Mirrors `bos`/`eos`/`pad` — short, memorable, consistent - Config field names also follow this pattern: `bot_token_id` alongside `bos_token_id` ### Fallback map - Exists specifically for Foundry Local backward compatibility with older model packages - Only triggered when config fields are not set - Resolved eagerly in the Tokenizer constructor (no lazy cache needed since tokenizer is already available) ## Related PRs - **Foundry-Local**: [Optimize tool/reasoning detection with token ID comparison in decode loop](microsoft/foundry-local#838) --------- Co-authored-by: Sayan Shaw <sayanshaw@microsoft.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrate tool/reasoning metadata from catalog to GenAI + optimize decode loop
Summary
Two goals:
Catalog v2 migration: Tool-call and reasoning token metadata (
toolCallStart,toolCallEnd,reasoningStart,reasoningEnd) is being removed from the catalog. This PR sources it from GenAI'sgenai_config.jsoninstead, via the newOgaTokenizer::GetBotTokenId()/GetEotTokenId()/GetBorTokenId()/GetEorTokenId()APIs, and writes the decoded strings intoModelInfoso all existing downstream consumers continue working.Decode loop optimization: The current per-token detection of special tokens (double-decode +
string.find()) is replaced with a single integer comparison against cached token IDs — the same pattern used for EOS detection.Naming Convention
This PR adopts the new bot/eot/bor/eor naming convention introduced in this GenAI PR:
Mirrors
bos/eos/padin both naming and access pattern (same Tokenizer class).Performance
Before (per token): 2 tokenizer stream decodes + 2
string.find()+ string comparisonAfter (per token): 1 integer comparison (fast path) or unchanged (slow path fallback)
The fast path also eliminates the special tokenizer stream entirely — when tag IDs are available, only the normal stream is maintained.
Storage (follows EOS pattern)
eos_token_ids_GenAIModelInstanceDecode()— filters EOS tokenstag_info_(IDs + strings)GenAIModelInstanceDecode()— fast-path detectionModelInfo.string_propertiesBuildToolCallContext()→ToolCallStreamAccumulatorChanges
GenAIModelInstance(genai_model_instance.h/.cc)TagInfostruct withbot_id/eot_id/bor_id/eor_id+ decoded stringsGetTagInfo()— lazy-cached viastd::call_once(same pattern asGetEosTokenIds())tokenizer_->GetBotTokenId()etc. (4 getter calls, one-time)tokenizer_with_special_->Decode(&id, 1)to get the string (4 decodes, one-time)OnnxChatGenerator::Decode()(onnx_chat_generator.cc)string.find()behavior preservedModel::Load()(model.cc)ModelInfostring properties fromGetTagInfo()cached stringssupports_tool_calling/supports_reasoningfrom tag ID presence (>= 0)No changes to
ToolCallContext,ToolCallStreamAccumulator, orBuildToolCallContext()ModelInfoas before — single source of truthDependency
Requires updated
onnxruntime-genainuget withOgaTokenizerGetBotTokenId/GetEotTokenId/GetBorTokenId/GetEorTokenIdAPIs.deps_versions.jsonbump needed once GenAI nuget publishesHow it works
Before (per token, every single generated token):
Cost per token: 2 tokenizer stream decodes + 2
string.find()+ string comparison — on every token, even though markers appear maybe 2-4 times per generation.After (per token):
Cost per token: 1 integer comparison + 1 normal decode. No special stream. No string search.
Estimated performance gain
For a typical 500-token generation:
~50% reduction in tokenizer decode calls and complete elimination of string search operations in the hot loop. The special tokenizer stream is never even created/maintained for models with tag IDs configured.
Tag names supported by the GenAI API
"tool_call_start","tool_call_end"— tool call delimiters"reasoning_start","reasoning_end"— chain-of-thought reasoning delimitersTesting
Will be validated end-to-end once the GenAI nuget is available. The slow path (no tag IDs) is unchanged, so existing behavior is preserved for models without the new config fields.