@@ -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