Files
wehub-resource-sync 0418dc5cf9
CI / Python tests (3.10) (push) Has been cancelled
CI / Python tests (3.11) (push) Has been cancelled
CI / Python quality (push) Has been cancelled
CI / Frontend typecheck (push) Has been cancelled
Autopilot Pages / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:45:00 +08:00

500 lines
18 KiB
Python

"""Tests for the OpenAI-compatible API client."""
from __future__ import annotations
import json
import httpx
import pytest
from openharness.api.client import ApiMessageRequest
from openharness.api.openai_client import (
OpenAICompatibleClient,
_convert_assistant_message,
_convert_messages_to_openai,
_convert_tools_to_openai,
_normalize_openai_base_url,
_strip_think_blocks,
_token_limit_param_for_model,
)
from openharness.engine.messages import (
ConversationMessage,
ImageBlock,
TextBlock,
ToolResultBlock,
ToolUseBlock,
)
class TestConvertToolsToOpenai:
"""Test Anthropic → OpenAI tool schema conversion."""
def test_basic_tool(self):
anthropic_tools = [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path"},
},
"required": ["path"],
},
}
]
result = _convert_tools_to_openai(anthropic_tools)
assert len(result) == 1
assert result[0]["type"] == "function"
assert result[0]["function"]["name"] == "read_file"
assert result[0]["function"]["description"] == "Read a file"
assert result[0]["function"]["parameters"]["properties"]["path"]["type"] == "string"
def test_empty_tools(self):
assert _convert_tools_to_openai([]) == []
def test_multiple_tools(self):
tools = [
{"name": "tool_a", "description": "A", "input_schema": {}},
{"name": "tool_b", "description": "B", "input_schema": {}},
]
result = _convert_tools_to_openai(tools)
assert len(result) == 2
assert result[0]["function"]["name"] == "tool_a"
assert result[1]["function"]["name"] == "tool_b"
class TestConvertMessagesToOpenai:
"""Test Anthropic → OpenAI message format conversion."""
def test_system_prompt(self):
messages: list[ConversationMessage] = []
result = _convert_messages_to_openai(messages, "You are helpful.")
assert len(result) == 1
assert result[0]["role"] == "system"
assert result[0]["content"] == "You are helpful."
def test_no_system_prompt(self):
messages = [ConversationMessage.from_user_text("hi")]
result = _convert_messages_to_openai(messages, None)
assert result[0]["role"] == "user"
assert result[0]["content"] == "hi"
def test_user_text_message(self):
messages = [ConversationMessage.from_user_text("hello")]
result = _convert_messages_to_openai(messages, None)
assert len(result) == 1
assert result[0] == {"role": "user", "content": "hello"}
def test_user_multimodal_message(self):
messages = [
ConversationMessage(
role="user",
content=[
TextBlock(text="Please describe this image."),
ImageBlock(media_type="image/png", data="YWJj", source_path="/tmp/example.png"),
],
)
]
result = _convert_messages_to_openai(messages, None)
assert result[0]["role"] == "user"
assert isinstance(result[0]["content"], list)
assert result[0]["content"][0] == {"type": "text", "text": "Please describe this image."}
assert result[0]["content"][1] == {
"type": "image_url",
"image_url": {"url": "data:image/png;base64,YWJj"},
}
def test_assistant_text_message(self):
msg = ConversationMessage(
role="assistant", content=[TextBlock(text="I'll help you.")]
)
result = _convert_messages_to_openai([msg], None)
assert result[0]["role"] == "assistant"
assert result[0]["content"] == "I'll help you."
assert "tool_calls" not in result[0]
def test_assistant_with_tool_calls(self):
msg = ConversationMessage(
role="assistant",
content=[
TextBlock(text="Let me read that file."),
ToolUseBlock(id="call_1", name="read_file", input={"path": "/tmp/x"}),
],
)
result = _convert_messages_to_openai([msg], None)
assert result[0]["role"] == "assistant"
assert result[0]["content"] == "Let me read that file."
assert len(result[0]["tool_calls"]) == 1
tc = result[0]["tool_calls"][0]
assert tc["id"] == "call_1"
assert tc["type"] == "function"
assert tc["function"]["name"] == "read_file"
assert json.loads(tc["function"]["arguments"]) == {"path": "/tmp/x"}
def test_tool_result_messages(self):
# User message containing tool results
msg = ConversationMessage(
role="user",
content=[
ToolResultBlock(
tool_use_id="call_1", content="file contents here", is_error=False
),
],
)
result = _convert_messages_to_openai([msg], None)
assert len(result) == 1
assert result[0]["role"] == "tool"
assert result[0]["tool_call_id"] == "call_1"
assert result[0]["content"] == "file contents here"
def test_full_conversation_round_trip(self):
"""Test a complete user → assistant(tool_call) → user(tool_result) → assistant flow."""
messages = [
ConversationMessage.from_user_text("Read /tmp/test.txt"),
ConversationMessage(
role="assistant",
content=[
TextBlock(text="I'll read that."),
ToolUseBlock(
id="call_abc", name="read_file", input={"path": "/tmp/test.txt"}
),
],
),
ConversationMessage(
role="user",
content=[
ToolResultBlock(
tool_use_id="call_abc", content="hello world", is_error=False
)
],
),
ConversationMessage(
role="assistant",
content=[TextBlock(text="The file contains: hello world")],
),
]
result = _convert_messages_to_openai(messages, "Be helpful")
assert result[0] == {"role": "system", "content": "Be helpful"}
assert result[1] == {"role": "user", "content": "Read /tmp/test.txt"}
assert result[2]["role"] == "assistant"
assert len(result[2]["tool_calls"]) == 1
assert result[3]["role"] == "tool"
assert result[3]["tool_call_id"] == "call_abc"
assert result[4]["role"] == "assistant"
assert result[4]["content"] == "The file contains: hello world"
def test_multiple_tool_results(self):
msg = ConversationMessage(
role="user",
content=[
ToolResultBlock(tool_use_id="c1", content="result1", is_error=False),
ToolResultBlock(tool_use_id="c2", content="result2", is_error=True),
],
)
result = _convert_messages_to_openai([msg], None)
assert len(result) == 2
assert result[0]["tool_call_id"] == "c1"
assert result[1]["tool_call_id"] == "c2"
class TestNormalizeOpenAIBaseUrl:
def test_preserves_explicit_v1_path(self):
assert _normalize_openai_base_url("https://jarodfund.xyz/openai/v1") == "https://jarodfund.xyz/openai/v1"
def test_adds_default_v1_when_path_missing(self):
assert _normalize_openai_base_url("https://api.example.com") == "https://api.example.com/v1"
def test_strips_trailing_slash_without_dropping_path(self):
assert _normalize_openai_base_url("https://api.example.com/openai/v1/") == "https://api.example.com/openai/v1"
class TestTokenLimitParams:
def test_gpt5_uses_max_completion_tokens(self):
assert _token_limit_param_for_model("gpt-5.4", 4096) == {"max_completion_tokens": 4096}
def test_legacy_chat_models_keep_max_tokens(self):
assert _token_limit_param_for_model("gpt-4o", 4096) == {"max_tokens": 4096}
class _FakeUsage:
prompt_tokens = 11
completion_tokens = 7
class _FakeChunk:
def __init__(self) -> None:
self.choices = []
self.usage = _FakeUsage()
class _FakeCompletions:
def __init__(self) -> None:
self.last_kwargs: dict[str, object] | None = None
async def create(self, **kwargs):
self.last_kwargs = kwargs
async def _stream():
yield _FakeChunk()
return _stream()
class _FakeChat:
def __init__(self) -> None:
self.completions = _FakeCompletions()
class _FakeOpenAIClient:
def __init__(self) -> None:
self.chat = _FakeChat()
@pytest.mark.asyncio
async def test_openai_client_uses_full_base_url_path_for_requests():
seen_urls: list[str] = []
def _handler(request: httpx.Request) -> httpx.Response:
seen_urls.append(str(request.url))
return httpx.Response(
200,
json={
"id": "x",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
transport = httpx.MockTransport(_handler)
http_client = httpx.AsyncClient(transport=transport)
client = OpenAICompatibleClient(
api_key="test-key",
base_url="https://jarodfund.xyz/openai/v1",
)
client._client._client = http_client
request = ApiMessageRequest(
model="gpt-4o-mini",
messages=[ConversationMessage.from_user_text("Explain the codebase")],
)
events = [event async for event in client.stream_message(request)]
assert events
assert seen_urls == ["https://jarodfund.xyz/openai/v1/chat/completions"]
await http_client.aclose()
def test_openai_client_init_normalizes_base_url(monkeypatch):
captured: dict[str, object] = {}
class _StubAsyncOpenAI:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.openai_client.AsyncOpenAI", _StubAsyncOpenAI)
OpenAICompatibleClient(api_key="test-key", base_url="https://jarodfund.xyz/openai/v1/")
assert captured["base_url"] == "https://jarodfund.xyz/openai/v1"
def test_openai_client_init_passes_timeout(monkeypatch):
captured: dict[str, object] = {}
class _StubAsyncOpenAI:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openharness.api.openai_client.AsyncOpenAI", _StubAsyncOpenAI)
OpenAICompatibleClient(api_key="test-key", timeout=45.0)
assert captured["timeout"] == 45.0
def test_openai_client_uses_bearer_authorization_header():
client = OpenAICompatibleClient(api_key="test-key", base_url="https://example.com/v1")
assert client._client.default_headers["Authorization"] == "Bearer test-key"
class TestStreamMessageTokenParams:
@pytest.mark.asyncio
async def test_gpt5_stream_uses_max_completion_tokens(self):
client = OpenAICompatibleClient(api_key="test-key")
fake_sdk = _FakeOpenAIClient()
client._client = fake_sdk
request = ApiMessageRequest(
model="gpt-5.4",
messages=[ConversationMessage.from_user_text("Explain the codebase")],
)
events = [event async for event in client.stream_message(request)]
assert events
assert fake_sdk.chat.completions.last_kwargs is not None
assert "max_completion_tokens" in fake_sdk.chat.completions.last_kwargs
assert "max_tokens" not in fake_sdk.chat.completions.last_kwargs
@pytest.mark.asyncio
async def test_gpt4o_stream_keeps_max_tokens(self):
client = OpenAICompatibleClient(api_key="test-key")
fake_sdk = _FakeOpenAIClient()
client._client = fake_sdk
request = ApiMessageRequest(
model="gpt-4o",
messages=[ConversationMessage.from_user_text("Explain the codebase")],
)
events = [event async for event in client.stream_message(request)]
assert events
assert fake_sdk.chat.completions.last_kwargs is not None
assert "max_tokens" in fake_sdk.chat.completions.last_kwargs
assert "max_completion_tokens" not in fake_sdk.chat.completions.last_kwargs
class TestStripThinkBlocks:
"""Unit tests for the _strip_think_blocks streaming helper."""
def test_no_think_tags_passthrough(self):
visible, leftover = _strip_think_blocks("Hello world")
assert visible == "Hello world"
assert leftover == ""
def test_complete_think_block_removed(self):
visible, leftover = _strip_think_blocks("<think>internal reasoning</think>answer")
assert visible == "answer"
assert leftover == ""
def test_multiline_think_block_removed(self):
buf = "<think>\nstep 1\nstep 2\n</think>final answer"
visible, leftover = _strip_think_blocks(buf)
assert visible == "final answer"
assert leftover == ""
def test_unclosed_think_held_in_leftover(self):
# Streaming chunk ends before </think> arrives
visible, leftover = _strip_think_blocks("prefix<think>partial reasoning")
assert visible == "prefix"
assert leftover == "<think>partial reasoning"
def test_empty_string(self):
visible, leftover = _strip_think_blocks("")
assert visible == ""
assert leftover == ""
def test_only_think_block(self):
visible, leftover = _strip_think_blocks("<think>all hidden</think>")
assert visible == ""
assert leftover == ""
def test_multiple_think_blocks(self):
buf = "<think>a</think>text1<think>b</think>text2"
visible, leftover = _strip_think_blocks(buf)
assert visible == "text1text2"
assert leftover == ""
def test_text_before_unclosed_think(self):
visible, leftover = _strip_think_blocks("before<think>unclosed")
assert visible == "before"
assert leftover == "<think>unclosed"
def test_closed_then_unclosed(self):
# One complete block followed by a new unclosed one (cross-chunk scenario)
buf = "<think>done</think>visible<think>still open"
visible, leftover = _strip_think_blocks(buf)
assert visible == "visible"
assert leftover == "<think>still open"
def test_partial_open_tag_is_held_for_next_chunk(self):
visible, leftover = _strip_think_blocks("prefix<thi")
assert visible == "prefix"
assert leftover == "<thi"
def test_partial_open_tag_after_closed_block_is_held(self):
buf = "<think>done</think>visible<thi"
visible, leftover = _strip_think_blocks(buf)
assert visible == "visible"
assert leftover == "<thi"
def test_split_open_tag_across_chunks_does_not_leak_reasoning(self):
buf = ""
buf += "<thi"
visible, buf = _strip_think_blocks(buf)
assert visible == ""
assert buf == "<thi"
buf += "nk>secret</think>answer"
visible, buf = _strip_think_blocks(buf)
assert visible == "answer"
assert buf == ""
class TestReasoningContentEmission:
"""``reasoning_content`` is a non-standard field. It must round-trip
when the streaming parser captured non-empty reasoning, but the
legacy "emit empty string when there are tool calls" behaviour now
requires opt-in via ``OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT=1``.
Strict-OpenAI providers (Cerebras, NVIDIA NIM, OpenAI direct) reject
requests carrying the field with a ``wrong_api_format`` 400, so the
default-off behaviour fixes them out-of-the-box; Kimi-on-Anthropic
users opt in via env var.
"""
def _msg_with_tool_use(self, *, reasoning: str | None = None) -> ConversationMessage:
msg = ConversationMessage(
role="assistant",
content=[
TextBlock(text="ok"),
ToolUseBlock(id="tool_1", name="read_file", input={"path": "x"}),
],
)
if reasoning is not None:
msg._reasoning = reasoning # type: ignore[attr-defined]
return msg
def test_omits_reasoning_when_no_captured_text(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", raising=False)
out = _convert_assistant_message(self._msg_with_tool_use())
assert "reasoning_content" not in out
def test_replays_captured_reasoning(self, monkeypatch):
monkeypatch.delenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", raising=False)
out = _convert_assistant_message(self._msg_with_tool_use(reasoning="thinking…"))
assert out["reasoning_content"] == "thinking…"
def test_emits_empty_when_opted_in(self, monkeypatch):
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", "1")
out = _convert_assistant_message(self._msg_with_tool_use())
assert out["reasoning_content"] == ""
def test_opt_in_truthy_values(self, monkeypatch):
for v in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", v)
out = _convert_assistant_message(self._msg_with_tool_use())
assert out.get("reasoning_content") == "", f"value={v!r}"
def test_opt_in_falsy_values(self, monkeypatch):
for v in ("0", "false", "no", "off", ""):
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", v)
out = _convert_assistant_message(self._msg_with_tool_use())
assert "reasoning_content" not in out, f"value={v!r} should not opt in"
def test_no_tool_calls_never_emits_empty(self, monkeypatch):
# Pure-text assistant messages have always omitted the field; the
# opt-in is scoped to tool-use messages where Kimi specifically
# demands the placeholder.
monkeypatch.setenv("OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT", "1")
msg = ConversationMessage(role="assistant", content=[TextBlock(text="hi")])
out = _convert_assistant_message(msg)
assert "reasoning_content" not in out