From 71d42a5cfcf3e4df5a92719ba4c732d05573e083 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 18 Sep 2026 23:08:11 +0300 Subject: [PATCH] fix: preserve OpenRouter affinity when cache markers move Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> --- ouroboros/llm_routing.py | 7 +- .../llm_golden/openrouter_payload.json | 8 +- tests/test_prompt_cache_v664.py | 134 ++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/ouroboros/llm_routing.py b/ouroboros/llm_routing.py index 938f91549..f2979923a 100644 --- a/ouroboros/llm_routing.py +++ b/ouroboros/llm_routing.py @@ -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, diff --git a/tests/fixtures/llm_golden/openrouter_payload.json b/tests/fixtures/llm_golden/openrouter_payload.json index 31475ef9e..787cee9e0 100644 --- a/tests/fixtures/llm_golden/openrouter_payload.json +++ b/tests/fixtures/llm_golden/openrouter_payload.json @@ -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": [ diff --git a/tests/test_prompt_cache_v664.py b/tests/test_prompt_cache_v664.py index dbe062f93..456196bbe 100644 --- a/tests/test_prompt_cache_v664.py +++ b/tests/test_prompt_cache_v664.py @@ -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