Files
DocsGPT/tests/llm/test_responses_chain_budget.py
T
arc53-machine f882ef49a7 refactor: read settings directly instead of getattr with a second default
About 85 call sites read a setting as getattr(settings, "NAME", fallback),
each carrying its own copy of the default. Every one of those names is a
field with a default on the model, so the fallback could never apply to
the real settings object; it only masked drift. Two had drifted:

- OPENAI_PROMPT_CACHE_KEY defaults to True on the model but the reader
  fell back to False, and two test stubs relied on that.
- SharePoint's MICROSOFT_AUTHORITY fallback to
  https://login.microsoftonline.com/<tenant> never fired, because the
  attribute always exists (as None), so MSAL got authority=None. The
  connector now derives the tenant authority when the setting is unset,
  as its test always assumed.

Four places read EMBEDDINGS_KEY straight from os.environ, skipping the
"None"/"" normalisation the model applies; they read the setting now.
Test stubs that replaced a module's settings with a SimpleNamespace list
every setting the code under test reads.
2026-09-17 11:14:34 +01:00

420 lines
15 KiB
Python

"""Bounded cross-turn chaining, chained system-head dedupe and prompt-cache
hints on the OpenAI Responses path.
Background (prod, 2026-09-01/03): ``previous_response_id`` chained every user
turn onto the previous one, so Azure's stored transcript grew without bound
(889k prompt tokens for a 37k-token local history) while every local guard
measured the local history. Each chained round also re-sent the system
message, which the server appends rather than dedupes, and after a
compression the rebuilt local messages were still chained onto the
uncompressed transcript.
"""
import types
from unittest.mock import MagicMock
import pytest
from docsgpt.core.model_settings import ModelCapabilities
def _make_llm(monkeypatch, store_responses=True, **extra_settings):
monkeypatch.setattr("docsgpt.llm.openai.OpenAI", MagicMock())
monkeypatch.setattr(
"docsgpt.llm.openai.StorageCreator",
types.SimpleNamespace(get_storage=lambda: None),
)
# Every setting the Responses path reads, with the hints off; tests opt in per case.
stub = {
"OPENAI_API_KEY": "k",
"API_KEY": "k",
"OPENAI_BASE_URL": "",
"AZURE_DEPLOYMENT_NAME": "dep",
"OPENAI_RESPONSES_STORE": store_responses,
"OPENAI_REASONING_SUMMARY": "auto",
"OPENAI_RESPONSES_TRUNCATION_AUTO": False,
"OPENAI_PROMPT_CACHE_KEY": False,
"OPENAI_PROMPT_CACHE_RETENTION": None,
}
monkeypatch.setattr("docsgpt.llm.openai.settings", types.SimpleNamespace(**{**stub, **extra_settings}))
from docsgpt.llm.openai import OpenAILLM
llm = OpenAILLM(api_key="k")
llm.capabilities = ModelCapabilities(
supports_tools=True, supports_structured_output=True, api_flavor="responses"
)
return llm
def _messages(system="sys"):
return [
{"role": "system", "content": system},
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"},
{"role": "user", "content": "q2"},
]
def _roles(items):
return [i.get("role") for i in items if isinstance(i, dict) and i.get("role")]
def _accepted(llm, rid="resp_1"):
"""The provider accepted the request just built: record its response."""
llm._record_responses_metadata(types.SimpleNamespace(id=rid, output=[], usage=None))
# ── chained system head ──────────────────────────────────────────────────────
@pytest.mark.unit
def test_chained_input_omits_unchanged_system_head(monkeypatch):
llm = _make_llm(monkeypatch)
# The unchained send is what puts the system message into the stored
# transcript; the chained follow-up must not append a second copy.
first, prev = llm._build_responses_input(_messages(), None)
assert prev is None
assert "system" in _roles(first)
_accepted(llm)
chained, prev = llm._build_responses_input(_messages(), "resp_1")
assert prev == "resp_1"
assert _roles(chained) == ["user"]
@pytest.mark.unit
def test_chained_input_resends_changed_system_head(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm)
chained, _ = llm._build_responses_input(_messages("sys v2"), "resp_1")
assert _roles(chained) == ["system", "user"]
_accepted(llm, "resp_2")
# ...and the new head becomes the one the chain holds.
again, _ = llm._build_responses_input(_messages("sys v2"), "resp_2")
assert _roles(again) == ["user"]
@pytest.mark.unit
def test_chained_system_head_hash_roundtrips_through_state(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
_accepted(llm)
state = llm.export_responses_state()
assert state.get("system_hash")
resumed = _make_llm(monkeypatch)
assert resumed.import_responses_state(state) is True
chained, _ = resumed._build_responses_input(_messages(), "resp_1")
assert _roles(chained) == ["user"]
@pytest.mark.unit
def test_start_responses_turn_forgets_system_head(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
_accepted(llm)
llm.start_responses_turn()
chained, _ = llm._build_responses_input(_messages(), "resp_1")
# A fresh chain has no head on the server yet, so the head is sent.
assert _roles(chained) == ["system", "user"]
@pytest.mark.unit
def test_unchained_input_always_carries_system(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
again, prev = llm._build_responses_input(_messages(), None)
assert prev is None
assert "system" in _roles(again)
# ── request params: truncation + cache hints ────────────────────────────────
def _params(llm, **kwargs):
return llm._build_responses_params(
"gpt-5.6", [{"role": "user", "content": []}], tools=None,
response_format=None, previous_response_id=None, stream=True,
kwargs=kwargs,
)
@pytest.mark.unit
def test_build_responses_params_omits_truncation_and_cache_hints_when_off(monkeypatch):
llm = _make_llm(monkeypatch)
llm._prompt_cache_key = "conv-123"
params = _params(llm)
assert "truncation" not in params
assert "prompt_cache_key" not in params
assert "prompt_cache_retention" not in params
@pytest.mark.unit
def test_build_responses_params_truncation_and_cache_hints(monkeypatch):
llm = _make_llm(
monkeypatch,
OPENAI_RESPONSES_TRUNCATION_AUTO=True,
OPENAI_PROMPT_CACHE_KEY=True,
OPENAI_PROMPT_CACHE_RETENTION="24h",
)
llm._prompt_cache_key = "conv-123"
params = _params(llm)
assert params["truncation"] == "auto"
assert params["prompt_cache_key"] == "conv-123"
assert params["prompt_cache_retention"] == "24h"
@pytest.mark.unit
def test_build_responses_params_cache_key_needs_a_conversation(monkeypatch):
llm = _make_llm(monkeypatch, OPENAI_PROMPT_CACHE_KEY=True)
llm._prompt_cache_key = None
assert "prompt_cache_key" not in _params(llm)
# ── agent: when does the next turn chain? ───────────────────────────────────
def _agent(monkeypatch, history, last_compression_at=None, **overrides):
from docsgpt.agents import base as base_mod
from docsgpt.agents.base import BaseAgent
class _Agent(BaseAgent):
def _gen_inner(self, query, log_context):
yield from ()
agent = _Agent.__new__(_Agent)
agent.chat_history = history
agent.llm = types.SimpleNamespace(
responses_chain_key=lambda: "key",
_uses_responses_api=lambda: True,
)
agent.model_id = "m"
agent.model_user_id = None
agent.user = "u"
agent.last_compression_at = last_compression_at
defaults = {
"OPENAI_RESPONSES_STORE": True,
"OPENAI_RESPONSES_CHAIN_ACROSS_TURNS": True,
"OPENAI_RESPONSES_CHAIN_BUDGET_TOKENS": None,
}
defaults.update(overrides)
for key, value in defaults.items():
monkeypatch.setattr(base_mod.settings, key, value, raising=False)
monkeypatch.setattr(
"docsgpt.core.model_utils.get_token_limit", lambda *a, **k: 1000
)
return agent
def _turn(prompt_tokens, epoch=None, rid="resp_1"):
meta = {
"response_id": rid,
"response_chain_key": "key",
"usage": {"prompt_tokens": prompt_tokens},
}
if epoch:
meta["compression_epoch"] = epoch
return {"prompt": "q", "response": "a", "metadata": meta}
@pytest.mark.unit
def test_previous_response_id_chains_within_budget(monkeypatch):
agent = _agent(monkeypatch, [_turn(500)])
assert agent._previous_response_id() == "resp_1"
@pytest.mark.unit
def test_previous_response_id_stops_at_model_window(monkeypatch):
# Last turn already cost the whole window: start this one from the
# bounded local history instead of growing Azure's transcript further.
agent = _agent(monkeypatch, [_turn(1000)])
assert agent._previous_response_id() is None
@pytest.mark.unit
def test_previous_response_id_honours_explicit_budget(monkeypatch):
agent = _agent(
monkeypatch, [_turn(500)], OPENAI_RESPONSES_CHAIN_BUDGET_TOKENS=400
)
assert agent._previous_response_id() is None
@pytest.mark.unit
def test_previous_response_id_chains_when_usage_unknown(monkeypatch):
turn = _turn(0)
turn["metadata"].pop("usage")
agent = _agent(monkeypatch, [turn])
assert agent._previous_response_id() == "resp_1"
@pytest.mark.unit
def test_previous_response_id_kill_switch(monkeypatch):
agent = _agent(
monkeypatch, [_turn(10)], OPENAI_RESPONSES_CHAIN_ACROSS_TURNS=False
)
assert agent._previous_response_id() is None
@pytest.mark.unit
def test_previous_response_id_breaks_after_newer_compression(monkeypatch):
agent = _agent(
monkeypatch,
[_turn(10, epoch="2026-09-03T09:00:00+00:00")],
last_compression_at="2026-09-03T10:00:00+00:00",
)
assert agent._previous_response_id() is None
@pytest.mark.unit
def test_previous_response_id_chains_when_turn_saw_the_compression(monkeypatch):
epoch = "2026-09-03T10:00:00+00:00"
agent = _agent(monkeypatch, [_turn(10, epoch=epoch)], last_compression_at=epoch)
assert agent._previous_response_id() == "resp_1"
@pytest.mark.unit
def test_previous_response_id_compression_epoch_format_tolerant(monkeypatch):
# JSONB round-trips give "2026-09-03 10:00:00.123+00:00"; the point
# itself is written as an ISO datetime. Both must compare equal.
agent = _agent(
monkeypatch,
[_turn(10, epoch="2026-09-03T10:00:00.123000+00:00")],
last_compression_at="2026-09-03 10:00:00.123+00:00",
)
assert agent._previous_response_id() == "resp_1"
@pytest.mark.unit
def test_previous_response_id_breaks_when_turn_predates_any_compression(monkeypatch):
agent = _agent(
monkeypatch, [_turn(10)], last_compression_at="2026-09-03T10:00:00+00:00"
)
assert agent._previous_response_id() is None
@pytest.mark.unit
def test_emit_responses_metadata_records_compression_epoch(monkeypatch):
agent = _agent(monkeypatch, [], last_compression_at="2026-09-03T10:00:00+00:00")
agent.llm = types.SimpleNamespace(
responses_chain_key=lambda: "key",
_uses_responses_api=lambda: True,
_last_response_id="resp_9",
_last_usage={"prompt_tokens": 1},
export_responses_state=lambda: {"chain_key": "key"},
)
events = list(agent._emit_responses_metadata())
assert events and events[0]["metadata"]["compression_epoch"] == "2026-09-03T10:00:00+00:00"
assert events[0]["metadata"]["response_id"] == "resp_9"
# ── cache key is opaque ─────────────────────────────────────────────────────
@pytest.mark.unit
def test_cache_key_for_user_is_opaque_and_stable():
from docsgpt.agents.base import _cache_key_for_user
key = _cache_key_for_user("user_2Vhzgd63RSgixvvbF8Z2nhtqnE9")
assert key and "user_2Vhzgd" not in key
assert len(key) == 32 and all(c in "0123456789abcdef" for c in key)
assert key == _cache_key_for_user("user_2Vhzgd63RSgixvvbF8Z2nhtqnE9")
assert key != _cache_key_for_user("someone-else")
assert _cache_key_for_user(None) is None
# ── the head hash commits only once the provider accepted the request ───────
@pytest.mark.unit
def test_system_hash_commits_only_when_the_response_is_recorded(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
# Not recorded (the request failed before the provider stored it): a
# chained follow-up must still carry the head.
chained, _ = llm._build_responses_input(_messages(), "resp_1")
assert _roles(chained) == ["system", "user"]
_accepted(llm)
chained, _ = llm._build_responses_input(_messages(), "resp_1")
assert _roles(chained) == ["user"]
@pytest.mark.unit
def test_failed_chained_request_resends_changed_head_on_retry(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm)
first, _ = llm._build_responses_input(_messages("sys v2"), "resp_1")
assert _roles(first) == ["system", "user"]
# Transport error before any chunk: nothing recorded. The same-primary
# retry chains onto resp_1 again, whose stored head is still "sys v1".
retry, _ = llm._build_responses_input(_messages("sys v2"), "resp_1")
assert _roles(retry) == ["system", "user"]
_accepted(llm, "resp_2")
after, _ = llm._build_responses_input(_messages("sys v2"), "resp_2")
assert _roles(after) == ["user"]
# ── a failed non-streaming response must not become chain state ─────────────
@pytest.mark.unit
def test_failed_response_records_no_chain_state(monkeypatch):
llm = _make_llm(monkeypatch)
# An accepted turn with head "sys v1".
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm, "resp_ok")
committed = llm._chain_system_hash
failed = types.SimpleNamespace(
id="resp_bad", status="failed", error=types.SimpleNamespace(message="boom"),
incomplete_details=None, output=[], usage=None,
)
llm.client.responses.create = MagicMock(return_value=failed)
with pytest.raises(RuntimeError):
llm._responses_gen(
"gpt-5.6", _messages("sys v2"), tools=None, previous_response_id="resp_ok"
)
# Neither the failed id nor the head it carried became chain state
# (the in-turn id is cleared before every request; the retry chains via
# the caller's previous_response_id)...
assert llm._last_response_id != "resp_bad"
assert llm._chain_system_hash == committed
# ...so the retry chains onto resp_ok and re-sends the changed head.
retry, prev = llm._build_responses_input(_messages("sys v2"), "resp_ok")
assert prev == "resp_ok"
assert _roles(retry) == ["system", "user"]
@pytest.mark.unit
def test_output_capped_response_still_records_chain_state(monkeypatch):
llm = _make_llm(monkeypatch)
capped = types.SimpleNamespace(
id="resp_len", status="incomplete",
incomplete_details=types.SimpleNamespace(reason="max_output_tokens"),
output=[types.SimpleNamespace(type="message", content=[
types.SimpleNamespace(type="output_text", text="partial")])],
usage=None, error=None,
)
llm.client.responses.create = MagicMock(return_value=capped)
assert llm._responses_gen("gpt-5.6", _messages(), tools=None) == "partial"
assert llm._last_response_id == "resp_len"
assert llm._chain_system_hash is not None
@pytest.mark.unit
def test_recorded_request_without_a_head_clears_the_committed_hash(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm, "resp_1")
# An unchained fallback with no system message at all is recorded: the
# new transcript holds no head, so the old hash must not survive it.
llm._build_responses_input([{"role": "user", "content": "q"}], None)
_accepted(llm, "resp_2")
assert llm._chain_system_hash is None
chained, _ = llm._build_responses_input(_messages("sys v1"), "resp_2")
assert _roles(chained) == ["system", "user"]