1011 lines
38 KiB
Python
1011 lines
38 KiB
Python
"""Tests for the ChatCompletionsTransport."""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import OpenAI
|
|
|
|
from agent.transports import get_transport
|
|
from agent.transports.types import NormalizedResponse
|
|
|
|
|
|
@pytest.fixture
|
|
def transport():
|
|
import agent.transports.chat_completions # noqa: F401
|
|
return get_transport("chat_completions")
|
|
|
|
|
|
class TestChatCompletionsBasic:
|
|
@pytest.mark.parametrize(
|
|
"choice",
|
|
[SimpleNamespace(message=SimpleNamespace()), SimpleNamespace()],
|
|
)
|
|
def test_normalize_response_allows_missing_optional_message_fields(
|
|
self, transport, choice
|
|
):
|
|
response = SimpleNamespace(choices=[choice], usage=None)
|
|
|
|
normalized = transport.normalize_response(response)
|
|
|
|
assert normalized.content is None
|
|
assert normalized.tool_calls is None
|
|
assert normalized.finish_reason == "stop"
|
|
|
|
def test_normalize_response_allows_sparse_tool_call_fields(self, transport):
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
tool_calls=[
|
|
SimpleNamespace(
|
|
function=SimpleNamespace(arguments='{"city":"Paris"}')
|
|
),
|
|
SimpleNamespace(),
|
|
SimpleNamespace(
|
|
id="call-3",
|
|
function=SimpleNamespace(name="lookup"),
|
|
),
|
|
SimpleNamespace(
|
|
id="call-4",
|
|
function=SimpleNamespace(name="", arguments="{}"),
|
|
),
|
|
SimpleNamespace(
|
|
function=SimpleNamespace(
|
|
name="weather", arguments="{}"
|
|
)
|
|
),
|
|
]
|
|
)
|
|
)
|
|
],
|
|
usage=None,
|
|
)
|
|
|
|
normalized = transport.normalize_response(response)
|
|
|
|
assert normalized.finish_reason == "stop"
|
|
assert normalized.tool_calls is not None
|
|
assert [tool.id for tool in normalized.tool_calls] == [
|
|
"call-3",
|
|
"call-4",
|
|
None,
|
|
]
|
|
assert [tool.name for tool in normalized.tool_calls] == [
|
|
"lookup",
|
|
"",
|
|
"weather",
|
|
]
|
|
assert [tool.arguments for tool in normalized.tool_calls] == [
|
|
"{}",
|
|
"{}",
|
|
"{}",
|
|
]
|
|
|
|
@pytest.mark.parametrize("provider", ["nous", "openrouter"])
|
|
def test_gpt56_ultra_uses_max_wire_effort(self, transport, provider):
|
|
from providers import get_provider_profile
|
|
|
|
profile = get_provider_profile(provider)
|
|
kw = transport.build_kwargs(
|
|
model="openai/gpt-5.6-sol",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
tools=[],
|
|
reasoning_config={"enabled": True, "effort": "ultra"},
|
|
supports_reasoning=True,
|
|
provider_profile=profile,
|
|
provider_name=provider,
|
|
base_url=profile.base_url,
|
|
)
|
|
assert kw["extra_body"]["reasoning"] == {"enabled": True, "effort": "max"}
|
|
|
|
|
|
def test_convert_messages_no_codex_leaks(self, transport):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
result = transport.convert_messages(msgs)
|
|
assert result is msgs # no copy needed
|
|
|
|
|
|
|
|
def _msg_with_extra_content(self):
|
|
return [
|
|
{"role": "assistant", "content": "ok",
|
|
"tool_calls": [{"id": "call_1", "type": "function",
|
|
"extra_content": {"google": {"thought_signature": "SIG_123"}},
|
|
"function": {"name": "t", "arguments": "{}"}}]},
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_convert_messages_strips_timestamp(self, transport):
|
|
"""Internal per-message ``timestamp`` metadata (stamped by
|
|
``_apply_persist_user_message_override`` to preserve platform event
|
|
time without embedding it in content, and persisted to the SQLite
|
|
store) is not part of the OpenAI Chat Completions schema. Strict
|
|
providers like Mistral / Fireworks-backed endpoints reject it with
|
|
HTTP 422 'Extra inputs are not permitted, field: messages[N].timestamp'.
|
|
Regression test for #47868.
|
|
"""
|
|
msgs = [
|
|
{"role": "user", "content": "hi", "timestamp": 1781976577.0},
|
|
]
|
|
result = transport.convert_messages(msgs)
|
|
assert "timestamp" not in result[0]
|
|
assert result[0]["content"] == "hi"
|
|
assert result[0]["role"] == "user"
|
|
# Original list untouched (deepcopy-on-demand)
|
|
assert msgs[0]["timestamp"] == 1781976577.0
|
|
|
|
def test_convert_messages_strips_provider_replay_sidecars(self, transport):
|
|
"""Native-provider replay channels must not cross a provider boundary.
|
|
|
|
``bedrock_content_blocks`` intentionally remains in durable history so
|
|
Bedrock can restore signed/reasoning blocks in their original order.
|
|
Chat Completions providers do not recognize it, though, and strict
|
|
endpoints reject unknown keys in ``messages`` with HTTP 400/422.
|
|
"""
|
|
msgs = [
|
|
{"role": "user", "content": "use a tool"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "lookup", "arguments": "{}"},
|
|
}
|
|
],
|
|
"anthropic_content_blocks": [{"thinking": "signed"}],
|
|
"bedrock_content_blocks": [
|
|
{"reasoningContent": {"redactedContentBase64": "cmVhc29uaW5n"}},
|
|
{
|
|
"toolUse": {
|
|
"toolUseId": "call_1",
|
|
"name": "lookup",
|
|
"input": {},
|
|
}
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
result = transport.convert_messages(msgs, model="gpt-4o")
|
|
|
|
assert "anthropic_content_blocks" not in result[1]
|
|
assert "bedrock_content_blocks" not in result[1]
|
|
assert result[1]["tool_calls"] == msgs[1]["tool_calls"]
|
|
# Durable history remains available if this conversation returns to Bedrock.
|
|
assert "anthropic_content_blocks" in msgs[1]
|
|
assert "bedrock_content_blocks" in msgs[1]
|
|
|
|
def test_convert_messages_no_copy_without_timestamp(self, transport):
|
|
"""A timestamp-free message list needs no sanitize pass and is
|
|
returned by identity (preserves the deepcopy-on-demand contract)."""
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
assert transport.convert_messages(msgs) is msgs
|
|
|
|
def test_convert_messages_strips_internal_scaffolding_markers(self, transport):
|
|
"""Hermes-internal ``_``-prefixed markers must never reach the wire.
|
|
|
|
The empty-response recovery path appends synthetic messages tagged
|
|
with ``_empty_recovery_synthetic``; permissive providers ignore the
|
|
unknown key, but strict gateways (opencode-go, codex.nekos.me)
|
|
reject the request, poisoning every later turn in the session.
|
|
"""
|
|
msgs = [
|
|
{"role": "user", "content": "run the task"},
|
|
{"role": "assistant", "content": "(empty)", "_empty_recovery_synthetic": True},
|
|
{"role": "user", "content": "continue", "_empty_recovery_synthetic": True},
|
|
{"role": "assistant", "content": "done", "_thinking_prefill": True,
|
|
"_empty_terminal_sentinel": True},
|
|
]
|
|
result = transport.convert_messages(msgs)
|
|
for m in result:
|
|
assert not any(k.startswith("_") for k in m), m
|
|
# Visible content preserved
|
|
assert result[1]["content"] == "(empty)"
|
|
assert result[2]["content"] == "continue"
|
|
# Original list untouched (deepcopy-on-demand)
|
|
assert msgs[1]["_empty_recovery_synthetic"] is True
|
|
|
|
|
|
def test_convert_messages_copy_on_write_for_dirty_history(self, transport):
|
|
"""Dirty provider metadata should not force a full-history deepcopy."""
|
|
clean_tool_call = {
|
|
"id": "call_clean",
|
|
"type": "function",
|
|
"function": {"name": "safe", "arguments": "{}"},
|
|
}
|
|
msgs = [
|
|
{"role": "user", "content": "hi", "metadata": {"large": ["shared"]}},
|
|
{
|
|
"role": "assistant",
|
|
"content": "ok",
|
|
"tool_calls": [
|
|
clean_tool_call,
|
|
{
|
|
"id": "call_dirty",
|
|
"call_id": "call_dirty",
|
|
"response_item_id": "fc_dirty",
|
|
"extra_content": {"google": {"thought_signature": "SIG"}},
|
|
"type": "function",
|
|
"function": {"name": "t", "arguments": "{}"},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
result = transport.convert_messages(msgs, model="gpt-4o")
|
|
|
|
assert result is not msgs
|
|
assert result[0] is msgs[0]
|
|
assert result[1] is not msgs[1]
|
|
assert result[1]["tool_calls"] is not msgs[1]["tool_calls"]
|
|
assert result[1]["tool_calls"][0] is clean_tool_call
|
|
assert result[1]["tool_calls"][1] is not msgs[1]["tool_calls"][1]
|
|
assert "call_id" not in result[1]["tool_calls"][1]
|
|
assert "response_item_id" not in result[1]["tool_calls"][1]
|
|
assert "extra_content" not in result[1]["tool_calls"][1]
|
|
assert "call_id" in msgs[1]["tool_calls"][1]
|
|
assert "extra_content" in msgs[1]["tool_calls"][1]
|
|
|
|
|
|
|
|
class TestChatCompletionsBuildKwargs:
|
|
|
|
def test_basic_kwargs(self, transport):
|
|
msgs = [{"role": "user", "content": "Hello"}]
|
|
kw = transport.build_kwargs(model="gpt-4o", messages=msgs, timeout=30.0)
|
|
assert kw["model"] == "gpt-4o"
|
|
assert kw["messages"][0]["content"] == "Hello"
|
|
assert kw["timeout"] == 30.0
|
|
|
|
|
|
|
|
def test_tools_included(self, transport):
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
tools = [{"type": "function", "function": {"name": "test", "parameters": {}}}]
|
|
kw = transport.build_kwargs(model="gpt-4o", messages=msgs, tools=tools)
|
|
assert kw["tools"] == tools
|
|
|
|
def test_openrouter_provider_prefs(self, transport):
|
|
from providers import get_provider_profile
|
|
profile = get_provider_profile("openrouter")
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="gpt-4o", messages=msgs,
|
|
provider_profile=profile,
|
|
provider_preferences={"only": ["openai"]},
|
|
)
|
|
assert kw["extra_body"]["provider"] == {"only": ["openai"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nous_tags(self, transport):
|
|
from agent.portal_tags import nous_portal_tags
|
|
from providers import get_provider_profile
|
|
profile = get_provider_profile("nous")
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(model="gpt-4o", messages=msgs, provider_profile=profile)
|
|
assert kw["extra_body"]["tags"] == nous_portal_tags()
|
|
|
|
def test_reasoning_default(self, transport):
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="gpt-4o", messages=msgs,
|
|
supports_reasoning=True,
|
|
)
|
|
assert kw["extra_body"]["reasoning"] == {"enabled": True, "effort": "medium"}
|
|
|
|
def test_nous_omits_disabled_reasoning_for_unknown_model(self, transport):
|
|
from providers import get_provider_profile
|
|
profile = get_provider_profile("nous")
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="gpt-4o", messages=msgs,
|
|
provider_profile=profile,
|
|
supports_reasoning=True,
|
|
reasoning_config={"enabled": False},
|
|
)
|
|
# Not a Portal model id, so the catalog can't rule out a
|
|
# reasoning-mandatory route (which 400s on a disable) — omit.
|
|
# tests/plugins/model_providers/test_nous_profile.py covers the
|
|
# catalog-known cases where the disable IS forwarded.
|
|
assert "reasoning" not in kw.get("extra_body", {})
|
|
|
|
def test_ollama_num_ctx(self, transport):
|
|
from providers import get_provider_profile
|
|
profile = get_provider_profile("custom")
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="llama3", messages=msgs,
|
|
provider_profile=profile,
|
|
ollama_num_ctx=32768,
|
|
)
|
|
assert kw["extra_body"]["options"]["num_ctx"] == 32768
|
|
|
|
def test_custom_think_false(self, transport):
|
|
from providers import get_provider_profile
|
|
profile = get_provider_profile("custom")
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="qwen3", messages=msgs,
|
|
provider_profile=profile,
|
|
reasoning_config={"effort": "none"},
|
|
base_url="http://127.0.0.1:11434/v1",
|
|
)
|
|
assert kw["extra_body"]["think"] is False
|
|
|
|
def test_custom_omits_think_on_mistral(self, transport):
|
|
from providers import get_provider_profile
|
|
profile = get_provider_profile("custom")
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="mistral-small-latest",
|
|
messages=msgs,
|
|
provider_profile=profile,
|
|
reasoning_config={"enabled": False, "effort": "none"},
|
|
base_url="https://api.mistral.ai/v1",
|
|
)
|
|
assert kw.get("extra_body", {}).get("think") is None
|
|
assert kw.get("reasoning_effort") == "none"
|
|
|
|
|
|
|
|
def test_gemini_openai_compat_flash_reasoning_maps_to_nested_google_thinking_config(self, transport):
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="gemini-3-flash-preview",
|
|
messages=msgs,
|
|
provider_name="gemini",
|
|
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
|
reasoning_config={"enabled": True, "effort": "high"},
|
|
)
|
|
assert "thinking_config" not in kw["extra_body"]
|
|
assert kw["extra_body"]["extra_body"]["google"]["thinking_config"] == {
|
|
"include_thoughts": True,
|
|
"thinking_level": "high",
|
|
}
|
|
|
|
def test_gemini_ultra_thinking_raises_first_request_max_tokens(self, transport):
|
|
from agent.gemini_native_adapter import GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
|
|
from providers import get_provider_profile
|
|
|
|
profile = get_provider_profile("gemini")
|
|
kw = transport.build_kwargs(
|
|
model="gemini-3.7-flash",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
provider_profile=profile,
|
|
provider_name="gemini",
|
|
base_url=profile.base_url,
|
|
max_tokens=4096,
|
|
max_tokens_param_fn=lambda n: {"max_tokens": n},
|
|
reasoning_config={"enabled": True, "effort": "ultra"},
|
|
)
|
|
assert kw["max_tokens"] == GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
|
|
assert kw["extra_body"]["thinking_config"]["thinkingLevel"] == "high"
|
|
|
|
def test_gemini_without_thinking_keeps_explicit_max_tokens(self, transport):
|
|
from providers import get_provider_profile
|
|
|
|
profile = get_provider_profile("gemini")
|
|
kw = transport.build_kwargs(
|
|
model="gemini-3.7-flash",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
provider_profile=profile,
|
|
provider_name="gemini",
|
|
base_url=profile.base_url,
|
|
max_tokens=4096,
|
|
max_tokens_param_fn=lambda n: {"max_tokens": n},
|
|
)
|
|
assert kw["max_tokens"] == 4096
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_omit_temperature(self, transport):
|
|
"""Omit temperature is set via ProviderProfile with OMIT_TEMPERATURE sentinel."""
|
|
from providers.base import ProviderProfile, OMIT_TEMPERATURE
|
|
msgs = [{"role": "user", "content": "Hi"}]
|
|
kw = transport.build_kwargs(
|
|
model="gpt-4o", messages=msgs,
|
|
provider_profile=ProviderProfile(name="_t", fixed_temperature=OMIT_TEMPERATURE),
|
|
)
|
|
assert "temperature" not in kw
|
|
|
|
|
|
class TestChatCompletionsKimi:
|
|
"""Regression tests for the Kimi/Moonshot quirks migrated into the transport."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_moonshot_tool_schemas_are_sanitized_by_model_name(self, transport):
|
|
"""Aggregator routes (Nous, OpenRouter) hit Moonshot by model name, not base URL."""
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"description": "Search",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"q": {"description": "query"}, # missing type
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]
|
|
kw = transport.build_kwargs(
|
|
model="moonshotai/kimi-k2.6",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
tools=tools,
|
|
max_tokens_param_fn=lambda n: {"max_tokens": n},
|
|
)
|
|
assert kw["tools"][0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
|
|
|
|
def test_moonshot_outgoing_schema_carries_required_array(self, transport):
|
|
"""Moonshot 400s on object schemas without an explicit `required` array
|
|
(#66835). Assert the wire-level tool schema — what actually leaves the
|
|
transport — carries `required: []` on a zero-required-param tool."""
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "browser_snapshot",
|
|
"description": "Snapshot",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
]
|
|
kw = transport.build_kwargs(
|
|
model="moonshotai/kimi-k3",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
tools=tools,
|
|
max_tokens_param_fn=lambda n: {"max_tokens": n},
|
|
)
|
|
assert kw["tools"][0]["function"]["parameters"]["required"] == []
|
|
|
|
def test_non_moonshot_tools_are_not_mutated(self, transport):
|
|
"""Other models don't go through the Moonshot sanitizer."""
|
|
original_params = {
|
|
"type": "object",
|
|
"properties": {"q": {"description": "query"}}, # missing type
|
|
}
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search",
|
|
"description": "Search",
|
|
"parameters": original_params,
|
|
},
|
|
},
|
|
]
|
|
kw = transport.build_kwargs(
|
|
model="anthropic/claude-sonnet-4.6",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
tools=tools,
|
|
max_tokens_param_fn=lambda n: {"max_tokens": n},
|
|
)
|
|
# The parameters dict is passed through untouched (no synthetic type)
|
|
assert "type" not in kw["tools"][0]["function"]["parameters"]["properties"]["q"]
|
|
|
|
|
|
class TestChatCompletionsLmStudioReasoning:
|
|
"""LM Studio publishes per-model reasoning ``allowed_options``. When the
|
|
user requests an effort the model can't honor (e.g. ``high`` on a
|
|
toggle-style ``["off","on"]`` model), the transport omits
|
|
``reasoning_effort`` so LM Studio falls back to the model's default —
|
|
silently downgrading "high" to "low" would mislead the user.
|
|
"""
|
|
|
|
def test_omits_effort_when_high_not_allowed_toggle(self, transport):
|
|
kw = transport.build_kwargs(
|
|
model="gpt-oss", messages=[{"role": "user", "content": "Hi"}],
|
|
is_lmstudio=True,
|
|
supports_reasoning=True,
|
|
reasoning_config={"effort": "high"},
|
|
lmstudio_reasoning_options=["off", "on"],
|
|
)
|
|
assert "reasoning_effort" not in kw
|
|
|
|
|
|
def test_passes_through_when_effort_allowed(self, transport):
|
|
kw = transport.build_kwargs(
|
|
model="gpt-oss", messages=[{"role": "user", "content": "Hi"}],
|
|
is_lmstudio=True,
|
|
supports_reasoning=True,
|
|
reasoning_config={"effort": "high"},
|
|
lmstudio_reasoning_options=["off", "low", "medium", "high"],
|
|
)
|
|
assert kw["reasoning_effort"] == "high"
|
|
|
|
|
|
|
|
|
|
|
|
class TestChatCompletionsValidate:
|
|
|
|
def test_none(self, transport):
|
|
assert transport.validate_response(None) is False
|
|
|
|
|
|
|
|
def test_valid(self, transport):
|
|
r = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="hi"))])
|
|
assert transport.validate_response(r) is True
|
|
|
|
|
|
class TestChatCompletionsNormalize:
|
|
|
|
def test_text_response(self, transport):
|
|
r = SimpleNamespace(
|
|
choices=[SimpleNamespace(
|
|
message=SimpleNamespace(content="Hello", tool_calls=None, reasoning_content=None),
|
|
finish_reason="stop",
|
|
)],
|
|
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
|
)
|
|
nr = transport.normalize_response(r)
|
|
assert isinstance(nr, NormalizedResponse)
|
|
assert nr.content == "Hello"
|
|
assert nr.finish_reason == "stop"
|
|
assert nr.tool_calls is None
|
|
|
|
def test_tool_call_response(self, transport):
|
|
tc = SimpleNamespace(
|
|
id="call_123",
|
|
function=SimpleNamespace(name="terminal", arguments='{"command": "ls"}'),
|
|
)
|
|
r = SimpleNamespace(
|
|
choices=[SimpleNamespace(
|
|
message=SimpleNamespace(content=None, tool_calls=[tc], reasoning_content=None),
|
|
finish_reason="tool_calls",
|
|
)],
|
|
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=20, total_tokens=30),
|
|
)
|
|
nr = transport.normalize_response(r)
|
|
assert len(nr.tool_calls) == 1
|
|
assert nr.tool_calls[0].name == "terminal"
|
|
assert nr.tool_calls[0].id == "call_123"
|
|
|
|
|
|
|
|
def test_empty_reasoning_content_preserved(self, transport):
|
|
"""DeepSeek can require an explicit empty reasoning_content replay field."""
|
|
r = SimpleNamespace(
|
|
choices=[SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content=None,
|
|
tool_calls=None,
|
|
reasoning=None,
|
|
reasoning_content="",
|
|
),
|
|
finish_reason="stop",
|
|
)],
|
|
usage=None,
|
|
)
|
|
nr = transport.normalize_response(r)
|
|
assert nr.provider_data == {"reasoning_content": ""}
|
|
assert nr.reasoning_content == ""
|
|
|
|
|
|
|
|
def test_refusal_none_is_noop(self, transport):
|
|
"""The common case: ``refusal`` is None → behavior unchanged."""
|
|
r = SimpleNamespace(
|
|
choices=[SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="hello", tool_calls=None, reasoning_content=None,
|
|
refusal=None,
|
|
),
|
|
finish_reason="stop",
|
|
)],
|
|
usage=None,
|
|
)
|
|
nr = transport.normalize_response(r)
|
|
assert nr.finish_reason == "stop"
|
|
assert nr.content == "hello"
|
|
assert nr.provider_data is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestChatCompletionsCacheStats:
|
|
|
|
def test_no_usage(self, transport):
|
|
r = SimpleNamespace(usage=None)
|
|
assert transport.extract_cache_stats(r) is None
|
|
|
|
|
|
|
|
def test_deepseek_native_top_level_cache_hit_tokens(self, transport):
|
|
"""DeepSeek's native API (api.deepseek.com) reports cache hits as
|
|
top-level prompt_cache_hit_tokens, not the OpenAI nested shape —
|
|
the extractor must read it or direct DeepSeek sessions show 0%
|
|
cache hit rate (#61871)."""
|
|
r = SimpleNamespace(
|
|
usage=SimpleNamespace(
|
|
prompt_tokens_details=None,
|
|
prompt_cache_hit_tokens=1500,
|
|
prompt_cache_miss_tokens=500,
|
|
)
|
|
)
|
|
result = transport.extract_cache_stats(r)
|
|
assert result == {"cached_tokens": 1500, "creation_tokens": 0}
|
|
|
|
|
|
|
|
class TestChatCompletionsGeminiNativeExtraBodyStrip:
|
|
"""Profile extra_body (e.g. Nous portal tags) must not reach a native
|
|
Gemini endpoint — Google's REST API rejects unknown fields with HTTP 400.
|
|
"""
|
|
|
|
def _nous_profile(self):
|
|
from providers import get_provider_profile
|
|
return get_provider_profile("nous")
|
|
|
|
def test_tags_stripped_when_endpoint_is_native_gemini(self, transport):
|
|
kw = transport.build_kwargs(
|
|
"anthropic/claude-sonnet-4.6",
|
|
[{"role": "user", "content": "hi"}],
|
|
None,
|
|
provider_profile=self._nous_profile(),
|
|
base_url="https://generativelanguage.googleapis.com/v1beta",
|
|
session_id="s1",
|
|
max_tokens=None,
|
|
)
|
|
eb = kw.get("extra_body")
|
|
assert not eb or "tags" not in eb
|
|
|
|
def test_tags_preserved_on_nous_endpoint(self, transport):
|
|
kw = transport.build_kwargs(
|
|
"hermes-3-405b",
|
|
[{"role": "user", "content": "hi"}],
|
|
None,
|
|
provider_profile=self._nous_profile(),
|
|
base_url="https://inference.nousresearch.com/v1",
|
|
session_id="s1",
|
|
max_tokens=None,
|
|
)
|
|
eb = kw.get("extra_body")
|
|
assert eb and "tags" in eb
|
|
|
|
def test_tags_pass_through_on_gemini_openai_compat(self, transport):
|
|
# /openai compat endpoint is not "native" — unchanged behavior.
|
|
kw = transport.build_kwargs(
|
|
"anthropic/claude-sonnet-4.6",
|
|
[{"role": "user", "content": "hi"}],
|
|
None,
|
|
provider_profile=self._nous_profile(),
|
|
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
|
session_id="s1",
|
|
max_tokens=None,
|
|
)
|
|
eb = kw.get("extra_body")
|
|
assert eb and "tags" in eb
|
|
|
|
|
|
class TestPromptCacheKeyCapability:
|
|
"""Chat Completions cache routing is opt-in and body-safe."""
|
|
|
|
@staticmethod
|
|
def _messages(instructions="You are stable."):
|
|
return [
|
|
{"role": "system", "content": instructions},
|
|
{"role": "user", "content": "hello"},
|
|
]
|
|
|
|
@staticmethod
|
|
def _tools(name="lookup"):
|
|
return [{
|
|
"type": "function",
|
|
"function": {
|
|
"name": name,
|
|
"description": "Look something up.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}]
|
|
|
|
def _request_body(self, kwargs, *, stream=False):
|
|
captured = {}
|
|
|
|
def handler(request):
|
|
captured.update(json.loads(request.content))
|
|
if stream:
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=(
|
|
'data: {"id":"chatcmpl_1","object":"chat.completion.chunk",'
|
|
'"choices":[{"index":0,"delta":{"content":"ok"},'
|
|
'"finish_reason":null}]}\n\n'
|
|
"data: [DONE]\n\n"
|
|
),
|
|
)
|
|
return httpx.Response(200, json={
|
|
"id": "chatcmpl_1",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": kwargs["model"],
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "ok"},
|
|
"finish_reason": "stop",
|
|
}],
|
|
})
|
|
|
|
with httpx.Client(transport=httpx.MockTransport(handler)) as http_client:
|
|
client = OpenAI(
|
|
api_key="test-key",
|
|
base_url="https://cache-capable.test/v1",
|
|
http_client=http_client,
|
|
)
|
|
result = client.chat.completions.create(**kwargs, stream=stream)
|
|
if stream:
|
|
list(result)
|
|
return captured
|
|
|
|
def test_profile_capability_emits_content_key_in_nonstream_request_body(self, transport):
|
|
from providers.base import ProviderProfile
|
|
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model",
|
|
messages=self._messages(),
|
|
tools=self._tools(),
|
|
session_id="cron_job_2026-07-15T10:00:00Z",
|
|
provider_profile=ProviderProfile(
|
|
name="cache-capable", supports_prompt_cache_key=True,
|
|
),
|
|
)
|
|
|
|
body = self._request_body(kwargs)
|
|
|
|
assert body["prompt_cache_key"].startswith("pck_")
|
|
assert body["prompt_cache_key"] == kwargs["prompt_cache_key"]
|
|
|
|
def test_legacy_capability_emits_same_key_in_streaming_request_body(self, transport):
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model",
|
|
messages=self._messages(),
|
|
tools=self._tools(),
|
|
session_id="cron_job_2026-07-15T10:05:00Z",
|
|
supports_prompt_cache_key=True,
|
|
)
|
|
|
|
body = self._request_body(kwargs, stream=True)
|
|
|
|
assert body["prompt_cache_key"] == kwargs["prompt_cache_key"]
|
|
|
|
def test_openai_api_base_url_implies_capability(self, transport):
|
|
"""api.openai.com gets the key WITHOUT an explicit flag (exact host)."""
|
|
kwargs = transport.build_kwargs(
|
|
model="gpt-cache-model",
|
|
messages=self._messages(),
|
|
tools=self._tools(),
|
|
session_id="cron_job_2026-07-15T10:07:00Z",
|
|
base_url="https://api.openai.com/v1",
|
|
)
|
|
|
|
assert kwargs["prompt_cache_key"].startswith("pck_")
|
|
|
|
@pytest.mark.parametrize(
|
|
"base_url",
|
|
[
|
|
"https://myproxy.example.com/api.openai.com/v1", # host embedded in path
|
|
"https://api.openai.com.evil.example/v1", # prefix-spoofed host
|
|
"https://eastus.api.cognitive.microsoft.com/openai/v1", # Azure
|
|
],
|
|
)
|
|
def test_non_openai_hosts_do_not_imply_capability(self, transport, base_url):
|
|
kwargs = transport.build_kwargs(
|
|
model="strict-model",
|
|
messages=self._messages(),
|
|
tools=self._tools(),
|
|
session_id="cron_job_2026-07-15T10:08:00Z",
|
|
base_url=base_url,
|
|
)
|
|
|
|
assert "prompt_cache_key" not in kwargs
|
|
|
|
@pytest.mark.parametrize("provider", [None, "anthropic", "custom"])
|
|
def test_default_off_never_leaks_unknown_body_field(self, transport, provider):
|
|
from providers import get_provider_profile
|
|
|
|
kwargs = transport.build_kwargs(
|
|
model="strict-model",
|
|
messages=self._messages(),
|
|
tools=self._tools(),
|
|
session_id="cron_job_2026-07-15T10:00:00Z",
|
|
provider_profile=(get_provider_profile(provider) if provider else None),
|
|
)
|
|
|
|
body = self._request_body(kwargs)
|
|
|
|
assert "prompt_cache_key" not in kwargs
|
|
assert "prompt_cache_key" not in body
|
|
|
|
def test_explicit_top_level_and_extra_body_overrides_are_preserved(self, transport):
|
|
from providers.base import ProviderProfile
|
|
|
|
profile = ProviderProfile(name="cache-capable", supports_prompt_cache_key=True)
|
|
top_level = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
provider_profile=profile,
|
|
request_overrides={"prompt_cache_key": "caller-top-level"},
|
|
)
|
|
in_extra_body = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
provider_profile=profile,
|
|
request_overrides={"extra_body": {"prompt_cache_key": "caller-extra-body"}},
|
|
)
|
|
|
|
assert top_level["prompt_cache_key"] == "caller-top-level"
|
|
assert "prompt_cache_key" not in top_level.get("extra_body", {})
|
|
assert "prompt_cache_key" not in in_extra_body
|
|
assert in_extra_body["extra_body"]["prompt_cache_key"] == "caller-extra-body"
|
|
|
|
def test_cron_ids_share_static_prefix_key_and_content_changes_invalidate(self, transport):
|
|
def key(session_id, *, instructions="You are stable.", tool_name="lookup"):
|
|
return transport.build_kwargs(
|
|
model="cache-model",
|
|
messages=self._messages(instructions),
|
|
tools=self._tools(tool_name),
|
|
session_id=session_id,
|
|
supports_prompt_cache_key=True,
|
|
)["prompt_cache_key"]
|
|
|
|
first = key("cron_job_20260715_100000")
|
|
second = key("cron_job_20260715_100500")
|
|
|
|
assert first == second
|
|
assert first != key("cron_job_20260715_100500", instructions="You are different.")
|
|
assert first != key("cron_job_20260715_100500", tool_name="search")
|
|
|
|
def test_unrelated_sessions_get_distinct_keys(self, transport):
|
|
"""#78941: identical static prefix across unrelated (non-cron) sessions
|
|
must not collapse onto one shared prompt_cache_key."""
|
|
kw1 = transport.build_kwargs(
|
|
model="cache-model",
|
|
messages=self._messages("You are stable."),
|
|
tools=self._tools("lookup"),
|
|
session_id="session_alice_1",
|
|
supports_prompt_cache_key=True,
|
|
)
|
|
kw2 = transport.build_kwargs(
|
|
model="cache-model",
|
|
messages=self._messages("You are stable."),
|
|
tools=self._tools("lookup"),
|
|
session_id="session_bob_1",
|
|
supports_prompt_cache_key=True,
|
|
)
|
|
assert kw1["prompt_cache_key"] != kw2["prompt_cache_key"]
|
|
|
|
def test_stale_profile_without_supports_prompt_cache_key_does_not_crash(self, transport):
|
|
"""A ProviderProfile from a stale sys.modules cache (pre-#f4fb23f3d)
|
|
won't have the ``supports_prompt_cache_key`` field. Accessing it via
|
|
``profile.supports_prompt_cache_key`` raises AttributeError and crashes
|
|
every API call. Use getattr with a False default so it degrades to
|
|
"no prompt cache key" instead of crashing.
|
|
|
|
Regression: 'NousProfile' object has no attribute
|
|
'supports_prompt_cache_key' (Aug 2026, after partial update).
|
|
"""
|
|
from providers.base import ProviderProfile
|
|
|
|
# Simulate a stale class that predates supports_prompt_cache_key
|
|
# by creating a profile and deleting the attribute.
|
|
profile = ProviderProfile(name="stale-provider")
|
|
del profile.supports_prompt_cache_key
|
|
|
|
# Must not raise AttributeError — should fall back to False.
|
|
kwargs = transport.build_kwargs(
|
|
model="stale-model",
|
|
messages=self._messages(),
|
|
tools=self._tools(),
|
|
provider_profile=profile,
|
|
)
|
|
assert "prompt_cache_key" not in kwargs
|
|
|
|
def test_overlong_caller_top_level_key_is_bounded(self, transport):
|
|
"""OpenAI caps prompt_cache_key at 64 chars and 400s longer values.
|
|
|
|
A caller-supplied over-length key (request_overrides) must be hashed
|
|
to the same pck_<sha256[:24]> shape the Responses transport uses
|
|
(opencode#44571 parity — clamp on every chat protocol).
|
|
"""
|
|
from providers.base import ProviderProfile
|
|
|
|
profile = ProviderProfile(name="cache-capable", supports_prompt_cache_key=True)
|
|
long_key = "sess-" + "x" * 200
|
|
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
provider_profile=profile,
|
|
request_overrides={"prompt_cache_key": long_key},
|
|
)
|
|
|
|
assert kwargs["prompt_cache_key"].startswith("pck_")
|
|
assert len(kwargs["prompt_cache_key"]) <= 64
|
|
body = self._request_body(kwargs)
|
|
assert body["prompt_cache_key"] == kwargs["prompt_cache_key"]
|
|
|
|
def test_overlong_caller_extra_body_key_is_bounded(self, transport):
|
|
from providers.base import ProviderProfile
|
|
|
|
profile = ProviderProfile(name="cache-capable", supports_prompt_cache_key=True)
|
|
long_key = "sess-" + "y" * 200
|
|
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
provider_profile=profile,
|
|
request_overrides={"extra_body": {"prompt_cache_key": long_key}},
|
|
)
|
|
|
|
eb_key = kwargs["extra_body"]["prompt_cache_key"]
|
|
assert eb_key.startswith("pck_")
|
|
assert len(eb_key) <= 64
|
|
# No duplicate top-level field competing with the caller's extra_body.
|
|
assert "prompt_cache_key" not in kwargs
|
|
|
|
def test_short_caller_key_passes_through_unchanged(self, transport):
|
|
from providers.base import ProviderProfile
|
|
|
|
profile = ProviderProfile(name="cache-capable", supports_prompt_cache_key=True)
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
provider_profile=profile,
|
|
request_overrides={"prompt_cache_key": "caller-top-level"},
|
|
)
|
|
assert kwargs["prompt_cache_key"] == "caller-top-level"
|
|
|
|
def test_overlong_caller_key_bounded_on_legacy_path(self, transport):
|
|
long_key = "sess-" + "z" * 200
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
supports_prompt_cache_key=True,
|
|
request_overrides={"prompt_cache_key": long_key},
|
|
)
|
|
assert kwargs["prompt_cache_key"].startswith("pck_")
|
|
assert len(kwargs["prompt_cache_key"]) <= 64
|
|
|
|
def test_whitespace_only_caller_key_is_dropped(self, transport):
|
|
"""_bounded_prompt_cache_key returns None for blank keys — the field
|
|
must be removed rather than sent empty."""
|
|
from providers.base import ProviderProfile
|
|
|
|
profile = ProviderProfile(name="cache-capable", supports_prompt_cache_key=True)
|
|
kwargs = transport.build_kwargs(
|
|
model="cache-model", messages=self._messages(), tools=self._tools(),
|
|
provider_profile=profile,
|
|
request_overrides={"prompt_cache_key": " "},
|
|
)
|
|
assert "prompt_cache_key" not in kwargs
|