511 lines
16 KiB
Python
511 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from opensquilla.provider import openai as openai_module
|
|
from opensquilla.provider.anthropic import AnthropicProvider
|
|
from opensquilla.provider.openai import OpenAIProvider
|
|
from opensquilla.provider.types import (
|
|
ChatConfig,
|
|
ContentBlockImage,
|
|
ContentBlockText,
|
|
ContentBlockToolResult,
|
|
DoneEvent,
|
|
ErrorEvent,
|
|
Message,
|
|
ToolDefinition,
|
|
ToolInputSchema,
|
|
)
|
|
|
|
|
|
def _openai_sse_body(model: str = "test-model") -> bytes:
|
|
chunks = [
|
|
{
|
|
"model": model,
|
|
"choices": [{"delta": {"content": "ok"}, "finish_reason": None}],
|
|
},
|
|
{
|
|
"model": model,
|
|
"choices": [{"delta": {}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 1},
|
|
},
|
|
]
|
|
return b"".join(
|
|
f"data: {json.dumps(chunk)}\n\n".encode() for chunk in chunks
|
|
) + b"data: [DONE]\n\n"
|
|
|
|
|
|
def _anthropic_sse_body(events: list[dict[str, Any]]) -> bytes:
|
|
parts: list[bytes] = []
|
|
for event in events:
|
|
parts.append(f"event: {event['type']}\n".encode())
|
|
parts.append(f"data: {json.dumps(event)}\n\n".encode())
|
|
return b"".join(parts)
|
|
|
|
|
|
def test_openai_final_request_proof_blocks_oversized_send(monkeypatch: Any) -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(500)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", patched_async_client)
|
|
provider = OpenAIProvider(api_key="test", model="gpt-test")
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
[Message(role="user", content="x" * 5000)],
|
|
config=ChatConfig(provider_request_max_chars=1000),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert requests == []
|
|
assert isinstance(events[0], ErrorEvent)
|
|
assert events[0].code == "provider_request_budget_exhausted"
|
|
proof = json.loads(events[0].message)
|
|
assert proof["fits"] is False
|
|
assert proof["retry_count"] == 2
|
|
assert proof["top_contributors"][0]["chars"] == 5000
|
|
|
|
|
|
def test_openai_env_request_proof_blocks_as_controlled_error(monkeypatch: Any) -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(500)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setenv("OPENSQUILLA_PROVIDER_REQUEST_PROOF_MAX_CHARS", "1")
|
|
monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", patched_async_client)
|
|
provider = OpenAIProvider(api_key="test", model="gpt-test")
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
[Message(role="user", content="hello")],
|
|
config=ChatConfig(provider_request_max_chars=0),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert requests == []
|
|
assert isinstance(events[0], ErrorEvent)
|
|
assert events[0].code == "provider_request_budget_exhausted"
|
|
|
|
|
|
def test_openai_final_request_proof_allows_native_image_payload(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
requests: list[httpx.Request] = []
|
|
payloads: list[dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
payloads.append(json.loads(request.content.decode("utf-8")))
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=_openai_sse_body(),
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", patched_async_client)
|
|
provider = OpenAIProvider(
|
|
api_key="test",
|
|
model="vision-test",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
provider_kind="openrouter",
|
|
)
|
|
messages = [
|
|
Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockText(text="describe this image"),
|
|
ContentBlockImage(
|
|
source_type="base64",
|
|
media_type="image/png",
|
|
data="a" * 5000,
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
messages,
|
|
config=ChatConfig(provider_request_max_chars=1000),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert len(requests) == 1
|
|
assert any(isinstance(event, DoneEvent) for event in events)
|
|
media_url = payloads[0]["messages"][0]["content"][1]["image_url"]["url"]
|
|
assert media_url.startswith("data:image/png;base64,")
|
|
assert len(media_url) > 5000
|
|
|
|
|
|
def test_anthropic_final_request_proof_blocks_oversized_send(monkeypatch: Any) -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(500)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
provider = AnthropicProvider(api_key="test", model="claude-test")
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
[Message(role="user", content="x" * 5000)],
|
|
config=ChatConfig(provider_request_max_chars=1000),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert requests == []
|
|
assert isinstance(events[0], ErrorEvent)
|
|
assert events[0].code == "provider_request_budget_exhausted"
|
|
proof = json.loads(events[0].message)
|
|
assert proof["fits"] is False
|
|
assert proof["retry_count"] == 2
|
|
|
|
|
|
def test_anthropic_env_request_proof_blocks_as_controlled_error(monkeypatch: Any) -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(500)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setenv("OPENSQUILLA_PROVIDER_REQUEST_PROOF_MAX_CHARS", "1")
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
provider = AnthropicProvider(api_key="test", model="claude-test")
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
[Message(role="user", content="hello")],
|
|
config=ChatConfig(provider_request_max_chars=0),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert requests == []
|
|
assert isinstance(events[0], ErrorEvent)
|
|
assert events[0].code == "provider_request_budget_exhausted"
|
|
|
|
|
|
def test_anthropic_final_request_proof_allows_native_image_payload(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
requests: list[httpx.Request] = []
|
|
payloads: list[dict[str, Any]] = []
|
|
|
|
body = _anthropic_sse_body(
|
|
[
|
|
{
|
|
"type": "message_start",
|
|
"message": {"id": "msg_1", "model": "claude-test", "usage": {}},
|
|
},
|
|
{
|
|
"type": "content_block_delta",
|
|
"delta": {"type": "text_delta", "text": "ok"},
|
|
},
|
|
{"type": "message_delta", "usage": {"output_tokens": 1}},
|
|
{"type": "message_stop"},
|
|
]
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
payloads.append(json.loads(request.content.decode("utf-8")))
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=body,
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
provider = AnthropicProvider(api_key="test", model="claude-test")
|
|
messages = [
|
|
Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockText(text="describe this image"),
|
|
ContentBlockImage(
|
|
source_type="base64",
|
|
media_type="image/png",
|
|
data="a" * 5000,
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
messages,
|
|
config=ChatConfig(provider_request_max_chars=1000),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert len(requests) == 1
|
|
assert any(isinstance(event, DoneEvent) for event in events)
|
|
media_source = payloads[0]["messages"][0]["content"][1]["source"]
|
|
assert media_source == {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": "a" * 5000,
|
|
}
|
|
|
|
|
|
def _large_schema_tool() -> ToolDefinition:
|
|
return ToolDefinition(
|
|
name="large_schema_tool",
|
|
description="Tool with enough schema text to prove final adapter payload accounting.",
|
|
input_schema=ToolInputSchema(
|
|
properties={
|
|
"payload": {
|
|
"type": "string",
|
|
"description": "schema details " * 120,
|
|
}
|
|
},
|
|
required=["payload"],
|
|
),
|
|
)
|
|
|
|
|
|
def test_openai_final_request_proof_compacts_adapter_payload_with_tools(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
requests: list[httpx.Request] = []
|
|
payloads: list[dict[str, Any]] = []
|
|
proofs: list[dict[str, Any]] = []
|
|
budget = 3600
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
payloads.append(json.loads(request.content.decode("utf-8")))
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=_openai_sse_body(),
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", patched_async_client)
|
|
real_log = openai_module.log
|
|
|
|
class _ProofLogShim:
|
|
# Swap the module's ``log`` attribute wholesale instead of patching
|
|
# ``log.info``: getattr on the BoundLoggerLazyProxy (which monkeypatch's
|
|
# save step performs) binds an ephemeral logger to the processor list of
|
|
# that moment, and teardown would install that frozen bound method on
|
|
# the proxy permanently, breaking capture_logs for later tests.
|
|
def info(self, event: str, **kwargs: Any) -> None:
|
|
if event == "provider.request_proof":
|
|
proofs.append(kwargs)
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(real_log, name)
|
|
|
|
monkeypatch.setattr(openai_module, "log", _ProofLogShim())
|
|
provider = OpenAIProvider(api_key="test", model="gpt-test")
|
|
messages = [
|
|
Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockToolResult(
|
|
tool_use_id="call_1",
|
|
content="tool output " * 500,
|
|
)
|
|
],
|
|
)
|
|
]
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
messages,
|
|
tools=[_large_schema_tool()],
|
|
config=ChatConfig(
|
|
system="system prompt " * 20,
|
|
provider_request_max_chars=budget,
|
|
),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert len(requests) == 1
|
|
assert any(isinstance(event, DoneEvent) for event in events)
|
|
assert len(requests[0].content) <= budget
|
|
assert any(
|
|
"provider_request" in message.get("content", "")
|
|
for message in payloads[0]["messages"]
|
|
)
|
|
proof = proofs[-1]
|
|
assert proof["fits"] is True
|
|
assert proof["retry_count"] >= 1
|
|
assert proof["messages_chars"] > 0
|
|
assert proof["tools_chars"] > 0
|
|
assert proof["system_chars"] > 0
|
|
assert proof["top_level_chars"] > 0
|
|
|
|
|
|
def test_anthropic_final_request_proof_compacts_adapter_payload_with_tools(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
requests: list[httpx.Request] = []
|
|
payloads: list[dict[str, Any]] = []
|
|
proofs: list[dict[str, Any]] = []
|
|
budget = 3600
|
|
body = _anthropic_sse_body(
|
|
[
|
|
{
|
|
"type": "message_start",
|
|
"message": {"id": "msg_1", "model": "claude-test", "usage": {}},
|
|
},
|
|
{
|
|
"type": "content_block_delta",
|
|
"delta": {"type": "text_delta", "text": "ok"},
|
|
},
|
|
{"type": "message_delta", "usage": {"output_tokens": 1}},
|
|
{"type": "message_stop"},
|
|
]
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
payloads.append(json.loads(request.content.decode("utf-8")))
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=body,
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
|
kwargs["transport"] = transport
|
|
return real_async_client(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("opensquilla.provider.anthropic.httpx.AsyncClient", patched_async_client)
|
|
monkeypatch.setattr(
|
|
"opensquilla.provider.anthropic.log.info",
|
|
lambda event, **kwargs: (
|
|
proofs.append(kwargs) if event == "provider.request_proof" else None
|
|
),
|
|
)
|
|
provider = AnthropicProvider(api_key="test", model="claude-test")
|
|
messages = [
|
|
Message(
|
|
role="user",
|
|
content=[
|
|
ContentBlockToolResult(
|
|
tool_use_id="call_1",
|
|
content="tool output " * 500,
|
|
)
|
|
],
|
|
)
|
|
]
|
|
|
|
async def run() -> list[Any]:
|
|
return [
|
|
event
|
|
async for event in provider.chat(
|
|
messages,
|
|
tools=[_large_schema_tool()],
|
|
config=ChatConfig(
|
|
system="system prompt " * 20,
|
|
provider_request_max_chars=budget,
|
|
),
|
|
)
|
|
]
|
|
|
|
events = asyncio.run(run())
|
|
|
|
assert len(requests) == 1
|
|
assert any(isinstance(event, DoneEvent) for event in events)
|
|
assert len(requests[0].content) <= budget
|
|
assert "provider_request" in payloads[0]["messages"][0]["content"][0]["content"]
|
|
proof = proofs[-1]
|
|
assert proof["fits"] is True
|
|
assert proof["retry_count"] >= 1
|
|
assert proof["messages_chars"] > 0
|
|
assert proof["tools_chars"] > 0
|
|
assert proof["system_chars"] > 0
|
|
assert proof["top_level_chars"] > 0
|