Skip to content

Commit 202a137

Browse files
committed
test(providers): cover codecov/patch gap on PR #67
Codecov flagged 75.92% patch coverage (target 98.19%). Diff lines were in: ``OpenAIProvider`` ``extra_body`` / ``extra_headers`` wiring and the new ``_normalise_tool_schema`` paths ($defs / $ref / anyOf title-preservation), plus the new ``AnthropicProvider`` ``base_url`` constructor branch. Added 12 focused unit tests: - ``test_openai_extras_and_schema.py`` (10) - ``extra_headers`` set / omitted on the AsyncOpenAI client - ``extra_body`` added when kwargs lacks it - ``extra_body`` merged with on_payload-supplied extra_body (instance-level keys as base, on_payload overriding on collision) - reasoning_details entry that's neither attr-bearing nor a dict falls through to ``text = None`` - schema normalisation: top-level title/description/$defs stripped; anyOf $ref-resolved item keeps its title (enum class names survive for cache parity); unknown $ref passed through unchanged; non-dict/non-list inputs returned as-is; smoke test against an actual ``pydantic.BaseModel.model_json_schema()`` - ``test_anthropic.py::TestAnthropicBaseUrl`` (2) - ``base_url`` forwarded to ``AsyncAnthropic`` - omitted when not supplied Provider coverage now 100% on both anthropic.py and openai.py.
1 parent f6fc823 commit 202a137

2 files changed

Lines changed: 283 additions & 0 deletions

File tree

