Skip to content

Commit c3644cf

Browse files
sena-labsclaude
andcommitted
test: expand coverage to 322 assertions + pre-release cleanup
- Add 60 new assertions across 7 new test sections (26-32): · Section 26: _stream_response() edge cases (mixed chunks, empty reasoning/content, non-dict choices, citations-only chunk, generic exc) · Section 27: pipes() additional paths (missing id/name, invalid pricing, non-JSON HTTPError body) · Section 28: _inject_cache_control() edge cases (all-image content, mixed image+text, user-role list) · Section 29: _non_stream_response() edge cases (error+empty choices, None content, missing content key) · Section 30: citation helper edge cases (zero-index ref, duplicate refs, query-param URLs, duplicate URL list) · Section 31: all 22 provider icons verified · Section 32: _retryable_request() stream flag verified - README.md: remove 4 obsolete TODO screenshot placeholder comments - .github/PULL_REQUEST_TEMPLATE.md: update test count 252 → 322 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a63a796 commit c3644cf

3 files changed

Lines changed: 321 additions & 7 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
## Testing
2727

28-
- [ ] All unit tests pass (`python test_pipe.py`252/252 ✓)
28+
- [ ] All unit tests pass (`python test_pipe.py`322/322 ✓)
2929
- [ ] New tests added for the changes
3030
- [ ] Integration tests pass (`python integration_test.py`) — if applicable
3131
- [ ] `CHANGELOG.md` updated under `[Unreleased]`

README.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,18 @@
77
Access **300+ AI models** through OpenRouter directly inside Open WebUI — with provider routing,
88
reasoning tokens, streaming, fallbacks, and cache control out of the box.
99

10-
<!-- TODO: Add a hero screenshot of the model selector with provider icons -->
11-
<!-- Suggested: media/screenshot.png -->
12-
1310
## Feature gallery
1411

1512
### Model selector
1613

17-
<!-- TODO: media/screenshot-models.png -->
1814
*Models from OpenAI, Anthropic, Google, Meta, Mistral, DeepSeek and more — each with its provider icon.*
1915

2016
### Reasoning tokens
2117

22-
<!-- TODO: media/screenshot-reasoning.png -->
2318
*`<think>` blocks streamed in real time with configurable effort levels.*
2419

2520
### Provider routing in action
2621

27-
<!-- TODO: media/screenshot-routing.png -->
2822
*Sort, prefer, exclude and require parameters across providers per request.*
2923

3024
---

test_pipe.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,326 @@ async def _test_pipe_no_msgs_key():
15341534
_assert(not _is_owui("https://custom-icon.example.com/icon.png"), "_is_owui_managed_icon: external URL → False")
15351535
_assert(not _is_owui("https://cdn.openai.com/logo.png"), "_is_owui_managed_icon: other https URL → False")
15361536

