CORS Proxy and createStreamFn
Calling LLM APIs from the browser cannot avoid CORS. pi-web-ui's proxy-utils.ts centralizes three decisions — "should this go through a proxy", "how to rewrite baseUrl", "is this error a CORS error" — and wraps them through createStreamFn into a function compatible with Agent.streamFn. AgentInterface attaches it to session.streamFn during setupSessionSubscription, and every LLM call afterwards goes through this layer.
Responsibilities
- Decide proxy by provider:
shouldUseProxyForProvider(provider, apiKey)has a built-in allowlist —zaiandopenai-codexalways go through the proxy; Anthropic OAuth tokens (sk-ant-oat-*) go through the proxy; other providers connect directly, seepackages/web-ui/src/utils/proxy-utils.ts:19-51. - Rewrite baseUrl:
applyProxyIfNeededwrapsmodel.baseUrlas${proxyUrl}/?url=${encodeURIComponent(baseUrl)}without mutating the original model, seepackages/web-ui/src/utils/proxy-utils.ts:61-82. - Identify CORS errors:
isCorsErrormatchesTypeError: Failed to fetch,NetworkError, and messages containingcors/cross-origin, seepackages/web-ui/src/utils/proxy-utils.ts:94-118. - Wrap streamFn:
createStreamFn(getProxyUrl)returns a(model, context, options) => Promisethat decides internally whether to go direct or through the proxy, seepackages/web-ui/src/utils/proxy-utils.ts:127-139.
Design motivation
Why not let the host modify the model before each call? Because the host does not know which provider needs a proxy — a regular Anthropic API key works direct, but an OAuth token must go through the proxy; OpenAI Codex always goes through the proxy; Z-AI always goes through the proxy. These rules live in proxy-utils.ts and are maintained in one place; adding a provider only needs a single edit. createStreamFn takes a getProxyUrl callback instead of reading storage directly, because AgentInterface needs to dynamically read the user's proxy.enabled and proxy.url — it cannot read them once at wiring time and bake them in.
Direct-first strategy: when !apiKey || !proxyUrl, it just calls streamSimple(model, context, options) to avoid pointless model object copies. Only when both key and proxyUrl are present does it call applyProxyIfNeeded to decide whether to actually rewrite — most providers will get the original model back because shouldUseProxyForProvider returns false.
Key files
packages/web-ui/src/utils/proxy-utils.ts:19-51—shouldUseProxyForProvider, judged by provider and key prefix.packages/web-ui/src/utils/proxy-utils.ts:61-82—applyProxyIfNeeded, returns a new model; original model untouched.packages/web-ui/src/utils/proxy-utils.ts:94-118—isCorsError, matches several browser error forms.packages/web-ui/src/utils/proxy-utils.ts:127-139—createStreamFn, the wiring point.packages/web-ui/src/components/AgentInterface.ts:138-143—AgentInterfacecallscreateStreamFn, passing a closure that readsproxy.urlfromAppStorage.
The full logic of createStreamFn is short — the core is calling streamSimple per case:
// packages/web-ui/src/utils/proxy-utils.ts:127-139
export function createStreamFn(getProxyUrl: () => Promise<string | undefined>) {
return async (model: Model<any>, context: Context, options?: SimpleStreamOptions) => {
const apiKey = options?.apiKey;
const proxyUrl = await getProxyUrl();
if (!apiKey || !proxyUrl) {
return streamSimple(model, context, options);
}
const proxiedModel = applyProxyIfNeeded(model, apiKey, proxyUrl);
return streamSimple(proxiedModel, context, options);
};
}shouldUseProxyForProvider uses a switch to list each provider's policy, defaulting to false:
// packages/web-ui/src/utils/proxy-utils.ts:19-51
export function shouldUseProxyForProvider(provider: string, apiKey: string): boolean {
switch (provider.toLowerCase()) {
case "zai":
return true;
case "anthropic":
return apiKey.startsWith("sk-ant-oat") || apiKey.startsWith("{");
case "openai-codex":
return true;
case "openai":
case "google":
// ... other direct providers ...
return false;
default:
return false;
}
}Data flow
The decision path of an LLM request from Agent to streamSimple:
Edges and failures
- No proxy configured:
getProxyUrlreturnsundefined, goes straight tostreamSimpledirect; it will not crash just because proxy is not configured, seepackages/web-ui/src/utils/proxy-utils.ts:132-134. - Model has no baseUrl:
applyProxyIfNeededreturns the original model without error, leaving the host to handle it, seepackages/web-ui/src/utils/proxy-utils.ts:67-70. - Unknown provider:
shouldUseProxyForProviderdefaults to false; a new provider works direct without code changes, at the cost that if the provider actually needs a proxy it will trigger a CORS error. - CORS error false positives:
TypeError: Failed to fetchcould also be a network outage, andisCorsErrorwill misidentify it as CORS;extract-document.tsuses it to decide whether to switch to the proxy path, seepackages/web-ui/src/tools/extract-document.ts:108-119. - streamFn replacement condition:
AgentInterfaceonly replaces whensession.streamFn === streamSimple; if the host has already injected a custom streamFn it is not overwritten, seepackages/web-ui/src/components/AgentInterface.ts:138.
Summary
proxy-utils collapses the browser-side CORS decision into a few pure functions: shouldUseProxyForProvider, applyProxyIfNeeded, isCorsError, createStreamFn. AgentInterface wraps with createStreamFn during wiring, and every subsequent LLM call decides dynamically based on provider and key. For the wiring flow see AgentInterface session host; for the storage-backed proxy settings see AppStorage and IndexedDB.