Skip to content

feat(proxy): support custom proxy routing for api keys and bypass loopback in fetch dispatcher - #873

Merged
SsuJojo merged 2 commits into
devfrom
feat/fetch-dispatcher-no-proxy
Oct 15, 2026
Merged

SsuJojo merged 2 commits into
devfrom
feat/fetch-dispatcher-no-proxy

Conversation

@icebear0828

Copy link
Copy Markdown
Owner

Summary

This PR enables per-API-Key proxy routing configuration across the proxy pipeline and bypasses loopback addresses in the global fetch dispatcher. Previously, Undici's global proxy agent intercepted local loopback addresses (e.g. 127.0.0.1, localhost) whenever an external proxy was configured or detected, causing local models (like Ollama or local backends) to fail when offline or when the proxy rejected loopback traffic. Furthermore, third-party API keys can now route through dedicated proxies or bypass them (global, direct, auto, or specific proxy node UUID).

Changes

  • Fetch Dispatcher Bypass (src/proxy/fetch-dispatcher.ts):
    • Switched to Undici EnvHttpProxyAgent with default loopback bypass (localhost,127.0.0.1,::1) merged with NO_PROXY/no_proxy.
    • Added targeted proxy dispatcher caching via getFetchDispatcher(targetProxyUrl) and withFetchDispatcher(init, dispatcherOrProxyUrl).
  • API Key & Proxy Pool Integration (src/auth/api-key-pool.ts, src/proxy/proxy-pool.ts, src/routes/api-keys.ts):
    • Extended ApiKeyEntry with optional proxyId?: string | null field and persistence.
    • Added PATCH /auth/api-keys/:id/proxy endpoint and setProxy method.
    • Added resolveProxyUrlById in ProxyPool to handle direct (returns null), global (returns undefined), auto (round-robin), and proxy UUID lookup.
  • Upstream Adapters & Routing (src/proxy/adapter-factory.ts, src/proxy/upstream-router.ts, src/proxy/upstream-router-bootstrap.ts, upstream implementations):
    • Upstream adapters (OpenAIUpstream, AnthropicUpstream, GeminiUpstream, ResponsesUpstream, AntigravityUpstream) now receive injected custom dispatchers.
    • Dynamic adapter cache key includes proxyId for hot-swapping proxy routes on key updates.
  • Frontend Management UI (shared/hooks/use-api-keys.ts, web/src/components/ApiKeyManager.tsx):
    • Added proxy selection dropdown to the "Add Key" form and each key row in the dashboard.
  • Documentation & Changelog (CHANGELOG.md):
    • Updated [Unreleased] with Added and Fixed entries.

Test Plan

  • npx tsc --noEmit
  • npm run build:web
  • npx vitest run tests/unit/proxy/fetch-dispatcher.test.ts tests/unit/proxy/fetch-dispatcher-loopback.test.ts tests/unit/auth/api-key-pool.test.ts tests/unit/proxy/proxy-pool-resolve.test.ts tests/unit/proxy/adapter-factory.test.ts tests/unit/routes/api-keys.test.ts
  • npm run test:unit (285 test files, 3005 tests passed)

…pback in fetch dispatcher

- configure Undici EnvHttpProxyAgent with default loopback bypass (localhost, 127.0.0.1, ::1) and support targetProxyUrl dispatching
- add proxyId field and PATCH /auth/api-keys/:id/proxy endpoint to allow per-key proxy routing
- resolve direct, global, auto, and proxy UUID assignments through ProxyPool and inject into upstream adapters
- expose proxy selector in web UI for adding and managing API keys

@SsuJojo SsuJojo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request Changes

Solid feature overall (loopback bypass via EnvHttpProxyAgent, per-key proxy routing, dispatcher caching, adapter cache invalidation on proxyId all look right, and tests are good), but there is one behavioral bug:

🐛 null proxyId is treated as direct, but the UI sends null for "Global Default"

