fix(providers): stop a shorter model family shadowing a longer one (#2762)

## Description

> **Stacked on #2761** — that PR splits `_lookup_encoding_name` out of
`_get_encoding_name_for_model`, which this one builds on. Please merge
#2761 first; the diff here will shrink to just this commit afterwards.

`_MODEL_ENCODINGS` and `_CONTEXT_LIMITS` are matched by prefix,
iterating in **plain dict order** — so the first *inserted* prefix wins
rather than the most specific one. `gpt-4.1` matched the `gpt-4` entry:

| model | resolved | actual | |
|---|---|---|---|
| `gpt-4.1` | 8192 | 1,047,576 | **128× under** |
| `gpt-4.1-mini` | 8192 | 1,047,576 | **128× under** |
| `gpt-4.1-nano` | 8192 | 1,047,576 | **128× under** |
| `gpt-4-32k-0613` | 8192 | 32,768 | 4× under |
| `gpt-5` / `-mini` / `-nano` | 128,000 | 400,000 | fell to
unknown-model default |
| `o4-mini` | 128,000 | 200,000 | fell to unknown-model default |

A 128× under-estimate matters because the context limit is what tells
the proxy how much headroom is left: it treats a 1M-context model as
nearly full and compresses accordingly.

The same shadowing picked the **wrong encoding** — `gpt-4.1` got
`cl100k_base` instead of `o200k_base`. Measured cost of that:

```text
                cl100k    o200k    error
python code        420      420    +0.0%
json blob          555      555    +0.0%
logs               580      580    +0.0%
english            201      201    +0.0%
CJK                600      450   +33.3%
```

So the encoding half is narrow but real — it only bites CJK content,
which the repo already treats as a case worth testing
(`tests/test_evals_cjk_tokenization.py`).

**Scope honestly:** `get_context_limit` consults LiteLLM *before* this
table, so the limit half only surfaces where LiteLLM is absent or does
not know the model. That is not hypothetical — the `litellm` dependency
carries a `python_version < '3.14'` marker (`pyproject.toml:56`), so
**any install on Python 3.14+ has no LiteLLM** and this table is
load-bearing. The encoding half never had a LiteLLM fallback and was
always wrong.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Both prefix loops now iterate `sorted(..., key=len, reverse=True)` —
longest prefix wins. This is the root-cause fix: it also protects the
*next* model added to these tables.
- Added the missing families: `gpt-4.1` (+`-mini`/`-nano`), `gpt-5`
(+`-mini`/`-nano`), `o4-mini` to both tables.
- Left `supports_model`'s prefix loop alone — it only returns a bool, so
order cannot change its answer.

Not touched: `_PRICING`. `gpt-4.1`/`gpt-5` also fall through to the
GPT-4o pricing tier, which skews cost reporting, but that is a separate
concern with its own verification burden (published rates, staleness
window) and does not belong in a tokenizer-correctness fix.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` + `ruff format`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

### Test Output

12 of 24 new cases fail without the fix; the 12 that pass are the "must
not regress" rows (`gpt-4`, `gpt-4-turbo`, `gpt-4o`, `o3`,
`gpt-3.5-turbo`) — included precisely so the longest-prefix change can't
quietly move them:

```text
$ git stash push headroom/providers/openai.py && pytest tests/test_openai_model_table_resolution.py -q
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[gpt-4.1-mini-1047576]
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[gpt-4.1-nano-1047576]
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[gpt-4.1-2025-04-14-1047576]
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[gpt-4-32k-0613-32768]
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[gpt-5-400000]
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[gpt-5-mini-400000]
FAILED ...::test_context_limit_prefers_the_most_specific_prefix[o4-mini-200000]
FAILED ...::test_encoding_prefers_the_most_specific_prefix[gpt-4.1-o200k_base]
FAILED ...::test_encoding_prefers_the_most_specific_prefix[gpt-4.1-mini-o200k_base]
FAILED ...::test_encoding_prefers_the_most_specific_prefix[gpt-4.1-2025-04-14-o200k_base]
FAILED ...::test_cjk_is_not_over_counted_for_gpt_41
12 failed, 12 passed in 0.70s

$ git stash pop && pytest tests/test_openai_model_table_resolution.py -q
24 passed in 0.43s
```

Regression check — 119 suites touching openai / cost / savings / token /
compress / outcome / budget, this branch vs clean `main` in the same
environment, comparing failure *sets*:

```text
branch : 5 failed, 1486 passed, 86 skipped in 111.01s
main   : 5 failed, 1453 passed, 86 skipped in 132.07s

NEW failures introduced: (none)

pre-existing on both:
  test_bundled_tools_savings.py::test_compressed_payload_preserves_answer_anthropic
  test_compress_route_tokenizer_by_model.py::...[deepseek/deepseek-v4]   (needs `transformers`)
  test_compress_route_tokenizer_by_model.py::...[deepseek/deepseek-v4]   (needs `transformers`)
  test_image_compressor_singleton_reuse.py::test_onnx_router_is_built_once_and_cached
  test_openai_streaming_backend.py::...test_litellm_vertex_streaming_preserves_max_tokens_and_vendor_fields
```

```text
$ ruff check headroom/providers/openai.py tests/test_openai_model_table_resolution.py
All checks passed!
$ mypy headroom/providers/openai.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- **Environment:** macOS, Python 3.13.7, isolated worktree at
`upstream/main` (`ad56dd38`), no `litellm` installed (matching a Python
3.14+ install, where the dep marker excludes it).
- **Exact command / steps:** resolve context limit + encoding for each
model against published OpenAI values, before and after.
- **Observed result:**

```text
before                              after
model               limit  enc      model               limit      enc
gpt-4.1              8192  cl100k   gpt-4.1           1047576  o200k_base
gpt-4.1-mini         8192  cl100k   gpt-4.1-mini      1047576  o200k_base
gpt-4.1-nano         8192  cl100k   gpt-4.1-nano      1047576  o200k_base
gpt-4.1-2025-04-14   8192  cl100k   gpt-4.1-2025-04-14 1047576 o200k_base
gpt-4-32k-0613       8192  cl100k   gpt-4-32k-0613      32768  cl100k_base
gpt-5              128000  o200k    gpt-5              400000  o200k_base
o4-mini            128000  o200k    o4-mini            200000  o200k_base

unchanged: gpt-4=8192/cl100k, gpt-4-turbo=128000/cl100k,
           gpt-4o=128000/o200k, o3=200000, gpt-3.5-turbo=16385/cl100k
```
This commit is contained in:
Tejas Chopra 2026-08-04 00:34:38 -07:00 committed by GitHub
parent cd92ed52ff
commit 0cb72f45b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 127 additions and 8 deletions

View file

@ -64,6 +64,10 @@ _MODEL_ENCODINGS: dict[str, str] = {
"o1-mini": "o200k_base",
"o3": "o200k_base",
"o3-mini": "o200k_base",
"o4": "o200k_base",
"o4-mini": "o200k_base",
"gpt-4.1": "o200k_base",
"gpt-5": "o200k_base",
# GPT-4 and GPT-3.5 use cl100k_base
"gpt-4": "cl100k_base",
"gpt-4-turbo": "cl100k_base",
@ -78,6 +82,18 @@ _CONTEXT_LIMITS: dict[str, int] = {
"gpt-4o-2024-11-20": 128000,
"gpt-4o-2024-08-06": 128000,
"gpt-4o-2024-05-13": 128000,
# GPT-4.1 series (~1M input). LiteLLM is still consulted first in
# get_context_limit; these are the manual fallback for installs without it
# (the litellm dep is gated python_version < '3.14').
"gpt-4.1": 1_047_576,
"gpt-4.1-mini": 1_047_576,
"gpt-4.1-nano": 1_047_576,
# GPT-5 series. This table is an INPUT budget (get_context_limit returns
# litellm's max_input_tokens when available), so these are 272K input --
# not the 400K total window, which is 272K in + 128K out.
"gpt-5": 272000,
"gpt-5-mini": 272000,
"gpt-5-nano": 272000,
# GPT-4 Turbo
"gpt-4-turbo": 128000,
"gpt-4-turbo-preview": 128000,
@ -94,6 +110,7 @@ _CONTEXT_LIMITS: dict[str, int] = {
"o1-mini": 128000,
"o3": 200000,
"o3-mini": 200000,
"o4-mini": 200000,
# DeepSeek (often accessed via OpenAI-compatible API). Values verified
# against api-docs.deepseek.com (V4) and LiteLLM model_cost (deprecated
# aliases). LiteLLM lookup is still attempted first in get_context_limit;
@ -275,10 +292,12 @@ def _lookup_encoding_name(model: str, custom_encodings: dict[str, str] | None =
if model in _MODEL_ENCODINGS:
return _MODEL_ENCODINGS[model]
# Prefix match for versioned models
for prefix, encoding in _MODEL_ENCODINGS.items():
# Prefix match for versioned models, longest prefix first. Plain dict order
# let a shorter family shadow a longer one: "gpt-4.1" hit the "gpt-4" entry
# and got cl100k_base instead of o200k_base, which over-counts CJK by ~33%.
for prefix in sorted(_MODEL_ENCODINGS, key=len, reverse=True):
if model.startswith(prefix):
return encoding
return _MODEL_ENCODINGS[prefix]
# Pattern-based inference
family = _infer_model_family(model)
@ -525,10 +544,12 @@ class OpenAIProvider(Provider):
if model in self._context_limits:
return self._context_limits[model]
# Prefix match
for prefix, limit in self._context_limits.items():
# Prefix match, longest prefix first. Plain dict order let a shorter
# family shadow a longer one: "gpt-4.1" hit the "gpt-4" entry and got
# 8192 instead of ~1M, and "gpt-4-32k-0613" got 8192 instead of 32768.
for prefix in sorted(self._context_limits, key=len, reverse=True):
if model.startswith(prefix):
return limit
return self._context_limits[prefix]
# Pattern-based inference
family = _infer_model_family(model)

View file

@ -0,0 +1,84 @@
"""A shorter model family must not shadow a longer one.
``_MODEL_ENCODINGS`` and ``_CONTEXT_LIMITS`` are matched by prefix. Iterating
them in plain dict order meant the first *inserted* prefix won, not the most
specific one, so ``gpt-4.1`` matched the ``gpt-4`` entry:
* context limit 8192 instead of ~1M -- a 128x under-estimate, which makes the
proxy think a 1M-context model is nearly full and compress accordingly;
* encoding ``cl100k_base`` instead of ``o200k_base``, which over-counts CJK
text by ~33%.
``gpt-4-32k-0613`` had the same problem (8192 instead of 32768).
``get_context_limit`` consults LiteLLM before this table, so the limit half only
surfaces where LiteLLM is missing or does not know the model -- notably any
install on Python >= 3.14, where the ``litellm`` dependency is excluded by its
``python_version < '3.14'`` marker. The encoding half has no such fallback and
was always wrong.
"""
from __future__ import annotations
import pytest
from headroom.providers.openai import (
OpenAIProvider,
_get_encoding_name_for_model,
)
@pytest.mark.parametrize(
("model", "expected"),
[
# The shadowing cases.
("gpt-4.1", 1_047_576),
("gpt-4.1-mini", 1_047_576),
("gpt-4.1-nano", 1_047_576),
("gpt-4.1-2025-04-14", 1_047_576),
("gpt-4-32k-0613", 32768),
# Newer families that fell through to the unknown-model default.
("gpt-5", 272_000),
("gpt-5-mini", 272_000),
("o4-mini", 200_000),
# Must not regress.
("gpt-4", 8192),
("gpt-4-turbo", 128_000),
("gpt-4o", 128_000),
("o3", 200_000),
("gpt-3.5-turbo", 16385),
],
)
def test_context_limit_prefers_the_most_specific_prefix(model: str, expected: int) -> None:
assert OpenAIProvider()._get_context_limit_manual(model) == expected
@pytest.mark.parametrize(
("model", "expected"),
[
("gpt-4.1", "o200k_base"),
("gpt-4.1-mini", "o200k_base"),
("gpt-4.1-2025-04-14", "o200k_base"),
("gpt-5", "o200k_base"),
("gpt-5-mini", "o200k_base"),
("o4-mini", "o200k_base"),
# Must not regress: these genuinely are cl100k_base.
("gpt-4", "cl100k_base"),
("gpt-4-turbo", "cl100k_base"),
("gpt-3.5-turbo", "cl100k_base"),
("gpt-4o", "o200k_base"),
],
)
def test_encoding_prefers_the_most_specific_prefix(model: str, expected: str) -> None:
assert _get_encoding_name_for_model(model) == expected
def test_cjk_is_not_over_counted_for_gpt_41() -> None:
"""The concrete cost of picking cl100k_base for a gpt-4.1 request."""
tiktoken = pytest.importorskip("tiktoken")
text = "这是一个测试文档,用于验证分词器的差异。" * 30
chosen = _get_encoding_name_for_model("gpt-4.1")
assert len(tiktoken.get_encoding(chosen).encode(text)) == len(
tiktoken.get_encoding("o200k_base").encode(text)
)

View file

@ -276,10 +276,24 @@ class TestOpenAIModelFallback:
"""Test fallback for unknown models."""
provider = OpenAIProvider()
# Unknown model should get 128K default
limit = provider.get_context_limit("gpt-5-future")
# Unknown model should get 128K default. Deliberately a name that
# matches no known family prefix -- this used to say "gpt-5-future",
# which stopped being unknown once gpt-5 was added to _CONTEXT_LIMITS.
limit = provider.get_context_limit("gpt-9-imaginary")
assert limit == 128000
def test_unknown_variant_inherits_its_family_limit(self):
"""An unrecognized variant of a *known* family takes that family's limit.
This is the same prefix inheritance that gives "gpt-4o-2024-11-20" the
gpt-4o limit, and it is strictly better than dropping such a model to
the generic 128K default.
"""
provider = OpenAIProvider()
assert provider.get_context_limit("gpt-5-future") == 272000
assert provider.get_context_limit("gpt-4.1-preview") == 1_047_576
def test_no_exception_for_unknown_model(self):
"""Test that unknown models don't raise exceptions."""
provider = OpenAIProvider()