Skip to content

Read Tool & Reasoning Tokens from GenAI API instead of Catalog Metadata - #838

Draft
sayanshaw24 wants to merge 10 commits into
mainfrom
sayanshaw/fl-tool-tags
Draft

Read Tool & Reasoning Tokens from GenAI API instead of Catalog Metadata#838
sayanshaw24 wants to merge 10 commits into
mainfrom
sayanshaw/fl-tool-tags

Conversation

@sayanshaw24

@sayanshaw24 sayanshaw24 commented Jun 24, 2026

Copy link
Copy Markdown

Migrate tool/reasoning metadata from catalog to GenAI + optimize decode loop

Summary

Two goals:

  1. 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's genai_config.json instead, via the new OgaTokenizer::GetBotTokenId() / GetEotTokenId() / GetBorTokenId() / GetEorTokenId() APIs, and writes the decoded strings into ModelInfo so all existing downstream consumers continue working.

  2. 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:

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

Mirrors bos/eos/pad in both naming and access pattern (same Tokenizer class).

Performance

Before (per token): 2 tokenizer stream decodes + 2 string.find() + string comparison
After (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)

Data Storage Consumer
eos_token_ids_ GenAIModelInstance Decode() — filters EOS tokens
tag_info_ (IDs + strings) GenAIModelInstance Decode() — fast-path detection
String copies ModelInfo.string_properties BuildToolCallContext()ToolCallStreamAccumulator

Changes

GenAIModelInstance (genai_model_instance.h / .cc)

  • New TagInfo struct with bot_id/eot_id/bor_id/eor_id + decoded strings
  • GetTagInfo() — lazy-cached via std::call_once (same pattern as GetEosTokenIds())
    • Calls tokenizer_->GetBotTokenId() etc. (4 getter calls, one-time)
    • Decodes each valid ID through tokenizer_with_special_->Decode(&id, 1) to get the string (4 decodes, one-time)

OnnxChatGenerator::Decode() (onnx_chat_generator.cc)

  • Fast path (tag IDs available): integer compare → return cached string, skip special stream
  • Slow path (no tag IDs, backward compat): existing double-decode + string.find() behavior preserved

Model::Load() (model.cc)

  • After loading the GenAI model, enriches ModelInfo string properties from GetTagInfo() cached strings
  • Only writes if the property isn't already set (catalog metadata takes precedence)
  • Infers supports_tool_calling / supports_reasoning from tag ID presence (>= 0)

No changes to ToolCallContext, ToolCallStreamAccumulator, or BuildToolCallContext()

  • They continue consuming strings from ModelInfo as before — single source of truth

Dependency

Requires updated onnxruntime-genai nuget with OgaTokenizerGetBotTokenId / GetEotTokenId / GetBorTokenId / GetEorTokenId APIs.

How it works

Before (per token, every single generated token):

Decode Loop:
  token_id = generator.GetNextTokens()[0]

  // Decode through BOTH streams (expensive)
  token_text    = stream_->Decode(token_id)              // normal stream decode
  special_text  = stream_with_special_->Decode(token_id) // special stream decode

  // String operations to detect markers
  if (special_text != token_text):
    is_tool  = special_text.find("tool_call")            // string search
    is_think = special_text.find("think")                // string search
    if (is_tool || is_think): return special_text
  return token_text

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):

Model Load (one-time, 4 tokenizer getter calls + 4 Decode calls):
  GenAI:  tokenizer->GetBotTokenId() → 151657   (from config or fallback vocab lookup)
  FL:     Decode(151657) → "<tool_call>"           (one tokenizer decode, cached in TagInfo)
  FL:     ModelInfo["tool_call_start"] = "<tool_call>"

Decode Loop (fast path):
  token_id = generator.GetNextTokens()[0]

  // Integer comparison (virtually free)
  if (token_id == tag_info.bot_id) { stream_->Decode(token_id); return tag_info.bot_str; }
  if (token_id == tag_info.eot_id) { stream_->Decode(token_id); return tag_info.eot_str; }
  // ... bor/eor ...

  // Normal token — single decode, no special stream needed
  return stream_->Decode(token_id)

Cost per token: 1 integer comparison + 1 normal decode. No special stream. No string search.

Estimated performance gain

For a typical 500-token generation:

  • Before: 500 × (2 decodes + 2 string searches) = 1000 decode calls + 1000 string ops
  • After: 500 × (1 decode + 1 int compare) = 500 decode calls + 500 trivial comparisons

~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 delimiters

Testing

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.

@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
foundry-local Ready Ready Preview Jul 31, 2026 1:42am

Request Review

Comment thread sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc Outdated
Comment thread sdk_v2/cpp/src/inferencing/generative/genai_model_instance.cc Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants