Skip to content

CORS Proxy and createStreamFn

源码版本v0.73.1

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

  1. Decide proxy by provider: shouldUseProxyForProvider(provider, apiKey) has a built-in allowlist — zai and openai-codex always go through the proxy; Anthropic OAuth tokens (sk-ant-oat-*) go through the proxy; other providers connect directly, see packages/web-ui/src/utils/proxy-utils.ts:19-51.
  2. Rewrite baseUrl: applyProxyIfNeeded wraps model.baseUrl as ${proxyUrl}/?url=${encodeURIComponent(baseUrl)} without mutating the original model, see packages/web-ui/src/utils/proxy-utils.ts:61-82.
  3. Identify CORS errors: isCorsError matches TypeError: Failed to fetch, NetworkError, and messages containing cors/cross-origin, see packages/web-ui/src/utils/proxy-utils.ts:94-118.
  4. Wrap streamFn: createStreamFn(getProxyUrl) returns a (model, context, options) => Promise that decides internally whether to go direct or through the proxy, see packages/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

The full logic of createStreamFn is short — the core is calling streamSimple per case:

typescript
// 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:

typescript
// 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: getProxyUrl returns undefined, goes straight to streamSimple direct; it will not crash just because proxy is not configured, see packages/web-ui/src/utils/proxy-utils.ts:132-134.
  • Model has no baseUrl: applyProxyIfNeeded returns the original model without error, leaving the host to handle it, see packages/web-ui/src/utils/proxy-utils.ts:67-70.
  • Unknown provider: shouldUseProxyForProvider defaults 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 fetch could also be a network outage, and isCorsError will misidentify it as CORS; extract-document.ts uses it to decide whether to switch to the proxy path, see packages/web-ui/src/tools/extract-document.ts:108-119.
  • streamFn replacement condition: AgentInterface only replaces when session.streamFn === streamSimple; if the host has already injected a custom streamFn it is not overwritten, see packages/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.