tests/providers/test_anthropic.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,3 +1049,25 @@ def test_content_is_falsy_but_present(self):
10491049
msgs = [{"role": "user", "content": None}]
10501050
AnthropicProvider._apply_message_cache_control(msgs, self.CACHE_CONTROL)
10511051
assert msgs[0]["content"] is None
1052+
1053+
1054+
class TestAnthropicBaseUrl:
1055+
"""Constructor base_url branch (line 73)."""
1056+
1057+
def test_base_url_forwarded_to_async_anthropic(self):
1058+
from unittest.mock import patch as _patch
1059+
1060+
with _patch("anthropic.AsyncAnthropic") as mock_anthropic:
1061+
mock_anthropic.return_value = MagicMock()
1062+
AnthropicProvider(api_key="x", base_url="https://proxy.example/anthropic")
1063+
assert mock_anthropic.call_args.kwargs.get("base_url") == (
1064+
"https://proxy.example/anthropic"
1065+
)
1066+
1067+
def test_no_base_url_omits_kwarg(self):
1068+
from unittest.mock import patch as _patch
1069+
1070+
with _patch("anthropic.AsyncAnthropic") as mock_anthropic:
1071+
mock_anthropic.return_value = MagicMock()
1072+
AnthropicProvider(api_key="x")
1073+
assert "base_url" not in mock_anthropic.call_args.kwargs
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""Coverage for OpenAIProvider's extra_body / extra_headers wiring and
2+
``_normalise_tool_schema`` paths. These are the diff lines that landed
3+
in PR #67 — adding focused unit tests so codecov/patch hits the bar.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from types import SimpleNamespace
9+
from typing import Any
10+
from unittest.mock import MagicMock, patch
11+
12+
import pytest
13+
from pydantic import BaseModel
14+
15+
from cubepi.providers.base import (
16+
Model,
17+
StreamOptions,
18+
TextContent,
19+
UserMessage,
20+
)
21+
from cubepi.providers.openai import OpenAIProvider
22+
23+
24+
def _model() -> Model:
25+
return Model(id="gpt-4o", provider="openai", api="openai")
26+
27+
28+
def _make_chunk(*, finish_reason=None):
29+
return SimpleNamespace(
30+
id="x",
31+
choices=[
32+
SimpleNamespace(
33+
delta=SimpleNamespace(content=None, tool_calls=None),
34+
finish_reason=finish_reason,
35+
)
36+
],
37+
usage=None,
38+
)
39+
40+
41+
async def _async_iter(items):
42+
for it in items:
43+
yield it
44+
45+
46+
# ---------------------------------------------------------------------------
47+
# extra_headers — constructor branch
48+
# ---------------------------------------------------------------------------
49+
50+
51+
def test_extra_headers_forwarded_to_async_openai() -> None:
52+
with patch("openai.AsyncOpenAI") as mock_openai:
53+
mock_openai.return_value = MagicMock()
54+
OpenAIProvider(
55+
api_key="x",
56+
base_url="https://example/v1",
57+
extra_headers={"X-Custom": "y"},
58+
)
59+
kwargs = mock_openai.call_args.kwargs
60+
assert kwargs["default_headers"] == {"X-Custom": "y"}
61+
62+
63+
def test_no_extra_headers_omits_default_headers() -> None:
64+
with patch("openai.AsyncOpenAI") as mock_openai:
65+
mock_openai.return_value = MagicMock()
66+
OpenAIProvider(api_key="x")
67+
assert "default_headers" not in mock_openai.call_args.kwargs
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# extra_body — merge into request kwargs
72+
# ---------------------------------------------------------------------------
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_extra_body_added_when_kwargs_missing_it() -> None:
77+
captured: dict[str, Any] = {}
78+
with patch("openai.AsyncOpenAI") as mock_openai:
79+
client = MagicMock()
80+
mock_openai.return_value = client
81+
client.chat = MagicMock()
82+
client.chat.completions = MagicMock()
83+
84+
async def capture(**kwargs):
85+
captured.update(kwargs)
86+
return _async_iter([_make_chunk(finish_reason="stop")])
87+
88+
client.chat.completions.create = capture
89+
90+
provider = OpenAIProvider(api_key="x", extra_body={"enable_thinking": False})
91+
provider._client = client
92+
93+
ms = await provider.stream(
94+
_model(),
95+
[UserMessage(content=[TextContent(text="hi")])],
96+
)
97+
async for _ in ms:
98+
pass
99+
await ms.result()
100+
101+
assert captured["extra_body"] == {"enable_thinking": False}
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_extra_body_merged_when_payload_hook_supplied_one() -> None:
106+
captured: dict[str, Any] = {}
107+
108+
async def on_payload(payload, model):
109+
payload["extra_body"] = {"a": 1, "enable_thinking": True} # collision on key
110+
return payload
111+
112+
with patch("openai.AsyncOpenAI") as mock_openai:
113+
client = MagicMock()
114+
mock_openai.return_value = client
115+
client.chat = MagicMock()
116+
client.chat.completions = MagicMock()
117+
118+
async def capture(**kwargs):
119+
captured.update(kwargs)
120+
return _async_iter([_make_chunk(finish_reason="stop")])
121+
122+
client.chat.completions.create = capture
123+
124+
provider = OpenAIProvider(api_key="x", extra_body={"enable_thinking": False})
125+
provider._client = client
126+
127+
ms = await provider.stream(
128+
_model(),
129+
[UserMessage(content=[TextContent(text="hi")])],
130+
options=StreamOptions(on_payload=on_payload),
131+
)
132+
async for _ in ms:
133+
pass
134+
await ms.result()
135+
136+
# Instance-level extra_body is the base; on_payload overrides on key collision.
137+
assert captured["extra_body"] == {"a": 1, "enable_thinking": True}
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# Reasoning details fallback — delta entry that's neither attr nor dict
142+
# ---------------------------------------------------------------------------
143+
144+
145+
@pytest.mark.asyncio
146+
async def test_reasoning_details_unknown_entry_type_yields_no_text() -> None:
147+
"""A reasoning_details entry that's neither attr-bearing nor a dict
148+
should hit the ``text = None`` branch and produce no thinking_delta.
149+
"""
150+
delta = SimpleNamespace(
151+
content=None,
152+
tool_calls=None,
153+
reasoning_content=None,
154+
reasoning=None,
155+
reasoning_details=[42], # not a dict, no .text attr
156+
)
157+
chunk = SimpleNamespace(
158+
id="x",
159+
choices=[SimpleNamespace(delta=delta, finish_reason=None)],
160+
usage=None,
161+
)
162+
final = _make_chunk(finish_reason="stop")
163+
164+
saw_thinking_delta = False
165+
with patch("openai.AsyncOpenAI") as mock_openai:
166+
client = MagicMock()
167+
mock_openai.return_value = client
168+
client.chat = MagicMock()
169+
client.chat.completions = MagicMock()
170+
171+
async def create(**_):
172+
return _async_iter([chunk, final])
173+
174+
client.chat.completions.create = create
175+
176+
provider = OpenAIProvider(api_key="x")
177+
provider._client = client
178+
179+
ms = await provider.stream(
180+
_model(),
181+
[UserMessage(content=[TextContent(text="hi")])],
182+
)
183+
async for evt in ms:
184+
if evt.type == "thinking_delta":
185+
saw_thinking_delta = True
186+
await ms.result()
187+
188+
assert saw_thinking_delta is False
189+
190+
191+
# ---------------------------------------------------------------------------
192+
# _normalise_tool_schema — coverage for $defs / $ref / anyOf paths
193+
# ---------------------------------------------------------------------------
194+
195+
196+
def test_normalise_strips_top_level_title_description_and_defs() -> None:
197+
schema = {
198+
"title": "Foo",
199+
"description": "docstring",
200+
"type": "object",
201+
"$defs": {"Bar": {"title": "Bar", "type": "string"}},
202+
"properties": {"x": {"title": "X", "type": "integer"}},
203+
}
204+
out = OpenAIProvider._normalise_tool_schema(schema)
205+
assert "title" not in out
206+
assert "description" not in out
207+
assert "$defs" not in out
208+
assert "title" not in out["properties"]["x"]
209+
210+
211+
def test_normalise_keeps_title_inside_anyof_via_ref() -> None:
212+
"""anyOf items whose $ref resolves into a $def should keep title
213+
(enum class name needs to survive for cache parity)."""
214+
schema = {
215+
"title": "Outer",
216+
"type": "object",
217+
"$defs": {"Scope": {"title": "Scope", "enum": ["a", "b"], "type": "string"}},
218+
"properties": {
219+
"scope": {"anyOf": [{"$ref": "#/$defs/Scope"}, {"type": "null"}]},
220+
},
221+
}
222+
out = OpenAIProvider._normalise_tool_schema(schema)
223+
scope_options = out["properties"]["scope"]["anyOf"]
224+
# The Scope variant should keep its title; the null variant has none.
225+
titled = [o for o in scope_options if "title" in o]
226+
assert titled and titled[0]["title"] == "Scope"
227+
228+
229+
def test_normalise_unknown_ref_left_unchanged() -> None:
230+
"""A $ref that doesn't resolve to a $def should be passed through."""
231+
schema = {"$ref": "#/$defs/MissingName"}
232+
out = OpenAIProvider._normalise_tool_schema(schema)
233+
assert out == schema
234+
235+
236+
def test_normalise_passes_through_non_dict_non_list() -> None:
237+
assert OpenAIProvider._normalise_tool_schema("plain") == "plain"
238+
assert OpenAIProvider._normalise_tool_schema(7) == 7
239+
240+
241+
# ---------------------------------------------------------------------------
242+
# end-to-end smoke — schema feeding through pydantic model
243+
# ---------------------------------------------------------------------------
244+
245+
246+
class _ToolParams(BaseModel):
247+
"""A doc-string that should NOT make it into the wire schema."""
248+
249+
name: str
250+
count: int = 1
251+
252+
253+
def test_normalise_works_on_pydantic_generated_schema() -> None:
254+
raw = _ToolParams.model_json_schema()
255+
out = OpenAIProvider._normalise_tool_schema(raw)
256+
# Top-level title + description stripped.
257+
assert "title" not in out
258+
assert "description" not in out
259+
# Property titles stripped.
260+
for prop in out["properties"].values():
261+
assert "title" not in prop

0 commit comments

Comments
 (0)