fix: preserve OpenRouter affinity when cache markers move

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Anton 2026-09-18 23:08:11 +03:00
parent 1851feedd4
commit 71d42a5cfc
3 changed files with 144 additions and 5 deletions

View file

@ -262,7 +262,12 @@ class _ProviderRoutingMixin:
first_user: Any = ""
for message in messages:
if str(message.get("role") or "") == "user":
first_user = message.get("content")
# Cache boundaries migrate during a task. Their markers and
# host-only block metadata must not rotate its routing key.
first_user = cls._copy_messages_with_cache_policy(
[message], allow_message_cache_control=False,
flatten_tool_content_blocks=False,
)[0].get("content")
break
serialized_user = json.dumps(
first_user,

View file

@ -2749,7 +2749,7 @@
"reason_code": "requested_wire_form",
"source_profile_fingerprint": "760c6157eb99a45072ed50ff3f02dda60fc887c3534865f85fbad14fcca919d1",
"accepted_profile_fingerprint": "760c6157eb99a45072ed50ff3f02dda60fc887c3534865f85fbad14fcca919d1",
"candidate_sha256": "085023a67633d3e571ad3492f17a02b112ebc45a77980651f9e00e1a4c1b39cc",
"candidate_sha256": "9451eeb0c00429e0c3b5e88d3ec0795aa56ed2818e36b1f238ff30669cd22b22",
"ladder_ordinal": 1,
"applied_actions": [],
"task_local": false,
@ -2780,7 +2780,7 @@
"effort": "medium",
"exclude": false
},
"session_id": "ouroboros-session-e8e49f8c93db5babc27cd74e553a4242"
"session_id": "ouroboros-session-f706174fa3ad320daf733cb9d635fe32"
},
"max_tokens": 65536,
"messages": [
@ -2860,7 +2860,7 @@
}
]
},
"payload_sha256": "085023a67633d3e571ad3492f17a02b112ebc45a77980651f9e00e1a4c1b39cc"
"payload_sha256": "9451eeb0c00429e0c3b5e88d3ec0795aa56ed2818e36b1f238ff30669cd22b22"
}
],
"physical_attempts": [
@ -2868,7 +2868,7 @@
"source": "llm.chat",
"model": "anthropic/claude-sonnet-4.6",
"provider": "openrouter",
"candidate_raw_sha256": "085023a67633d3e571ad3492f17a02b112ebc45a77980651f9e00e1a4c1b39cc",
"candidate_raw_sha256": "9451eeb0c00429e0c3b5e88d3ec0795aa56ed2818e36b1f238ff30669cd22b22",
"candidate_raw_size_bytes": 870,
"candidate_measurement_kind": "canonical_json_v1",
"states": [

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import asyncio
import copy
import json
import pytest
@ -80,6 +82,138 @@ def test_openrouter_uses_session_id_without_replacing_existing_extra_body(monkey
assert "prompt_cache_key" not in kwargs
def _sealed_transcripts():
from ouroboros.context_fit import seal_task_transcript
messages = _messages(stable="stable policy " * 800)
snapshots = []
for count in range(1, 8):
messages.extend([
{"role": "assistant", "content": "", "tool_calls": [{
"id": f"read-{count}", "type": "function",
"function": {"name": "read_file", "arguments": "{}"},
}]},
{"role": "tool", "tool_call_id": f"read-{count}", "content": f"evidence {count}"},
])
if count >= 5:
seal_task_transcript(messages)
snapshots.append(copy.deepcopy(messages))
# A compacted transcript can return the cache boundary to the task message.
messages = messages[:len(snapshots[0])]
seal_task_transcript(messages)
snapshots.append(messages)
return snapshots
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("model,cache_markers", [
("openai/gpt-5.5", False), ("anthropic/claude-fable-5", True),
])
def test_openrouter_session_survives_marker_migration_on_sdk_wire(asynchronous, model, cache_markers):
import httpx
import openai
from ouroboros.llm import LLMClient
snapshots = _sealed_transcripts()
originals = copy.deepcopy(snapshots)
client = LLMClient(api_key="unused")
target = {
"provider": "openrouter", "resolved_model": model, "usage_model": model,
"supports_openrouter_extensions": True,
}
tools = [{"type": "function", "function": {
"name": "read_file", "parameters": {"type": "object", "properties": {}},
}}]
payloads = [client._build_remote_kwargs(
target, messages, "high", 512, "auto", None, tools,
skip_capability_fetch=True,
) for messages in snapshots]
assert snapshots == originals, "affinity projection must not mutate the canonical transcript"
captured = []
def respond(request):
captured.append(json.loads(request.content))
return httpx.Response(200, json={
"id": "fixture", "object": "chat.completion", "created": 0, "model": model,
"choices": [{"index": 0, "message": {"role": "assistant", "content": "done"},
"finish_reason": "stop"}],
})
async def send_async():
async with openai.AsyncOpenAI(
api_key="fixture", base_url="https://fixture.invalid/v1", max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)),
) as sdk:
for payload in payloads:
await sdk.chat.completions.create(**payload)
if asynchronous:
asyncio.run(send_async())
else:
with openai.OpenAI(
api_key="fixture", base_url="https://fixture.invalid/v1", max_retries=0,
http_client=httpx.Client(transport=httpx.MockTransport(respond)),
) as sdk:
for payload in payloads:
sdk.chat.completions.create(**payload)
assert len(captured) == 4
assert len({payload["session_id"] for payload in captured}) == 1
assert all(payload["tools"] == captured[0]["tools"] for payload in captured)
boundaries = []
for payload in captured:
marked = [i for i, message in enumerate(payload["messages"])
if message["role"] != "system" and isinstance(message["content"], list)
for block in message["content"] if "cache_control" in block]
boundaries.append(marked)
if cache_markers:
assert boundaries == [[1], [3], [5], [1]], "supported markers must still migrate both ways"
else:
assert boundaries == [[], [], [], []]
for previous, current in zip(captured[:2], captured[1:3]):
assert current["messages"][:len(previous["messages"])] == previous["messages"]
assert captured[3]["messages"] == captured[0]["messages"]
def test_derived_session_ignores_transport_metadata_but_preserves_semantic_inputs():
from ouroboros.llm import LLMClient
messages = _messages()
messages[1]["content"] = [
{"type": "text", "text": "solve exactly"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA==", "detail": "high"}},
]
baseline = copy.deepcopy(messages)
identity = LLMClient._openrouter_session_identity("openai/gpt-5.5", messages)
for ttl in ("5m", "1h"):
annotated = copy.deepcopy(messages)
annotated[1]["content"][0]["cache_control"] = {"type": "ephemeral", "ttl": ttl}
annotated[1]["content"][1].update({
"_caption": "host caption", "_source_path": "/fixture/image.png", "_context_capsule": "host",
})
original = copy.deepcopy(annotated)
assert LLMClient._openrouter_session_identity("openai/gpt-5.5", annotated) == identity
assert annotated == original
changed = []
for text in ("another task", "solve exactly "):
candidate = copy.deepcopy(messages)
candidate[1]["content"][0]["text"] = text
changed.append(candidate)
candidate = copy.deepcopy(messages)
candidate[1]["content"][1]["image_url"]["url"] = "data:image/png;base64,AQ=="
changed.append(candidate)
candidate = copy.deepcopy(messages)
candidate[1]["content"].reverse()
changed.append(candidate)
candidate = copy.deepcopy(messages)
candidate[0]["content"][0]["text"] = "another policy"
changed.append(candidate)
assert all(LLMClient._openrouter_session_identity("openai/gpt-5.5", candidate) != identity
for candidate in changed)
assert LLMClient._openrouter_session_identity("openai/gpt-5.6-sol", messages) != identity
assert messages == baseline
def test_named_openai_cache_parameter_gets_one_exact_retry(monkeypatch):
import ouroboros.llm_attempt as llm_attempt_mod
from ouroboros.llm import LLMClient