Merge pull request #3298 from nesquena/release/stage-batch4

v0.51.192: per-model context_length default-only guard (#3263, closes #3256) (stage-batch4)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-31 18:28:07 -07:00
commit 4c7c65192c
6 files changed
+517 -22

No files matched your search

+5
View File
@@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.192] — 2026-05-31 — Release FL (stage-batch4 — per-model context_length default-only guard)
### Fixed
- A global `model.context_length` cap (set in config for the default model, e.g. 232000) no longer silently shrinks **non-default** models' real context windows. The cap is now applied only when the session model equals `model.default`; other models (e.g. a 1M-context variant) keep their real metadata window. The guard is applied consistently across the session context-length resolver (`api/routes.py`), the per-turn persistence path, and the live SSE usage payload, and the auto-compress `threshold_tokens` is rescaled to the real cap so the context-window indicator and compression trigger reflect the actual window. The live-usage perf path caches the resolved per-model window once per stream (it runs ~10×/sec during streaming) so non-default-model streams don't take a config/metadata lookup on every metering tick. Backend-only; default-model sessions are unaffected. Closes #3256 (#3263, @allenliang2022).
## [v0.51.191] — 2026-05-31 — Release FK (stage-batch3 — skills-detail markdown styling + launchd duplicate-start guard)
### Fixed
+73 -1
View File
@@ -1750,6 +1750,70 @@ def _split_provider_qualified_model(model: str) -> tuple[str, str | None]:
return model, None
def _model_matches_configured_default(
session_model: str | None,
cfg_default: str | None,
provider: str | None = None,
) -> bool:
"""Return True when ``session_model`` refers to the configured ``model.default``.
The global ``model.context_length`` cap applies ONLY to the default model
(#3256/#3263). An exact string compare is not enough because ``model.default``
and the session model can be stored in different but equivalent shapes:
- bare: ``claude-opus-4.8``
- slash-prefixed: ``anthropic/claude-opus-4.8`` (OpenRouter-style)
- @provider:model: ``@anthropic:claude-opus-4.8``
Matching rule (correct in both directions):
1. Identical strings → match.
2. Otherwise compare BARE model ids — BUT only after a provider-compatibility
check: if BOTH sides carry an identifiable provider (from a ``provider/``
prefix, an ``@provider:`` qualifier, or the explicit ``provider`` arg for
the session side) and those providers DIFFER, it is NOT a match. This
stops a non-default model on a different provider that happens to share a
bare name (``openai/gpt-4o`` vs default ``openrouter/gpt-4o``) from being
treated as the default and wrongly receiving its cap.
3. When a provider can't be identified on one side, fall through to the bare
comparison (lenient-when-unknown — a bare default config still matches a
bare/prefixed session model).
Empty default → no match.
"""
sess = str(session_model or "").strip()
default = str(cfg_default or "").strip()
if not sess or not default:
return False
if sess == default:
return True
def _split(value: str) -> tuple[str, str | None]:
"""Return (bare_model, provider_or_None) for any of the 3 shapes."""
value = str(value or "").strip()
# @provider:model
unq, q_prov = _split_provider_qualified_model(value)
if q_prov:
return unq.strip(), str(q_prov).strip().lower()
# provider/model (single leading slash segment)
if "/" in value:
prefix, rest = value.split("/", 1)
return rest.strip(), prefix.strip().lower()
return value, None
sess_bare, sess_prov = _split(sess)
default_bare, default_prov = _split(default)
# The explicit provider arg is the session side's provider when the model
# string itself didn't carry one.
if not sess_prov and provider:
sess_prov = str(provider).strip().lower() or None
if not sess_bare or not default_bare or sess_bare != default_bare:
return False
# Bare ids match. Reject only when both sides name DIFFERENT providers.
if sess_prov and default_prov and sess_prov != default_prov:
return False
return True
def _should_attach_codex_provider_context(model: str, raw_active_provider: str, catalog: dict) -> bool:
"""Return True when a bare Codex model needs separate provider context.
@@ -2045,8 +2109,16 @@ def _resolve_context_length_for_session_model(
try:
_model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {}
if isinstance(_model_cfg_load, dict):
# Only apply the global model.context_length override when the
# session model matches model.default. Otherwise a global cap
# set for the default model (e.g. 232000) silently clobbers
# other models' real metadata (e.g. a 1M-context variant).
_cfg_default_model = str(_model_cfg_load.get('default') or '').strip()
_raw_cfg_ctx_load = _model_cfg_load.get('context_length')
if _raw_cfg_ctx_load is not None:
if _raw_cfg_ctx_load is not None and (
not _cfg_default_model
or _model_matches_configured_default(model_for_lookup, _cfg_default_model, provider)
):
try:
_parsed_load = int(_raw_cfg_ctx_load)
if _parsed_load > 0:
+217 -8
View File
@@ -3912,6 +3912,15 @@ def _run_agent_streaming(
_live_prompt_exact_tokens = [0]
_live_prompt_estimate_tool_delta_tokens = [0]
_live_prompt_estimate_seen_ids = set()
# Per-stream cache for the real per-model context_length (#3256 perf).
# _live_usage_snapshot() runs on every metering tick (~10x/sec during
# streaming); recomputing get_model_context_length() there triggered a
# config read + potential metadata/network probe on every token for
# non-default models (e.g. claude-opus-4.7-1m), freezing the stream while
# the default model was unaffected. The value is constant for a given
# (model, base_url, provider) within one stream, so resolve it at most
# once. Sentinel: None=not computed, 0=not applicable/failed, >0=real cap.
_real_ctx_cache = [None]
def _seed_live_prompt_estimate() -> int:
"""Capture the latest exact prompt size before adding live tool deltas."""
@@ -3988,9 +3997,76 @@ def _run_agent_streaming(
try:
_cc = getattr(_agent, 'context_compressor', None)
if _cc:
_usage['context_length'] = getattr(_cc, 'context_length', 0) or 0
_usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
_usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
_cc_cl_u = getattr(_cc, 'context_length', 0) or 0
# Default-only guard (#3256): the agent-side compressor is
# built in agent_init with the global model.context_length
# applied unconditionally — for non-default models that
# value is the stale global cap (e.g. 232K). Drop it here
# so the live usage payload doesn't surface the wrong cap.
# PERF: resolve the real per-model cap at most once per
# stream (cached in _real_ctx_cache). This snapshot runs on
# every metering tick; doing the config read + metadata
# lookup per tick froze non-default-model streams.
if _real_ctx_cache[0] is None:
_resolved_real = 0 # 0 = guard not applicable / failed
try:
from api.config import get_config as _gc_u
_cfg_u = _gc_u()
_mcfg_u = _cfg_u.get('model', {}) if isinstance(_cfg_u, dict) else {}
if isinstance(_mcfg_u, dict):
_def_u = str(_mcfg_u.get('default') or '').strip()
_raw_u = _mcfg_u.get('context_length')
try:
_cl_u = int(_raw_u) if _raw_u is not None else 0
except (TypeError, ValueError):
_cl_u = 0
_sm_u = str(getattr(_agent, 'model', '') or '').strip()
from api.routes import _model_matches_configured_default as _mmcd_u
if (
_cl_u > 0
and _cc_cl_u == _cl_u
and _def_u
and _sm_u
and not _mmcd_u(_sm_u, _def_u, getattr(_agent, 'provider', '') or '')
):
# Recompute from real per-model metadata.
try:
from agent.model_metadata import get_model_context_length as _g_u
_real_u = _g_u(
_sm_u,
getattr(_agent, 'base_url', '') or '',
config_context_length=None,
provider=getattr(_agent, 'provider', '') or '',
) or 0
if _real_u:
_resolved_real = _real_u
except Exception:
pass
except Exception:
_resolved_real = 0
_real_ctx_cache[0] = _resolved_real
# Apply the cached real cap when the guard determined one.
if _real_ctx_cache[0]:
# Also rescale threshold_tokens by the same ratio so the
# auto-compress trigger reflects the real window, not
# the stale global cap (e.g. 197.2k @ 232K cap → ~850k
# @ 1M real cap).
_orig_cc_cl = getattr(_cc, 'context_length', 0) or 0
_orig_thresh = getattr(_cc, 'threshold_tokens', 0) or 0
_cc_cl_u = _real_ctx_cache[0]
if _orig_cc_cl > 0 and _orig_thresh > 0:
_scaled_thresh = int(_orig_thresh * _real_ctx_cache[0] / _orig_cc_cl)
_usage['context_length'] = _cc_cl_u
_usage['threshold_tokens'] = _scaled_thresh
_usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
else:
_usage['context_length'] = _cc_cl_u
_usage['threshold_tokens'] = _orig_thresh
_usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
else:
_usage['context_length'] = _cc_cl_u
_usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
_usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
except Exception:
pass
@@ -5793,8 +5869,45 @@ def _run_agent_streaming(
# block writes them to the session itself so GET /api/session
# returns them on reload instead of falling back to 0.
_cc_for_save = getattr(agent, 'context_compressor', None)
# Initialized before the compressor block so the #3256/#3263
# threshold-rescale below is safe even when there is no
# compressor (fresh agent / interrupted stream): _skip_cc_cl
# stays False and _cc_cl stays 0, so the rescale is a no-op.
_skip_cc_cl = False
_cc_cl = 0
if _cc_for_save:
s.context_length = getattr(_cc_for_save, 'context_length', 0) or 0
_cc_cl = getattr(_cc_for_save, 'context_length', 0) or 0
# Same guard as routes._resolve_context_length_for_session_model:
# the agent-side context_compressor was constructed with the
# global model.context_length applied to EVERY model. If the
# session's model isn't model.default, that value is a stale
# cap (e.g. 232K) that would clobber the real 1M metadata
# on every stream end. In that case skip the compressor
# value and let the fallback resolver below recompute.
_skip_cc_cl = False
try:
_model_cfg_cc = _cfg.get('model', {}) if isinstance(_cfg, dict) else {}
if isinstance(_model_cfg_cc, dict):
_cfg_default_cc = str(_model_cfg_cc.get('default') or '').strip()
_raw_cfg_cl_cc = _model_cfg_cc.get('context_length')
try:
_cfg_cl_cc = int(_raw_cfg_cl_cc) if _raw_cfg_cl_cc is not None else 0
except (TypeError, ValueError):
_cfg_cl_cc = 0
_sess_model_cc = str(getattr(agent, 'model', resolved_model or '') or '').strip()
from api.routes import _model_matches_configured_default as _mmcd_cc
if (
_cfg_cl_cc > 0
and _cc_cl == _cfg_cl_cc
and _cfg_default_cc
and _sess_model_cc
and not _mmcd_cc(_sess_model_cc, _cfg_default_cc, resolved_provider or '')
):
_skip_cc_cl = True
except Exception:
pass
if not _skip_cc_cl:
s.context_length = _cc_cl
s.threshold_tokens = getattr(_cc_for_save, 'threshold_tokens', 0) or 0
s.last_prompt_tokens = getattr(_cc_for_save, 'last_prompt_tokens', 0) or 0
# Fallback: if the compressor didn't report a context_length
@@ -5812,7 +5925,14 @@ def _run_agent_streaming(
# window in the persisted session and the SSE payload —
# which then trips LCM auto-compress at ~25% of the wrong
# value, cascading into 429 floods.
if not getattr(s, 'context_length', 0):
#
# #3256/#3263: ALSO run this fallback when _skip_cc_cl is true
# (non-default model whose compressor carried the stale global
# cap). Without this, a session that already had a stale 232K
# context_length persisted keeps it forever — skipping the
# compressor write removes the re-clobber but never recomputes
# the real per-model window. Recompute and overwrite in that case.
if (not getattr(s, 'context_length', 0)) or _skip_cc_cl:
try:
from agent.model_metadata import get_model_context_length
_cfg_ctx_len = None
@@ -5821,7 +5941,20 @@ def _run_agent_streaming(
_model_cfg_for_ctx = _cfg.get('model', {}) if isinstance(_cfg, dict) else {}
if isinstance(_model_cfg_for_ctx, dict):
_raw_cfg_ctx = _model_cfg_for_ctx.get('context_length')
if _raw_cfg_ctx is not None:
# Default-only guard: only apply the global
# model.context_length cap when the session
# model equals model.default. Otherwise the
# cap (e.g. 232K set for the default model)
# silently shrinks other models' real metadata.
_cfg_default_ctx = str(_model_cfg_for_ctx.get('default') or '').strip()
_sess_model_ctx = str(getattr(agent, 'model', resolved_model or '') or '').strip()
from api.routes import _model_matches_configured_default as _mmcd_ctx
_apply_cfg_ctx = (
not _cfg_default_ctx
or not _sess_model_ctx
or _mmcd_ctx(_sess_model_ctx, _cfg_default_ctx, resolved_provider or '')
)
if _raw_cfg_ctx is not None and _apply_cfg_ctx:
try:
_parsed_cfg_ctx = int(_raw_cfg_ctx)
if _parsed_cfg_ctx > 0:
@@ -5863,6 +5996,23 @@ def _run_agent_streaming(
# Older hermes-agent builds may not expose this helper.
# Better to leave context_length=0 than crash the save.
pass
# #3256/#3263: when we skipped the stale compressor cap for a
# non-default model and recomputed the real per-model window
# above, rescale the persisted threshold_tokens to that real cap
# so the auto-compress trigger and the reloaded context-ring
# match the live snapshot (which already rescales). Without this,
# a reload shows a smaller compression trigger than streaming did.
# Only rescale when both the original cap and threshold are
# positive; otherwise clear the threshold to 0 (consistent with
# the live-snapshot path) rather than leave a stale value.
if _skip_cc_cl:
_orig_cap = _cc_cl # the stale global cap the compressor reported
_orig_thresh = getattr(s, 'threshold_tokens', 0) or 0
_real_cap = getattr(s, 'context_length', 0) or 0
if _real_cap > 0 and _orig_cap > 0 and _orig_thresh > 0:
s.threshold_tokens = int(_orig_thresh * _real_cap / _orig_cap)
else:
s.threshold_tokens = 0
if not ephemeral and s.messages:
_latest_assistant_idx = next(
(idx for idx in range(len(s.messages) - 1, -1, -1)
@@ -5991,7 +6141,45 @@ def _run_agent_streaming(
# survive a page reload; this block only populates the live SSE usage payload.
_cc = getattr(agent, 'context_compressor', None)
if _cc:
usage['context_length'] = getattr(_cc, 'context_length', 0) or 0
_cc_cl_sse = getattr(_cc, 'context_length', 0) or 0
# #3256/#3263: remember the original compressor cap + threshold
# so that if we drop the stale cap below and the fallback
# resolves the real per-model window, we can rescale the
# threshold consistently (the live snapshot already does this).
_orig_cc_cl_sse = _cc_cl_sse
_orig_cc_thresh_sse = getattr(_cc, 'threshold_tokens', 0) or 0
_dropped_stale_cap_sse = False
# Default-only guard (#3256): the agent-side context_compressor
# is constructed in agent_init with the global model.context_length
# applied unconditionally, so for non-default models its
# context_length is the stale global cap (e.g. 232K) — surfacing
# it via SSE makes the indicator show the wrong window even
# after the session was correctly resized. Drop the compressor
# value in that case and let the fallback resolver below recompute.
try:
_model_cfg_sse = _cfg.get('model', {}) if isinstance(_cfg, dict) else {}
if isinstance(_model_cfg_sse, dict):
_cfg_default_sse = str(_model_cfg_sse.get('default') or '').strip()
_raw_cfg_cl_sse = _model_cfg_sse.get('context_length')
try:
_cfg_cl_sse = int(_raw_cfg_cl_sse) if _raw_cfg_cl_sse is not None else 0
except (TypeError, ValueError):
_cfg_cl_sse = 0
_sess_model_sse = str(getattr(agent, 'model', resolved_model or '') or '').strip()
from api.routes import _model_matches_configured_default as _mmcd_sse
if (
_cfg_cl_sse > 0
and _cc_cl_sse == _cfg_cl_sse
and _cfg_default_sse
and _sess_model_sse
and not _mmcd_sse(_sess_model_sse, _cfg_default_sse, resolved_provider or '')
):
_cc_cl_sse = 0
_dropped_stale_cap_sse = True
except Exception:
pass
if _cc_cl_sse:
usage['context_length'] = _cc_cl_sse
usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
# Fallback: when the compressor is absent or reports context_length=0,
@@ -6013,7 +6201,19 @@ def _run_agent_streaming(
_model_cfg_for_ctx = _cfg.get('model', {}) if isinstance(_cfg, dict) else {}
if isinstance(_model_cfg_for_ctx, dict):
_raw_cfg_ctx = _model_cfg_for_ctx.get('context_length')
if _raw_cfg_ctx is not None:
# Default-only guard (see #3256): the global
# model.context_length cap only applies to
# model.default; other models keep their real
# metadata.
_cfg_default_ctx = str(_model_cfg_for_ctx.get('default') or '').strip()
_sess_model_ctx = str(getattr(agent, 'model', resolved_model or '') or '').strip()
from api.routes import _model_matches_configured_default as _mmcd_sfb
_apply_cfg_ctx = (
not _cfg_default_ctx
or not _sess_model_ctx
or _mmcd_sfb(_sess_model_ctx, _cfg_default_ctx, resolved_provider or '')
)
if _raw_cfg_ctx is not None and _apply_cfg_ctx:
try:
_parsed_cfg_ctx = int(_raw_cfg_ctx)
if _parsed_cfg_ctx > 0:
@@ -6041,6 +6241,15 @@ def _run_agent_streaming(
)
if _fb_cl:
usage['context_length'] = _fb_cl
# #3256/#3263: if we dropped the stale compressor cap
# for a non-default model, the threshold_tokens written
# above is still the stale compressor value. Rescale it
# to the real resolved window so the terminal `done`
# payload matches the live snapshot (which rescales) —
# otherwise messages.js overwrites S.lastUsage with the
# stale threshold and the indicator reverts on stream end.
if _dropped_stale_cap_sse and _orig_cc_cl_sse > 0 and _orig_cc_thresh_sse > 0:
usage['threshold_tokens'] = int(_orig_cc_thresh_sse * _fb_cl / _orig_cc_cl_sse)
except Exception:
pass
# Fallback: when last_prompt_tokens is missing (no compressor), use the
@@ -0,0 +1,203 @@
"""Regression coverage for #3256 / #3263: the global model.context_length cap
must apply ONLY when the session model equals model.default.
Bug: a global `model.context_length` (set for the default model, e.g. 232000)
was applied to EVERY model, silently shrinking a non-default model's real
context window (e.g. a 1M-context variant). The fix scopes the override to
`model.default` only, in `_resolve_context_length_for_session_model`.
This test drives the real resolver with a monkeypatched
`agent.model_metadata.get_model_context_length` that records the
`config_context_length` it receives, so we assert the gating without needing
the real agent metadata catalog.
"""
import sys
import types
from pathlib import Path as _Path
def _install_fake_get_model_context_length(monkeypatch, recorder):
"""Install a fake get_model_context_length into a stand-in agent.model_metadata."""
mod = types.ModuleType("agent.model_metadata")
def _fake(model, base_url="", config_context_length=None, provider="", custom_providers=None):
recorder["model"] = model
recorder["config_context_length"] = config_context_length
# Pretend the real per-model metadata window is 1,000,000 unless the
# caller forced a config cap, in which case honor the cap (mirrors the
# real helper's contract).
if config_context_length:
return int(config_context_length)
return 1_000_000
mod.get_model_context_length = _fake
# Ensure a parent `agent` package exists so `from agent.model_metadata import ...` resolves.
if "agent" not in sys.modules:
agent_pkg = types.ModuleType("agent")
agent_pkg.__path__ = []
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
monkeypatch.setitem(sys.modules, "agent.model_metadata", mod)
def _resolver():
import api.routes as routes
return routes._resolve_context_length_for_session_model
def test_global_cap_applies_to_default_model(monkeypatch):
"""When the session model IS model.default, the global cap is passed through."""
import api.config as config
rec = {}
_install_fake_get_model_context_length(monkeypatch, rec)
monkeypatch.setattr(
config, "get_config",
lambda *a, **k: {"model": {"default": "claude-opus-4.8", "context_length": 232000}},
)
result = _resolver()("claude-opus-4.8")
assert rec["config_context_length"] == 232000, "default model must receive the global cap"
assert result == 232000
def test_global_cap_NOT_applied_to_non_default_model(monkeypatch):
"""When the session model is NOT model.default, the global cap is dropped so
the model's real (larger) window is used — the core #3256/#3263 fix."""
import api.config as config
rec = {}
_install_fake_get_model_context_length(monkeypatch, rec)
monkeypatch.setattr(
config, "get_config",
lambda *a, **k: {"model": {"default": "claude-opus-4.8", "context_length": 232000}},
)
result = _resolver()("claude-opus-4.7-1m-internal")
assert rec["config_context_length"] is None, (
"non-default model must NOT receive the global cap (it would clobber real metadata)"
)
assert result == 1_000_000, "non-default model should resolve to its real 1M window, not the 232K cap"
def test_no_default_configured_still_applies_cap(monkeypatch):
"""If model.default is unset, the cap applies (backward-compatible)."""
import api.config as config
rec = {}
_install_fake_get_model_context_length(monkeypatch, rec)
monkeypatch.setattr(
config, "get_config",
lambda *a, **k: {"model": {"context_length": 200000}},
)
result = _resolver()("some-model")
assert rec["config_context_length"] == 200000
assert result == 200000
def test_empty_model_returns_zero(monkeypatch):
rec = {}
_install_fake_get_model_context_length(monkeypatch, rec)
assert _resolver()("") == 0
assert _resolver()(None) == 0
# --- #3263 provider-aware default match (Codex final-gate finding, v0.51.192) ---
# model.default and the session model can be stored in equivalent-but-different
# shapes (bare / provider-prefixed / @provider:model). An exact string compare
# wrongly treats the actual default model as non-default and drops its configured
# context_length cap. _model_matches_configured_default() normalizes the shapes.
def _matcher():
import api.routes as routes
return routes._model_matches_configured_default
def test_default_match_exact():
assert _matcher()("claude-opus-4.8", "claude-opus-4.8") is True
def test_default_match_bare_session_vs_prefixed_default():
# Config default is provider-prefixed; session model is bare — still the default.
assert _matcher()("claude-opus-4.8", "anthropic/claude-opus-4.8", "anthropic") is True
def test_default_match_prefixed_session_vs_bare_default():
assert _matcher()("anthropic/claude-opus-4.8", "claude-opus-4.8", "anthropic") is True
def test_default_match_at_qualified_session():
assert _matcher()("@anthropic:claude-opus-4.8", "claude-opus-4.8") is True
def test_default_match_distinct_models_do_not_match():
assert _matcher()("claude-opus-4.7-1m", "claude-opus-4.8", "anthropic") is False
assert _matcher()("claude-opus-4.7-1m", "anthropic/claude-opus-4.8", "anthropic") is False
assert _matcher()("gpt-5.5", "claude-opus-4.8", "openai") is False
def test_default_match_same_bare_different_provider_does_NOT_match():
"""Same bare model id on a DIFFERENT provider is NOT the configured default
and must not receive its cap (Codex final-gate over-match finding)."""
assert _matcher()("openai/gpt-4o", "openrouter/gpt-4o", "openai") is False
assert _matcher()("@openai:gpt-4o", "@openrouter:gpt-4o") is False
assert _matcher()("gpt-4o", "openrouter/gpt-4o", "openai") is False
# but the SAME provider (or unknown session provider) still matches:
assert _matcher()("openrouter/gpt-4o", "openrouter/gpt-4o") is True
assert _matcher()("gpt-4o", "openrouter/gpt-4o", "openrouter") is True
def test_default_match_empty_inputs_are_false():
assert _matcher()("claude-opus-4.8", "") is False
assert _matcher()("", "claude-opus-4.8") is False
def test_prefixed_default_still_receives_cap(monkeypatch):
"""The actual default model, configured provider-prefixed, must still get
the global cap when the session stores it bare (the Codex final-gate bug)."""
import api.config as config
rec = {}
_install_fake_get_model_context_length(monkeypatch, rec)
monkeypatch.setattr(
config, "get_config",
lambda *a, **k: {"model": {"default": "anthropic/claude-opus-4.8", "context_length": 232000}},
)
# session model is the bare form of the prefixed default
result = _resolver()("claude-opus-4.8", "anthropic")
assert rec["config_context_length"] == 232000, (
"provider-prefixed default model must still receive its configured cap"
)
assert result == 232000
# --- #3263 dual-gate MUST-FIX invariants (Codex regression gate, v0.51.192) ---
# These pin the two consistency fixes applied after the gate found that the
# default-only guard dropped the stale cap but didn't (a) recompute a persisted
# stale context_length, or (b) rescale the terminal SSE threshold. Both live
# deep inside _run_agent_streaming, so we pin them at the source-structure level
# (the live-snapshot path already had behavioral coverage; these guard the two
# sibling paths from silently regressing back to the stale value).
_STREAMING_SRC = (_Path(__file__).resolve().parent.parent / "api" / "streaming.py").read_text(encoding="utf-8")
def test_persistence_fallback_also_runs_when_skip_cc_cl():
"""The per-turn persistence fallback must recompute the real cap when the
stale compressor cap was skipped — not only when context_length is falsy.
Otherwise a previously-persisted stale 232K survives forever."""
assert "(not getattr(s, 'context_length', 0)) or _skip_cc_cl:" in _STREAMING_SRC, (
"persistence fallback gate must also fire on _skip_cc_cl (#3263 MUST-FIX 1)"
)
def test_persistence_rescales_threshold_when_cap_skipped():
"""When the stale cap is skipped and the real cap recomputed, the persisted
threshold_tokens must be rescaled to the real cap (or cleared), so a reload
matches the live snapshot."""
assert "if _skip_cc_cl:" in _STREAMING_SRC
assert "s.threshold_tokens = int(_orig_thresh * _real_cap / _orig_cap)" in _STREAMING_SRC, (
"persistence path must rescale threshold_tokens to the real cap (#3263 MUST-FIX 2)"
)
def test_sse_done_payload_rescales_threshold_when_cap_dropped():
"""The terminal SSE usage payload must rescale threshold_tokens when it
dropped the stale compressor cap, so the indicator doesn't revert on stream
end (messages.js overwrites S.lastUsage with this payload)."""
assert "_dropped_stale_cap_sse" in _STREAMING_SRC
assert "usage['threshold_tokens'] = int(_orig_cc_thresh_sse * _fb_cl / _orig_cc_cl_sse)" in _STREAMING_SRC, (
"SSE done payload must rescale threshold_tokens to the resolved window (#3263 MUST-FIX 3)"
)
+11 -7
View File
@@ -47,17 +47,21 @@ def test_fallback_uses_model_metadata():
def test_fallback_gates_on_falsy_context_length():
"""Fallback runs only when the compressor didn't populate s.context_length.
"""Fallback runs when the compressor didn't populate s.context_length.
The gate must check s.context_length (not _cc_for_save) — if the compressor
set context_length but it was 0, we still want the fallback to fire.
The gate must still check s.context_length being falsy — if the compressor
set context_length but it was 0, we want the fallback to fire. (#3256/#3263
widened the gate to ALSO fire on `_skip_cc_cl` — a non-default model whose
compressor carried the stale global cap — so the gate is now
`(not getattr(s, 'context_length', 0)) or _skip_cc_cl`. The falsy-check
remains; this test asserts that invariant is intact, not the exact spelling.)
"""
block = _persistence_block()
# The conditional must reference s.context_length (or getattr(s, 'context_length', 0))
# The conditional must still reference s.context_length being falsy.
assert (
"if not getattr(s, 'context_length'" in block
or "if not s.context_length" in block
), "Fallback must gate on s.context_length being falsy"
"not getattr(s, 'context_length'" in block
or "not s.context_length" in block
), "Fallback must still gate on s.context_length being falsy"
def test_fallback_passes_model_and_base_url():
@@ -38,12 +38,14 @@ def test_streaming_persists_context_fields_on_session_before_save():
# Save call follows shortly after
save_call = src.find("\n s.save()", block_start)
assert save_call != -1, "s.save() not found after the post-merge marker"
# Limit bumped to 9000 by cancellation finalization guards: the block now also
# checks for a late user cancel immediately before the durable final save,
# preventing a race that would otherwise save/emit a completed turn after Stop.
# The context_length fallback is still a single focused resolver call with
# arg-prep scaffold and commentary explaining the failure mode it prevents.
assert save_call - block_start < 9000, (
# Limit bumped to 15000 by the #3256/#3263 default-only context_length guard
# plus its dual-gate consistency fixes (recompute persisted stale cap +
# rescale threshold_tokens). The pre-save block legitimately grew here. NOTE:
# this byte-distance assertion is itself brittle (it must be bumped whenever a
# legitimate pre-save mutation block is added) — a structural check (presence
# of s.save() shortly after the post-merge marker) would be more durable; left
# as a follow-up. Earlier limits: 9000 (cancellation guards) → 13000 (#3263 v1).
assert save_call - block_start < 15000, (
"s.save() should be close to the post-merge marker — block expanded unexpectedly. "
"If you've added a new pre-save mutation block here, bump this limit."
)