fix: broken HTTP proxy for providers with custom logic - #132
Conversation
…igured proxy The previous proxy fix only helped providers that route through _resolve_litellm_client (has_custom_logic() == False). The 4 providers that build their own openai.AsyncOpenAI(http_client=client) - opencode_go, cline_pass, ollama_cloud, x_ai - silently bypassed every configured proxy because RequestExecutor._execute_non_streaming and _execute_streaming passed their unproxied shared client (self._http_client) to the plugin's acompletion(client=...). The plugin faithfully wired that unproxied client into its own AsyncOpenAI, so HTTPS traffic went straight to the upstream. _resolve_http_client was called and the proxy-aware httpx.AsyncClient was returned, but the executor then discarded it. The streaming path was worse: _resolve_http_client was never even called there, so every streaming request bypassed the proxy regardless of plugin. The 12-hex stable_id format users observed (e.g. AF9907BBF840) was correct all along - SHA-256 of the API key, truncated to 12 chars. The bug was purely in the executor, not in the lookup or env-var naming. Changes: - executor.py (_execute_non_streaming): pass the already-resolved request_client to plugin.acompletion / plugin.aembedding instead of self._http_client. - executor.py (_execute_streaming): resolve request_client_s via _resolve_http_client before the retry loop (it was missing entirely) and pass it to plugin.acompletion instead of self._http_client. - tests/test_proxy_request_routing.py: E2E test that spins up a real local TCP socket as a fake CONNECT proxy (no mocking at the network seam) and asserts the executor's outbound CONNECT lands on it when PROXY_URL_CREDENTIAL_<id> is configured. Covers non-streaming and streaming. Both go RED before the fix and GREEN after. - .fork/stack.yml: register this as the third proxy feature commit. - .fork/features/proxy.md: document the bug, fix, and scope note. Scope note: only the 4 providers that build their own AsyncOpenAI(http_client=client) now route through the proxy. The remaining has_custom_logic() == True providers do not consume the passed client at all (opencode_zen explicitly notes "# client unused" in source). For those, proxy support requires provider-specific refactors and remains out of scope here. Verified locally: - py_compile + ruff F401/F811/F821/E9 on all touched .py files - 502 passed in full suite, 2 pre-existing failures unrelated to this change (hardcoded 2026-07-01 timestamp now in the past; HTTP 400 vs 200 on unrelated test) confirmed by stashing patch + rerun
There was a problem hiding this comment.
🟡 Changes recommended
The streaming path now double-resolves proxy selection (spec + client), which can advance rotation and/or misreport proxy_name when proxy rotation is enabled.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes outbound proxy routing for providers that implement has_custom_logic() by ensuring the executor passes the proxy-aware httpx.AsyncClient into provider plugins for both non-streaming and streaming paths.
Changes:
- Pass the resolved
request_client(proxy-aware) into custom-logic plugins in_execute_non_streaming. - Resolve a proxy-aware client in
_execute_streamingand pass it into custom-logic plugins. - Add an end-to-end regression test that spins up a local fake CONNECT proxy and asserts it is contacted for
opencode_goanddeepseek.
File summaries
| File | Description |
|---|---|
src/rotator_library/client/executor.py |
Routes custom-logic plugin calls through the resolved proxy-aware HTTP client (non-streaming + streaming). |
tests/test_proxy_request_routing.py |
Adds E2E regression tests using a local fake CONNECT proxy to validate proxy routing reaches the network seam. |
.gitignore |
Un-ignores the new test file so it is tracked alongside other explicitly-included tests. |
.fork/stack.yml / .fork/features/proxy.md |
Records the fix in the fork’s stack/feature ledger. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
chore(client/executor): update _resolve_http_client to accept resolved_spec refactor(client/executor): reuse proxy_spec in _
b911f44 to
6523780
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
| !tests/test_access_log_middleware.py | ||
| !tests/test_gemini_cli_models.py | ||
| !tests/test_suppress_litellm_warnings.py | ||
| !tests/test_proxy_request_routing.py |
There was a problem hiding this comment.
Remove the inline comment because Git treats it as part of the pattern, preventing this test file from being unignored.
| !tests/test_proxy_request_routing.py | |
| !tests/test_proxy_request_routing.py |
#ai-review-inline
|
|
||
| [dependency-groups] | ||
| dev = [ | ||
| "pytest>=9.1.1", |
There was a problem hiding this comment.
Remove the temporary “added” annotations because they provide no lasting context and will quickly become stale.
| "pytest>=9.1.1", | |
| "pytest>=9.1.1", | |
| "pytest-asyncio>=1.4.0", |
#ai-review-inline
| ) -> httpx.AsyncClient: | ||
| """Get or create an httpx client for the resolved proxy.""" | ||
| spec = self.config.resolve(provider, credential, stable_id) | ||
| spec = ( |
There was a problem hiding this comment.
Remove the per-line change-tracking comments to keep the fallback logic readable.
| spec = ( | |
| spec = ( | |
| resolved_spec | |
| if resolved_spec is not None | |
| else self.config.resolve(provider, credential, stable_id) | |
| ) |
#ai-review-inline
| provider: str, | ||
| credential: str, | ||
| stable_id: str, | ||
| resolved_spec: Optional[ProxySpec] = None, |
There was a problem hiding this comment.
Remove the change-tracking comment because version control already records this addition.
| resolved_spec: Optional[ProxySpec] = None, | |
| resolved_spec: Optional[ProxySpec] = None, |
#ai-review-inline
| provider, | ||
| credential, | ||
| stable_id, | ||
| resolved_spec=resolved_spec, |
There was a problem hiding this comment.
Using None as both the default and a valid no-proxy result may cause the pool to resolve a proxy again; use a sentinel to distinguish an omitted spec.
#ai-review-inline
| provider, | ||
| credential, | ||
| stable_id, | ||
| resolved_spec=resolved_spec, |
There was a problem hiding this comment.
This new keyword argument will break client-pool implementations that still use the previous three-argument get_client signature; update the shared interface and all implementations.
#ai-review-inline
|
|
||
|
|
||
| DEEPSEEK_TEST_API_KEY = "sk-fake-deepseek-key-for-proxy-routing-0000" | ||
| DEEPSEEK_TEST_STABLE_ID = hashlib.sha256(DEEPSEEK_TEST_API_KEY.encode()).hexdigest()[:12] |
There was a problem hiding this comment.
DEEPSEEK_TEST_STABLE_ID is unused and should be removed to avoid dead test setup.
#ai-review-inline
| # Upstream call will fail: the fake proxy does not forward to the real | ||
| # OpenCode API and the API key is synthetic. We don't care about the | ||
| # final error; only that the fake proxy was contacted first. | ||
| with pytest.raises(Exception): |
There was a problem hiding this comment.
Catching Exception can mask unrelated regressions; assert the specific network or client exception expected from the closed tunnel.
#ai-review-inline
| def handle(self) -> None: | ||
| trace = self.server.trace # type: ignore[attr-defined] | ||
| try: | ||
| data = self.request.recv(4096) |
There was a problem hiding this comment.
A single recv call may return a partial HTTP request line, making the proxy trace assertion flaky; read until CRLF instead.
| data = self.request.recv(4096) | |
| first_line = self.request.makefile("rb").readline(4097).rstrip(b"\r\n") |
#ai-review-inline
| try: | ||
| self.request.settimeout(0.5) | ||
| self.request.recv(4096) | ||
| except Exception: |
There was a problem hiding this comment.
Catching every exception silently can hide defects in the fake proxy; restrict suppression to expected socket errors.
| except Exception: | |
| except (OSError, TimeoutError): | |
| pass |
#ai-review-inline
| yield {"trace": trace, "url": proxy_url} | ||
| finally: | ||
| server.shutdown() | ||
| server.server_close() |
There was a problem hiding this comment.
Join the server thread during teardown to prevent it from leaking into subsequent tests.
| server.server_close() | |
| server.server_close() | |
| thread.join(timeout=1) |
#ai-review-inline
|
The change correctly propagates the proxy-aware HTTP client into custom provider plugins for both regular and streaming requests, with useful end-to-end regression coverage. However, resolved_spec=None is indistinguishable from “not supplied,” so an explicitly resolved direct/no-proxy route is resolved again in ProxiedClientPool.get_client(), potentially causing the selected client to disagree with logged metadata; use a sentinel or explicit resolution flag. Adding streaming and direct-route regression tests would further strengthen coverage. #ai-review-summary |
HTTP / SOCKS proxies didn't work for providers that build their own HTTP client.
The previous fix only helped providers that go through
_resolve_litellm_client.Four providers that build their own
openai.AsyncOpenAI(http_client=client)silently bypassed the proxy.Streaming was even worse because
_resolve_http_clientwas never called.Providers for which
has_custom_logic()isTrueare responsible for how they instanciate their HTTP clients and may chose (or forget) to respect network proxy support.