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")