Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions components/src/dynamo/common/configuration/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
T = TypeVar("T")


def parse_bool(value: str) -> bool:
"""Parse a case-insensitive true or false CLI value."""
normalized = value.strip().lower()
if normalized == "true":
return True
if normalized == "false":
return False
raise argparse.ArgumentTypeError("expected 'true' or 'false'")


def split_served_model_names(served_model_name: Any) -> list[str]:
"""Split a ``--served-model-name`` value into individual names.

Expand Down
14 changes: 14 additions & 0 deletions components/src/dynamo/common/tests/configuration/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
add_negatable_bool_argument,
env_or_default,
nullable_float,
parse_bool,
)

pytestmark = [
Expand All @@ -20,6 +21,19 @@
]


@pytest.mark.parametrize(
("value", "expected"),
[("true", True), ("TRUE", True), ("false", False), ("FALSE", False)],
)
def test_parse_bool(value, expected):
assert parse_bool(value) is expected


def test_parse_bool_rejects_invalid_value():
with pytest.raises(argparse.ArgumentTypeError, match="expected 'true' or 'false'"):
parse_bool("yes")


class TestEnvOrDefault:
"""Test env_or_default function."""

Expand Down
16 changes: 16 additions & 0 deletions components/src/dynamo/frontend/frontend_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
add_argument,
add_negatable_bool_argument,
env_or_default,
parse_bool,
)

from . import __version__
Expand Down Expand Up @@ -89,6 +90,7 @@ class FrontendConfig(RouterConfigBase, KvRouterConfigBase, AicPerfConfigBase):
exclude_tools_when_tool_choice_none: bool
preprocess_workers: int
tokenizer_backend: str
tokenizer_fallback: bool
trust_remote_code: bool
frontend_route_extensions: list[str]

Expand Down Expand Up @@ -552,6 +554,20 @@ def add_arguments(self, parser) -> None:
choices=["default", "fastokens", "basetenkenizer"],
)

add_argument(
Comment thread
xianlubird marked this conversation as resolved.
Resolved
g,
flag_name="--tokenizer-fallback",
env_var="DYN_TOKENIZER_FALLBACK",
default=True,
help=(
"Fall back to HuggingFace when the selected fastokens or "
"basetenkenizer backend cannot load the model tokenizer. Set this "
"option to false to fail during startup instead."
),
arg_type=parse_bool,
metavar="{true,false}",
)

add_negatable_bool_argument(
g,
flag_name="--trust-remote-code",
Expand Down
1 change: 1 addition & 0 deletions components/src/dynamo/frontend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ def signal_handler():
"enable_streaming_reasoning_dispatch": config.enable_streaming_reasoning_dispatch,
"reasoning_field_name": config.reasoning_field_name,
"tokenizer_backend": config.tokenizer_backend,
"tokenizer_fallback": config.tokenizer_fallback,
}
if config.migration_max_seq_len is not None:
kwargs["migration_max_seq_len"] = config.migration_max_seq_len
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Use this backend for supported `tokenizer.json` models when you need Baseten Tok
#### Compatibility notes:

- Works with standard BPE `tokenizer.json` files (Qwen, LLaMA, GPT-family, Mistral, DeepSeek, etc.).
- If `fastokens` or `basetenkenizer` cannot load a particular tokenizer file, the frontend logs a warning and transparently falls back to HuggingFace; requests are never dropped.
- If `fastokens` or `basetenkenizer` cannot load a particular tokenizer file, the frontend logs a warning and transparently falls back to HuggingFace by default. Disable tokenizer fallback to reject incompatible configurations during startup.
- Special tokens declared only in a sibling `tokenizer_config.json` are preserved for Baseten encoding and decoding and for Dynamo's L1 prefix-cache boundaries.
- Has no effect on TikToken-format tokenizers (`.model` / `.tiktoken` files), which always use the TikToken backend.

Expand All @@ -42,6 +42,7 @@ Set the backend with a CLI flag or environment variable. The CLI flag takes prec
| CLI Argument | Env Var | Valid values | Default |
|---|---|---|---|
| `--tokenizer` | `DYN_TOKENIZER` | `default`, `fastokens`, `basetenkenizer` | `default` |
| `--tokenizer-fallback` | `DYN_TOKENIZER_FALLBACK` | `true`, `false` | `true` |

**Examples:**

Expand All @@ -55,6 +56,9 @@ python -m dynamo.frontend

# Baseten Tokenizer
python -m dynamo.frontend --tokenizer basetenkenizer

# Require Baseten Tokenizer instead of falling back to HuggingFace
python -m dynamo.frontend --tokenizer basetenkenizer --tokenizer-fallback false
```

## Dynamo Frontend Behavior
Expand All @@ -64,5 +68,5 @@ When a non-default backend is selected:
1. The frontend resolves `--tokenizer` / `DYN_TOKENIZER` and passes the selected backend to the Rust runtime.
2. `ModelDeploymentCard::tokenizer()` loads the HuggingFace tokenizer first for fallback behavior and L1 cache special-token metadata.
3. Dynamo constructs `FastTokenizer` for `fastokens` or `BasetenTokenizer` for `basetenkenizer` from the same `tokenizer.json` file.
4. If construction fails because the tokenizer uses unsupported features, Dynamo logs a warning and falls back to HuggingFace.
4. If construction fails because the tokenizer uses unsupported features, Dynamo logs a warning and falls back to HuggingFace. With `--tokenizer-fallback false`, startup fails and reports the backend loading error instead.
5. When the L1 prefix cache is enabled, Dynamo wraps the selected backend with the same special-token boundary metadata and cache metrics used by the default path.
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,13 @@ See the [Frontend Guide](../../developer-guide/knowledge-base/modular-components
Environment variable: `DYN_TOKENIZER`
</ParamField>

<ParamField path="--tokenizer-fallback" type="boolean" default="true">
Fall back to HuggingFace when `fastokens` or `basetenkenizer` cannot load the model tokenizer. Set
this option to `false` to fail during startup instead.

Environment variable: `DYN_TOKENIZER_FALLBACK`
</ParamField>

## Experimental

### Python route extensions
Expand Down
13 changes: 8 additions & 5 deletions docs/fern/pages/use-cases/fastokens-tokenizer/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Stay on the default backend if:

You can also set the same option with the `--tokenizer fastokens` frontend flag. The flag takes precedence when both settings are present.

To require `fastokens`, set `DYN_TOKENIZER_FALLBACK=false` or add `--tokenizer-fallback false`. The frontend then reports an error during model initialization when `fastokens` cannot load the tokenizer instead of using HuggingFace.

To return to the default HuggingFace tokenizer backend, set `DYN_TOKENIZER=default` or omit it. Changing the value requires the Frontend pod to be replaced, which `kubectl apply` does automatically.

No client changes are required. Request payloads, OpenAI-compatible API behavior, and streamed responses remain the same.
Expand All @@ -86,7 +88,7 @@ Stay on the default backend if:
Failed to load fastokens, falling back to HuggingFace
```

The fallback keeps the deployment healthy, but the model does not receive the `fastokens` speedup.
The default fallback keeps the deployment healthy, but the model does not receive the `fastokens` speedup. With tokenizer fallback disabled, the same loading failure stops model initialization.
</Step>

<Step title="Measure Your Workload">
Expand Down Expand Up @@ -115,15 +117,16 @@ Stay on the default backend if:
- **Encoding**: `fastokens` converts prompt text to token IDs.
- **Decoding**: HuggingFace `tokenizers` converts generated token IDs back to text.

Both backends load from the same `tokenizer.json`, so supported tokenizers should produce the same token IDs as the default HuggingFace path. If `fastokens` cannot load the tokenizer file, Dynamo logs a warning and falls back to the default backend instead of dropping requests.
Both backends load from the same `tokenizer.json`, so supported tokenizers should produce the same token IDs as the default HuggingFace path. If `fastokens` cannot load the tokenizer file, Dynamo logs a warning and falls back to the default backend. Set `--tokenizer-fallback false` to make this loading failure stop model initialization.

```mermaid
flowchart TD
Start["Frontend starts with<br/>--tokenizer fastokens"] --> Kind{"Tokenizer file type"}
Kind -->|BPE tokenizer.json| Load{"fastokens loads?"}
Kind -->|.model / .tiktoken| Other["Use existing TikToken backend"]
Load -->|Yes| Fast["Encode with fastokens<br/>Decode with HuggingFace"]
Load -->|No| Warn["Log warning"] --> Default["Use HuggingFace backend"]
Load -->|No, fallback true| Warn["Log warning"] --> Default["Use HuggingFace backend"]
Load -->|No, fallback false| Fail["Model initialization fails"]
Fast --> Serve["Serve requests"]
Default --> Serve
Other --> Serve
Expand All @@ -135,7 +138,7 @@ Stay on the default backend if:
| Tokenizer format | Behavior with `--tokenizer fastokens` |
|---|---|
| BPE `tokenizer.json` | Dynamo tries to encode with `fastokens` and decode with HuggingFace. |
| BPE `tokenizer.json` with unsupported components | Dynamo logs a warning and falls back to HuggingFace. |
| BPE `tokenizer.json` with unsupported components | Dynamo logs a warning and falls back to HuggingFace by default. With tokenizer fallback disabled, model initialization fails. |
| TikToken `.model` or `.tiktoken` | Unchanged. Dynamo uses the existing TikToken backend. |

`fastokens` targets BPE tokenizer pipelines. It is focused on inference and does not support every HuggingFace `tokenizers` feature; additional encoding outputs and some normalizers or pre-tokenizers are not available.
Expand All @@ -152,7 +155,7 @@ For any new model, validate on representative prompts before rolling out broadly
</Accordion>

<Accordion title="Why did fastokens fall back to HuggingFace?">
The model's tokenizer file uses a feature that `fastokens` does not support, or it is not a BPE `tokenizer.json` path. Dynamo has already fallen back to HuggingFace and should keep serving traffic. Check the tokenizer format, compare it against the [tested models list](https://github.com/crusoecloud/fastokens#tested-models), and use `--tokenizer default` to avoid the warning.
The model's tokenizer file uses a feature that `fastokens` does not support, or it is not a BPE `tokenizer.json` path. Dynamo has already fallen back to HuggingFace and should keep serving traffic. Check the tokenizer format, compare it against the [tested models list](https://github.com/crusoecloud/fastokens#tested-models), and use `--tokenizer default` to avoid the warning. For a deployment that requires `fastokens`, use `--tokenizer-fallback false` so an unsupported tokenizer fails during model initialization.
</Accordion>

<Accordion title="Why do the logs show an unrecognized DYN_TOKENIZER value?">
Expand Down
8 changes: 7 additions & 1 deletion lib/bindings/python/rust/llm/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ pub(crate) struct EntrypointArgs {
impl EntrypointArgs {
#[allow(clippy::too_many_arguments)]
#[new]
#[pyo3(signature = (engine_type, model_path=None, model_name=None, endpoint_id=None, template_file=None, router_config=None, kv_cache_block_size=None, http_host=None, http_port=None, http_metrics_port=None, tls_cert_path=None, tls_key_path=None, extra_engine_args=None, mocker_engine_args=None, runtime_config=None, namespace=None, namespace_prefix=None, is_prefill=false, is_decode=false, migration_limit=0, migration_max_seq_len=None, chat_engine_factory=None, aic_perf_config=None, *, metrics_prefix=None, enable_anthropic_api=None, strip_anthropic_preamble=None, enable_streaming_tool_dispatch=None, enable_streaming_reasoning_dispatch=None, reasoning_field_name=None, tokenizer_backend=None))]
#[pyo3(signature = (engine_type, model_path=None, model_name=None, endpoint_id=None, template_file=None, router_config=None, kv_cache_block_size=None, http_host=None, http_port=None, http_metrics_port=None, tls_cert_path=None, tls_key_path=None, extra_engine_args=None, mocker_engine_args=None, runtime_config=None, namespace=None, namespace_prefix=None, is_prefill=false, is_decode=false, migration_limit=0, migration_max_seq_len=None, chat_engine_factory=None, aic_perf_config=None, *, metrics_prefix=None, enable_anthropic_api=None, strip_anthropic_preamble=None, enable_streaming_tool_dispatch=None, enable_streaming_reasoning_dispatch=None, reasoning_field_name=None, tokenizer_backend=None, tokenizer_fallback=None))]
pub fn new(
py: Python<'_>,
engine_type: EngineType,
Expand Down Expand Up @@ -601,6 +601,7 @@ impl EntrypointArgs {
enable_streaming_reasoning_dispatch: Option<bool>,
reasoning_field_name: Option<String>,
tokenizer_backend: Option<String>,
tokenizer_fallback: Option<bool>,
) -> PyResult<Self> {
let endpoint_id_obj: Option<EndpointId> = endpoint_id.as_deref().map(EndpointId::from);
if (tls_cert_path.is_some() && tls_key_path.is_none())
Expand Down Expand Up @@ -647,6 +648,11 @@ impl EntrypointArgs {
.inner
.set_tokenizer_backend(Some(tokenizer_backend));
}
if let Some(tokenizer_fallback) = tokenizer_fallback {
runtime_config
.inner
.set_tokenizer_fallback_enabled(Some(tokenizer_fallback));
Comment thread
xianlubird marked this conversation as resolved.
Resolved
}
runtime_config.validate_config()?;

Ok(EntrypointArgs {
Expand Down
4 changes: 3 additions & 1 deletion lib/bindings/python/src/dynamo/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -3100,6 +3100,7 @@ class EntrypointArgs:
enable_streaming_tool_dispatch: Optional[bool] = None,
enable_streaming_reasoning_dispatch: Optional[bool] = None,
tokenizer_backend: Optional[str] = None,
tokenizer_fallback: Optional[bool] = None,
) -> None:
"""
Create EntrypointArgs.
Expand Down Expand Up @@ -3133,7 +3134,8 @@ class EntrypointArgs:
strip_anthropic_preamble: Optional Anthropic preamble stripping override
enable_streaming_tool_dispatch: Optional streaming tool dispatch override
enable_streaming_reasoning_dispatch: Optional streaming reasoning dispatch override
tokenizer_backend: Optional tokenizer backend override ("default" or "fastokens")
tokenizer_backend: Optional tokenizer backend override ("default", "fastokens", or "basetenkenizer")
tokenizer_fallback: Whether alternate tokenizer load failures fall back to HuggingFace
"""
...

Expand Down
69 changes: 69 additions & 0 deletions lib/llm/src/local_model/runtime_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub enum StructuralTagScope {
}

pub const ENV_TOKENIZER_BACKEND: &str = "DYN_TOKENIZER";
pub const ENV_TOKENIZER_FALLBACK: &str = "DYN_TOKENIZER_FALLBACK";

/// Worker-advertised support for Dynamo's vLLM-compatible
/// `POST /inference/v1/generate` adapter.
Expand Down Expand Up @@ -170,6 +171,11 @@ pub struct ModelRuntimeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokenizer_backend: Option<TokenizerBackend>,

/// Frontend tokenizer fallback override. When unset, direct Rust callers can still use
/// `DYN_TOKENIZER_FALLBACK`; when set, this explicit value wins over process environment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokenizer_fallback_enabled: Option<bool>,

/// Whether structural tag guided decoding is enabled for tool calls.
#[serde(default)]
pub structural_tag_mode: StructuralTagMode,
Expand Down Expand Up @@ -302,6 +308,7 @@ impl Default for ModelRuntimeConfig {
tool_call_parser: None,
reasoning_parser: None,
tokenizer_backend: None,
tokenizer_fallback_enabled: None,
structural_tag_mode: StructuralTagMode::Off,
structural_tag_scope: StructuralTagScope::Auto,
structural_tag_schema: StructuralTagSchemaMode::Auto,
Expand Down Expand Up @@ -542,6 +549,16 @@ impl ModelRuntimeConfig {
.unwrap_or_else(TokenizerBackend::from_env_or_default)
}

pub fn is_tokenizer_fallback_enabled(&self) -> bool {
self.tokenizer_fallback_enabled.unwrap_or_else(|| {
std::env::var(ENV_TOKENIZER_FALLBACK)
.ok()
.as_deref()
.and_then(dynamo_runtime::config::parse_bool_opt)
Comment thread
xianlubird marked this conversation as resolved.
Resolved
.unwrap_or(true)
})
}

/// Resolve the KV-state endpoint, preserving the serving endpoint as the compatibility
/// default for workers that do not advertise an explicit mapping.
pub fn effective_kv_state_endpoint(&self, serving_endpoint: &EndpointId) -> EndpointId {
Expand All @@ -558,6 +575,11 @@ impl ModelRuntimeConfig {
self
}

pub fn set_tokenizer_fallback_enabled(&mut self, enabled: Option<bool>) -> &mut Self {
self.tokenizer_fallback_enabled = enabled;
self
}

/// Rebuild canonical topology taints derived from `topology_domains`.
///
/// Existing caller-provided taints outside the reserved topology prefix are preserved; generated
Expand Down Expand Up @@ -826,6 +848,53 @@ mod tests {
}
}

#[test]
#[serial_test::serial]
fn tokenizer_fallback_env_default_and_explicit_config_precedence() {
temp_env::with_vars([(ENV_TOKENIZER_FALLBACK, Some("false"))], || {
let config = ModelRuntimeConfig::default();
assert!(!config.is_tokenizer_fallback_enabled());

let config = ModelRuntimeConfig {
tokenizer_fallback_enabled: Some(true),
..Default::default()
};
assert!(config.is_tokenizer_fallback_enabled());
});

temp_env::with_vars([(ENV_TOKENIZER_FALLBACK, Some("true"))], || {
let config = ModelRuntimeConfig {
tokenizer_fallback_enabled: Some(false),
..Default::default()
};
assert!(!config.is_tokenizer_fallback_enabled());
});

temp_env::with_vars_unset([ENV_TOKENIZER_FALLBACK], || {
let config = ModelRuntimeConfig::default();
assert!(config.is_tokenizer_fallback_enabled());
});
}

#[test]
fn tokenizer_fallback_roundtrips_through_serde_json() {
for enabled in [true, false] {
let config = ModelRuntimeConfig {
tokenizer_fallback_enabled: Some(enabled),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains(&format!("\"tokenizer_fallback_enabled\":{enabled}")));
let parsed: ModelRuntimeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tokenizer_fallback_enabled, Some(enabled));
}

let json = serde_json::to_string(&ModelRuntimeConfig::default()).unwrap();
assert!(!json.contains("tokenizer_fallback_enabled"));
let legacy: ModelRuntimeConfig = serde_json::from_str("{}").unwrap();
assert_eq!(legacy.tokenizer_fallback_enabled, None);
}

#[test]
fn tokenizer_backend_string_values_are_strict() {
for backend in [
Expand Down
Loading
Loading