1537+
# ── 26. _stream_response() edge cases ────────────────────────────────────────
1538+
1539+
_section("26. _stream_response() edge cases")
1540+
1541+
pipe = Pipe()
1542+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="k")
1543+
1544+
# 26a. Reasoning + content in same chunk → <think>…</think> then content
1545+
sse_mixed_chunk = [
1546+
b"data: " + json.dumps({"choices": [{"delta": {"reasoning": "Inner thought", "content": "Answer"}}]}).encode(),
1547+
b"data: [DONE]",
1548+
]
1549+
with patch.object(pipe, "_retryable_request", return_value=_make_sse_response(sse_mixed_chunk)):
1550+
output = list(pipe._stream_response({}, {}))
1551+
full = "".join(output)
1552+
_assert("<think>" in full, "stream mixed chunk: <think> opened for reasoning")
1553+
_assert("Inner thought" in full, "stream mixed chunk: reasoning present")
1554+
_assert("</think>" in full, "stream mixed chunk: </think> closed before content")
1555+
_assert("Answer" in full, "stream mixed chunk: content present after think")
1556+
1557+
# 26b. Empty reasoning string → <think> NOT opened
1558+
sse_empty_reason = [
1559+
b"data: " + json.dumps({"choices": [{"delta": {"reasoning": "", "content": "Only content"}}]}).encode(),
1560+
b"data: [DONE]",
1561+
]
1562+
with patch.object(pipe, "_retryable_request", return_value=_make_sse_response(sse_empty_reason)):
1563+
output = list(pipe._stream_response({}, {}))
1564+
full = "".join(output)
1565+
_assert("<think>" not in full, "stream empty reasoning: <think> NOT opened")
1566+
_assert("Only content" in full, "stream empty reasoning: content still present")
1567+
1568+
# 26c. Empty content string → nothing yielded for that chunk
1569+
sse_empty_content = [
1570+
b"data: " + json.dumps({"choices": [{"delta": {"content": ""}}]}).encode(),
1571+
b"data: [DONE]",
1572+
]
1573+
with patch.object(pipe, "_retryable_request", return_value=_make_sse_response(sse_empty_content)):
1574+
output = list(pipe._stream_response({}, {}))
1575+
_assert("".join(output) == "", "stream empty content string: nothing yielded")
1576+
1577+
# 26d. Non-dict item in choices[0] → skipped safely, next chunk processed
1578+
sse_bad_choice = [
1579+
b"data: " + json.dumps({"choices": [42]}).encode(),
1580+
b"data: " + json.dumps({"choices": [{"delta": {"content": "OK"}}]}).encode(),
1581+
b"data: [DONE]",
1582+
]
1583+
with patch.object(pipe, "_retryable_request", return_value=_make_sse_response(sse_bad_choice)):
1584+
output = list(pipe._stream_response({}, {}))
1585+
full = "".join(output)
1586+
_assert("OK" in full, "stream non-dict choice: skipped safely, next chunk processed")
1587+
1588+
# 26e. Citations-only chunk (no choices) → updates citations used by later content
1589+
sse_citations_first = [
1590+
b"data: " + json.dumps({"citations": ["https://example.com"]}).encode(),
1591+
b"data: " + json.dumps({"choices": [{"delta": {"content": "See [1]"}}]}).encode(),
1592+
b"data: [DONE]",
1593+
]
1594+
with patch.object(pipe, "_retryable_request", return_value=_make_sse_response(sse_citations_first)):
1595+
output = list(pipe._stream_response({}, {}))
1596+
full = "".join(output)
1597+
_assert("https://example.com" in full, "stream citations-only chunk: citation applied to later content")
1598+
_assert("Citations:" in full, "stream citations-only chunk: citation list appended")
1599+
1600+
# 26f. Generic exception raised by _retryable_request → yields OpenRouter Error
1601+
with patch.object(pipe, "_retryable_request", side_effect=ValueError("unexpected")):
1602+
output = list(pipe._stream_response({}, {}))
1603+
full = "".join(output)
1604+
_assert("OpenRouter Error" in full, "stream generic exception: error yielded")
1605+
_assert("unexpected" in full, "stream generic exception: detail preserved")
1606+
1607+
# ── 27. pipes() additional paths ─────────────────────────────────────────────
1608+
1609+
_section("27. pipes() additional paths")
1610+
1611+
pipe = Pipe()
1612+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
1613+
1614+
# 27a. Model missing "id" key → skipped
1615+
_mock_no_id = MagicMock()
1616+
_mock_no_id.status_code = 200
1617+
_mock_no_id.raise_for_status = MagicMock()
1618+
_mock_no_id.json.return_value = {
1619+
"data": [
1620+
{"name": "No ID model"},
1621+
{"id": "openai/gpt-4o", "name": "GPT-4o"},
1622+
]
1623+
}
1624+
pipe._models_cache = None
1625+
with patch.object(pipe._session, "get", return_value=_mock_no_id):
1626+
models = pipe.pipes()
1627+
_assert(len(models) == 1, "pipes: model missing 'id' is skipped")
1628+
_assert(models[0]["id"] == "openai/gpt-4o", "pipes: model with valid id kept")
1629+
1630+
# 27b. Model missing "name" key → falls back to model_id as name
1631+
_mock_no_name = MagicMock()
1632+
_mock_no_name.status_code = 200
1633+
_mock_no_name.raise_for_status = MagicMock()
1634+
_mock_no_name.json.return_value = {
1635+
"data": [
1636+
{"id": "openai/gpt-4o"},
1637+
]
1638+
}
1639+
pipe._models_cache = None
1640+
with patch.object(pipe._session, "get", return_value=_mock_no_name):
1641+
models = pipe.pipes()
1642+
_assert(len(models) == 1, "pipes: model missing 'name' returns 1 entry")
1643+
_assert("openai/gpt-4o" in models[0]["name"], "pipes: model_id used as fallback name")
1644+
1645+
# 27c. FREE_ONLY with invalid pricing string (ValueError) → is_free=False → model excluded
1646+
_mock_invalid_price = MagicMock()
1647+
_mock_invalid_price.status_code = 200
1648+
_mock_invalid_price.raise_for_status = MagicMock()
1649+
_mock_invalid_price.json.return_value = {
1650+
"data": [
1651+
{"id": "some/model", "name": "Model", "pricing": {"prompt": "not-a-number", "completion": "0"}},
1652+
]
1653+
}
1654+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key", FREE_ONLY=True)
1655+
pipe._models_cache = None
1656+
with patch.object(pipe._session, "get", return_value=_mock_invalid_price):
1657+
models = pipe.pipes()
1658+
_assert(models[0]["id"] == "error", "pipes FREE_ONLY invalid pricing: model excluded → error entry")
1659+
_assert("No free models" in models[0]["name"], "pipes FREE_ONLY invalid pricing: correct error message")
1660+
1661+
# 27d. HTTPError on /models with non-JSON body → graceful error (no crash)
1662+
_mock_5xx_no_json = MagicMock()
1663+
_mock_5xx_no_json.status_code = 503
1664+
_mock_5xx_no_json.json.side_effect = ValueError("not JSON")
1665+
_mock_5xx_no_json.raise_for_status.side_effect = req_lib.exceptions.HTTPError(response=_mock_5xx_no_json)
1666+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
1667+
pipe._models_cache = None
1668+
with patch.object(pipe._session, "get", return_value=_mock_5xx_no_json):
1669+
models = pipe.pipes()
1670+
_assert(models[0]["id"] == "error", "pipes models non-JSON HTTPError: error id")
1671+
_assert("503" in models[0]["name"], "pipes models non-JSON HTTPError: status code in name")
1672+
1673+
# ── 28. _inject_cache_control() edge cases ────────────────────────────────────
1674+
1675+
_section("28. _inject_cache_control() edge cases")
1676+
1677+
pipe = Pipe()
1678+
1679+
# 28a. All image_url chunks → no cache_control applied (no text chunks to tag)
1680+
payload_all_img = {
1681+
"messages": [
1682+
{
1683+
"role": "user",
1684+
"content": [
1685+
{"type": "image_url", "image_url": {"url": "https://img.example.com/a.jpg"}},
1686+
{"type": "image_url", "image_url": {"url": "https://img.example.com/b.jpg"}},
1687+
],
1688+
}
1689+
]
1690+
}
1691+
pipe._inject_cache_control(payload_all_img)
1692+
_assert(
1693+
all("cache_control" not in chunk for chunk in payload_all_img["messages"][0]["content"]),
1694+
"cache_control: all image chunks → nothing applied",
1695+
)
1696+
1697+
# 28b. Mixed image + text chunks → text chunk gets cache_control, image chunk does not
1698+
payload_mixed_img = {
1699+
"messages": [
1700+
{
1701+
"role": "system",
1702+
"content": [
1703+
{"type": "image_url", "image_url": {"url": "https://img.example.com/a.jpg"}},
1704+
{"type": "text", "text": "Describe this image in detail."},
1705+
],
1706+
}
1707+
]
1708+
}
1709+
pipe._inject_cache_control(payload_mixed_img)
1710+
_assert(
1711+
"cache_control" not in payload_mixed_img["messages"][0]["content"][0],
1712+
"cache_control: image_url chunk skipped in mixed content",
1713+
)
1714+
_assert(
1715+
payload_mixed_img["messages"][0]["content"][1].get("cache_control") == {"type": "ephemeral"},
1716+
"cache_control: text chunk in mixed content gets cache_control",
1717+
)
1718+
1719+
# 28c. User role list content (no system) → falls through to user, applies cache_control
1720+
payload_user_list = {
1721+
"messages": [
1722+
{
1723+
"role": "user",
1724+
"content": [
1725+
{"type": "text", "text": "Short"},
1726+
{"type": "text", "text": "A longer user message that should receive cache_control injection"},
1727+
],
1728+
}
1729+
]
1730+
}
1731+
pipe._inject_cache_control(payload_user_list)
1732+
_assert(
1733+
payload_user_list["messages"][0]["content"][1].get("cache_control") == {"type": "ephemeral"},
1734+
"cache_control: user role list content gets cache_control when no system role",
1735+
)
1736+
1737+
# ── 29. _non_stream_response() edge cases ────────────────────────────────────
1738+
1739+
_section("29. _non_stream_response() edge cases")
1740+
1741+
pipe = Pipe()
1742+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="k")
1743+
1744+
# 29a. choices=[] AND top-level "error" → error message (not "empty response")
1745+
_mock_err_empty_choices = MagicMock()
1746+
_mock_err_empty_choices.json.return_value = {
1747+
"choices": [],
1748+
"error": {"message": "Context window exceeded"},
1749+
}
1750+
with patch.object(pipe, "_retryable_request", return_value=_mock_err_empty_choices):
1751+
result = pipe._non_stream_response({}, {})
1752+
_assert("Context window exceeded" in result, "non-stream: error field takes priority over empty choices")
1753+
1754+
# 29b. message.content is None → no crash, returns empty string
1755+
_mock_none_content = MagicMock()
1756+
_mock_none_content.json.return_value = {
1757+
"choices": [{"message": {"content": None}}]
1758+
}
1759+
with patch.object(pipe, "_retryable_request", return_value=_mock_none_content):
1760+
result = pipe._non_stream_response({}, {})
1761+
_assert(isinstance(result, str), "non-stream: None content → still returns string")
1762+
_assert(result == "", "non-stream: None content → empty string (no crash)")
1763+
1764+
# 29c. message dict missing "content" key → returns empty string (no crash)
1765+
_mock_no_content_key = MagicMock()
1766+
_mock_no_content_key.json.return_value = {
1767+
"choices": [{"message": {"role": "assistant"}}]
1768+
}
1769+
with patch.object(pipe, "_retryable_request", return_value=_mock_no_content_key):
1770+
result = pipe._non_stream_response({}, {})
1771+
_assert(isinstance(result, str), "non-stream: missing content key → still returns string")
1772+
_assert(result == "", "non-stream: missing content key → empty string (no crash)")
1773+
1774+
# ── 30. Citation helper edge cases ───────────────────────────────────────────
1775+
1776+
_section("30. Citation helper edge cases")
1777+
1778+
# 30a. [0] reference → left unchanged (1-based; idx = -1 is out of range)
1779+
_assert(
1780+
_insert_citations("See [0].", ["https://example.com"]) == "See [0].",
1781+
"citations: [0] reference left unchanged (1-based indexing)",
1782+
)
1783+
1784+
# 30b. Multiple [1] references in same text → all replaced with the same URL
1785+
_result_dup = _insert_citations("See [1] and also [1].", ["https://example.com"])
1786+
_assert(
1787+
_result_dup == "See [[1]](https://example.com) and also [[1]](https://example.com).",
1788+
"citations: duplicate [1] references both replaced",
1789+
)
1790+
1791+
# 30c. Citation URL with query params → URL preserved verbatim in the link
1792+
_cite_url_params = "https://example.com/article?q=test&ref=2"
1793+
_result_params = _insert_citations("Check [1].", [_cite_url_params])
1794+
_assert(
1795+
_cite_url_params in _result_params,
1796+
"citations: URL query params preserved verbatim in link",
1797+
)
1798+
1799+
# 30d. _format_citation_list() with duplicate URLs → both listed (no deduplication)
1800+
_result_fmt_dup = _format_citation_list(["https://a.com", "https://a.com"])
1801+
_assert(_result_fmt_dup.count("https://a.com") == 2, "citations: duplicate URLs both listed")
1802+
_assert("1." in _result_fmt_dup and "2." in _result_fmt_dup, "citations: both entries numbered")
1803+
1804+
# ── 31. All provider icons ───────────────────────────────────────────────────
1805+
1806+
_section("31. All provider icons")
1807+
1808+
_ALL_PROVIDER_KEYS = [
1809+
"openai", "anthropic", "google", "meta-llama", "mistralai",
1810+
"amazon", "deepseek", "x-ai", "cohere", "perplexity",
1811+
"allenai", "qwen", "nvidia", "databricks", "microsoft",
1812+
"together", "fireworks", "sambanova", "cerebras", "groq",
1813+
"inflection", "01-ai",
1814+
]
1815+
for _prov_key in _ALL_PROVIDER_KEYS:
1816+
_prov_icon = Pipe.get_provider_icon(_prov_key)
1817+
_assert(
1818+
_prov_icon is not None and len(_prov_icon) > 0,
1819+
f"provider icon: '{_prov_key}' → non-empty URL",
1820+
)
1821+
1822+
_assert(Pipe.get_provider_icon("unknown-provider") is None, "provider icon: unknown → None")
1823+
_assert(
1824+
Pipe.get_provider_icon("OPENAI") is not None,
1825+
"provider icon: uppercase input → case-insensitive match",
1826+
)
1827+
1828+
# ── 32. _retryable_request() stream flag ─────────────────────────────────────
1829+
1830+
_section("32. _retryable_request() stream flag")
1831+
1832+
pipe = Pipe()
1833+
pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="k")
1834+
1835+
# 32a. stream=True → requests.Session.post called with stream=True
1836+
_mock_ok_stream = MagicMock()
1837+
_mock_ok_stream.raise_for_status = MagicMock()
1838+
with patch.object(pipe._session, "post", return_value=_mock_ok_stream) as _mock_post_s:
1839+
pipe._retryable_request({}, {}, stream=True)
1840+
_assert(
1841+
_mock_post_s.call_args.kwargs.get("stream") is True
1842+
or _mock_post_s.call_args[1].get("stream") is True,
1843+
"retryable: stream=True forwarded to requests.Session.post",
1844+
)
1845+
1846+
# 32b. stream=False → requests.Session.post called with stream=False
1847+
_mock_ok_nostream = MagicMock()
1848+
_mock_ok_nostream.raise_for_status = MagicMock()
1849+
with patch.object(pipe._session, "post", return_value=_mock_ok_nostream) as _mock_post_ns:
1850+
pipe._retryable_request({}, {}, stream=False)
1851+
_assert(
1852+
_mock_post_ns.call_args.kwargs.get("stream") is False
1853+
or _mock_post_ns.call_args[1].get("stream") is False,
1854+
"retryable: stream=False forwarded to requests.Session.post",
1855+
)
1856+
15371857
# ══════════════════════════════════════════════════════════════════════════════
15381858
# Summary
15391859
# ══════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)