diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index f2cc8e6..065ef31 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -28,7 +28,7 @@ body:
id: pipe-version
attributes:
label: Pipe Version
- placeholder: "e.g. 1.0.0"
+ placeholder: "e.g. 1.2.0"
validations:
required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 26c31c8..34a5619 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -13,7 +13,7 @@
## Checklist
- [ ] I have read the [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines
-- [ ] All tests pass (`python test_pipe.py` — 234/234 ✓)
+- [ ] All tests pass (`python test_pipe.py` — 252/252 ✓)
- [ ] I have added tests for new functionality (if applicable)
- [ ] I have updated `CHANGELOG.md` under `[Unreleased]`
- [ ] I have updated documentation (if applicable)
diff --git a/.gitignore b/.gitignore
index 8526ea6..b2b998f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,14 @@ htmlcov/
tmpclaude-*
nul
+
+# Auto Claude data directory
+.auto-claude/
+
+# Auto Claude generated files
+.auto-claude-security.json
+.auto-claude-status
+.claude_settings.json
+.worktrees/
+.security-key
+logs/security/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7de1eac..839325a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Icon sync: correct prefixed model IDs** — `_sync_model_icons()` now discovers the pipe's `function_id` via `type(self).__module__` and writes DB records with the full prefixed ID (e.g. `openrouter_pipe.openai/gpt-4o`) matching what Open WebUI's frontend requests at `/models/model/profile/image`
- **Streaming status event** — the "done" status event is now correctly emitted at the end of streaming responses (async generator wrapper replaces sync generator that could not `await`)
- **Dead provider-icon code removed** — `info.meta.profile_image_url` was included in model dicts returned by `pipes()` but Open WebUI ignores all fields except `id` and `name`; the field has been removed in favour of the new DB-sync approach
+- **`pipes()` response always closed** — added `finally: response.close()` to guarantee HTTP connections are returned to the session pool in all code paths (auth errors, JSON decode failures, unexpected exceptions)
## [1.2.0] - 2026-02-17
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0dfdee9..e7e5350 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,7 +29,7 @@ pip install -r requirements.txt
### Running Tests
```bash
-python test_pipe.py # Unit tests (234 tests)
+python test_pipe.py # Unit tests (252 tests)
python integration_test.py # Live API tests (requires OPENROUTER_API_KEY)
```
diff --git a/README.md b/README.md
index d269d07..24b0a72 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,7 @@ All settings are configurable via **Valves** in the Open WebUI admin panel. Ever
| `FALLBACK_MODELS` | `OPENROUTER_FALLBACK_MODELS` | `""` | Fallback model IDs (comma-separated) |
| `ENABLE_MIDDLE_OUT` | `OPENROUTER_ENABLE_MIDDLE_OUT` | `false` | Middle-out compression for long prompts |
| `ENABLE_CACHE_CONTROL` | `OPENROUTER_ENABLE_CACHE_CONTROL` | `false` | Anthropic cache_control injection |
+| `SYNC_PROVIDER_ICONS` | `OPENROUTER_SYNC_ICONS` | `true` | Sync provider icons into Open WebUI's model database |
### Network
@@ -215,7 +216,7 @@ It also removes `user` when sent as a dict (OWUI format) since OpenRouter expect
OpenRouter-Pipe/
├── openrouter_pipe.py # Main pipe source (install this in Open WebUI)
├── function.json # Open WebUI community manifest (metadata, tags, categories)
-├── test_pipe.py # Unit test suite (234 tests)
+├── test_pipe.py # Unit test suite (252 tests)
├── integration_test.py # Live API integration tests (47 tests)
├── TESTING.md # Pre-release testing checklist
├── SECURITY.md # Security policy and vulnerability reporting
@@ -274,7 +275,7 @@ Your API key is incorrect or malformed. Get a valid key from [openrouter.ai/keys
"Rate limit exceeded (HTTP 429)"
-You're sending too many requests. Wait a moment and try again. Consider setting `MAX_RETRIES` to `2` or higher for automatic backoff.
+You're sending too many requests. Wait a moment and try again, or consider upgrading your OpenRouter plan. Note: `MAX_RETRIES` only retries on network timeouts and connection failures — HTTP rate limit errors are returned immediately without retry.
diff --git a/SECURITY.md b/SECURITY.md
index 406dfcc..33b3fa9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -3,7 +3,9 @@
## Supported Versions
| Version | Supported |
-|---------|-----------|| 1.2.x | Yes || 1.1.x | Yes |
+|---------|-----------|
+| 1.2.x | Yes |
+| 1.1.x | Yes |
| 1.0.x | Yes |
| < 1.0 | No |
diff --git a/TESTING.md b/TESTING.md
index 582f827..b82ba40 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -12,7 +12,7 @@ Manual checklist to verify every Pipe feature before release.
python test_pipe.py
```
-Must print **234/234 passed**. If any test fails, **do not release**.
+Must exit with `All tests passed! ✓` and `✗ Failed: 0`. If any test fails, **do not release**.
---
@@ -41,7 +41,7 @@ Must print **234/234 passed**. If any test fails, **do not release**.
| # | Action | Expected result |
|---|--------|-----------------|
| 3.1 | Select a model (e.g. `openai/gpt-4o`), type "Hello" with `stream: false` | The response appears all at once, correct text |
-| 3.2 | Select a reasoning model (e.g. `anthropic/claude-3.7-sonnet:thinking`) | The response contains `...` blocks followed by content |
+| 3.2 | Select a reasoning model (e.g. `deepseek/deepseek-r1`) | The response contains `...` blocks followed by content |
---
@@ -105,7 +105,7 @@ Must print **234/234 passed**. If any test fails, **do not release**.
| # | Action | Expected result |
|---|--------|-----------------|
-| 9.1 | Set `FALLBACK_MODELS = openai/gpt-4o, anthropic/claude-3.5-sonnet` | payload > `"models": ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"]` |
+| 9.1 | While using `openai/gpt-4o` as primary, set `FALLBACK_MODELS = anthropic/claude-3.5-sonnet` | payload contains `"models": ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"]` (primary model first, then fallbacks) |
| 9.2 | Leave `FALLBACK_MODELS` empty | No `models` field in payload |
---
@@ -191,7 +191,7 @@ Must print **234/234 passed**. If any test fails, **do not release**.
## Quick pre-release checklist
```
-[ ] python test_pipe.py → 234/234 ✓
+[ ] python test_pipe.py → 252 passed, 0 failed ✓
[ ] python integration_test.py → 47/47 ✓
[ ] Empty API key → clear error
[ ] Valid API key → 340+ models
diff --git a/openrouter_pipe.py b/openrouter_pipe.py
index 67117a0..b3ba401 100644
--- a/openrouter_pipe.py
+++ b/openrouter_pipe.py
@@ -277,6 +277,7 @@ def pipes(self) -> List[dict]:
return self._models_cache
headers = self._build_headers(include_content_type=False)
+ response = None
try:
response = self._session.get(
self.models_url, headers=headers, timeout=self.valves.REQUEST_TIMEOUT
@@ -313,6 +314,9 @@ def pipes(self) -> List[dict]:
print(f"[OpenRouter Pipe] Model fetch error: {exc}")
traceback.print_exc()
return [{"id": "error", "name": f"Unexpected error: {exc}"}]
+ finally:
+ if response is not None:
+ response.close()
provider_filter = self._parse_provider_filter()
prefix = self.valves.MODEL_PREFIX or ""
diff --git a/test_pipe.py b/test_pipe.py
index f7d91a2..c546831 100644
--- a/test_pipe.py
+++ b/test_pipe.py
@@ -1026,6 +1026,38 @@ async def _test_pipe_stream() -> str:
_assert(models[0]["id"] == "error", "pipes provider no match: error id")
_assert("No models match" in models[0]["name"], "pipes provider no match: correct message")
+# 15o. response.close() called via finally on auth-error branch (401)
+_close_auth_resp = MagicMock()
+_close_auth_resp.status_code = 401
+_close_auth_resp.json.return_value = {"error": {"message": "Unauthorized"}}
+pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="bad-key")
+pipe._models_cache = None
+with patch.object(pipe._session, "get", return_value=_close_auth_resp):
+ pipe.pipes()
+_assert(_close_auth_resp.close.call_count == 1, "pipes response.close(): called after 401 auth error")
+
+# 15p. response.close() called via finally when response.json() raises (JSONDecodeError)
+_close_json_err_resp = MagicMock()
+_close_json_err_resp.status_code = 200
+_close_json_err_resp.raise_for_status = MagicMock()
+_close_json_err_resp.json.side_effect = ValueError("No JSON object could be decoded")
+pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
+pipe._models_cache = None
+with patch.object(pipe._session, "get", return_value=_close_json_err_resp):
+ pipe.pipes()
+_assert(_close_json_err_resp.close.call_count == 1, "pipes response.close(): called after JSON decode error")
+
+# 15q. response.close() called via finally on success path
+_close_ok_resp = MagicMock()
+_close_ok_resp.status_code = 200
+_close_ok_resp.raise_for_status = MagicMock()
+_close_ok_resp.json.return_value = {"data": [{"id": "openai/gpt-4o", "name": "GPT-4o"}]}
+pipe.valves = Pipe.Valves(OPENROUTER_API_KEY="test-key")
+pipe._models_cache = None
+with patch.object(pipe._session, "get", return_value=_close_ok_resp):
+ pipe.pipes()
+_assert(_close_ok_resp.close.call_count == 1, "pipes response.close(): called after successful listing")
+
# ── 16. Valve json_schema_extra ──────────────────────────────────────────────
_section("16. Valve json_schema_extra")