In KeyRow (web/src/components/ApiKeyManager.tsx), selecting Global Default calls onProxyChange(entry.id, null), and ApiKeyPool.add stores proxyId: input.proxyId ?? null. However ProxyPool.resolveProxyUrlById treats null as direct connection:

if (proxyId === undefined || proxyId === "global") return undefined; // global
if (proxyId === null || proxyId === "direct") return null;           // direct

This conflicts with account assignments, where unset = "global" (see ProxyPool.getAssignment). Net effect: any API key added/edited through the UI with "Global Default" selected will silently bypass the global proxy and connect directly — the opposite of the label.

Suggested fix (pick one)

  • Map UI "global" → undefined (and store undefined/omit the field), treating only the literal "direct" as direct; or
  • Invert resolveProxyUrlById semantics for null → global and have setProxy/add store "global" explicitly.

Minor

  • src/routes/api-keys.ts PATCH /proxy accepts any string ≤64 chars without validating "global" | "direct" | "auto" | <known proxy uuid>; invalid values silently degrade to global. Consider validating (the account assign endpoint already does).
  • The proxyId?.startsWith("http") fallback duplicated in adapter-factory.ts and embeddings.ts is undocumented magic; prefer routing it through ProxyPool or removing it.
  • createRuntimeUpstreamRouter now accepts either an AdapterFactory or a ProxyPool in the same positional arg — consider two distinct params/overload for clarity.

Happy to approve once the null/global semantics are fixed. Thanks!

…xy assignments

- treat null/undefined/global proxyId as global default (undefined dispatcher) in ProxyPool and createAdapterForEntry, reserving direct connection (null dispatcher) strictly for 'direct'
- normalize 'global' proxyId to null in ApiKeyPool
- validate proxyId on POST /auth/api-keys and PATCH /auth/api-keys/:id/proxy against configured ProxyPool
- remove undocumented startsWith('http') fallback in adapter-factory and embeddings routes
- add function overloads to createRuntimeUpstreamRouter for cleaner proxyPool injection
@icebear0828

Copy link
Copy Markdown
Owner Author

Thanks for the review @SsuJojo! All feedback addressed in 35561b6:

  • Null / Global Proxy Resolution:
    • ProxyPool.resolveProxyUrlById now treats null, undefined, and "global" as global default (return undefined), reserving direct connection (return null) strictly for the literal "direct".
    • Added ProxyPool.getProxyDisplayName and normalized "global" proxyId to null in ApiKeyPool (add / setProxy / normalizeEntry).
    • Added regression test cases in proxy-pool-resolve.test.ts, adapter-factory.test.ts, and api-key-pool.test.ts.
  • Validation:
    • Injected proxyPool into createApiKeyRoutes. Validates proxyId on POST /auth/api-keys and PATCH /auth/api-keys/:id/proxy against ["global", "direct", "auto"] or registered proxy pool UUIDs, returning 400 for unknown proxy IDs.
  • Removed Undocumented Magic:
    • Removed duplicated startsWith("http") fallback from adapter-factory.ts and embeddings.ts.
  • Router Bootstrap Overload:
    • Added TypeScript function overloads to createRuntimeUpstreamRouter so proxyPool can be passed directly as the 4th argument without undefined, proxyPool.

@SsuJojo SsuJojo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix verified in 35561b6:

  • resolveProxyUrlById now treats null/undefined/"global" as global default, reserving "direct" for direct connection — semantics now consistent with account assignments.
  • normalizeProxyId normalizes "global" to null at add/setProxy/persisted-reload.
  • proxyId validated against ["global","direct","auto"] + registered proxy UUIDs (400 on unknown) on both POST and PATCH.
  • Undocumented startsWith("http") fallback removed from adapter-factory and embeddings.
  • createRuntimeUpstreamRouter now has proper TypeScript overloads; call site in index.ts updated.
  • Regression tests added for all of the above; CI green.

Thanks for the quick turnaround! Approving.

@SsuJojo
SsuJojo merged commit a555596 into dev Oct 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants