Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 21 additions & 1 deletion agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,15 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
Cache key is ``model@base_url`` so the same model name served from
different providers can have different limits.
"""
# Never persist non-positive values — a 0 or negative context length
# is always a bug and would poison the cache, causing downstream
# `get_model_context_length()` to return 0 (since `0 is not None`).
if length <= 0:
logger.warning(
"Refusing to cache non-positive context length %s -> %s tokens",
f"{model}@{base_url}", length,
)
return
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if cache.get(key) == length:
Expand Down Expand Up @@ -2664,8 +2673,19 @@ def get_model_context_length(
if base_url and not _skip_persistent_context_cache(base_url, provider):
cached = get_cached_context_length(model, base_url)
if cached is not None:
# Reject non-positive cached values — a 0 or negative value
# is always a bug (corrupted cache, probe failure, or manual
# edit). Without this guard, `0 is not None` short-circuits
# the resolution chain and the compressor gets context_length=0,
# breaking every status-bar and /usage display downstream.
if cached <= 0:
logger.warning(
"Dropping non-positive cache entry %s@%s -> %s; re-resolving",
model, base_url, cached,
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale 32k cache entries for Kimi-family models.
if cached <= 32768 and _model_name_suggests_kimi(model):
elif cached <= 32768 and _model_name_suggests_kimi(model):
logger.info(
"Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); "
"re-resolving via hardcoded defaults",
Expand Down
27 changes: 27 additions & 0 deletions tests/agent/test_model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,33 @@ def test_get_context_length_from_vllm_max_model_len_error(self):
class TestContextLengthCache:


def test_non_positive_lengths_never_persisted(self, tmp_path):
"""save_context_length must refuse 0/negative values — a persisted 0
short-circuits step 1 (``0 is not None``) and poisons the whole
resolution chain downstream (#25812)."""
cache_file = tmp_path / "cache.yaml"
with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file):
save_context_length("test/model", "http://x", 0)
save_context_length("test/model", "http://x", -1)
assert get_cached_context_length("test/model", "http://x") is None

@patch("agent.model_metadata.fetch_model_metadata")
def test_non_positive_cached_entry_dropped_and_reresolved(self, mock_fetch, tmp_path):
"""A pre-existing 0 entry (corrupted cache / manual edit) must be
invalidated at step 1 and re-resolved instead of returned."""
mock_fetch.return_value = {}
cache_file = tmp_path / "cache.yaml"
with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file):
# Write the poison entry directly — save_context_length now refuses it.
cache_file.write_text(
"context_lengths:\n test/model@http://x: 0\n", encoding="utf-8"
)
assert get_cached_context_length("test/model", "http://x") == 0
result = get_model_context_length("test/model", base_url="http://x")
assert result > 0
assert get_cached_context_length("test/model", "http://x") != 0


def test_null_context_lengths_key_returns_empty(self, tmp_path):
"""``context_lengths:`` with no value parses as None — must behave
like an empty cache instead of crashing every caller (#47135)."""
Expand Down
Loading