Skip to content

CORS プロキシと createStreamFn

源码版本v0.73.1

ブラウザ側から LLM API を叩くには CORS が避けられない。pi-web-uiproxy-utils.ts は「プロキシを通すか」「baseUrl をどう書き換えるか」「エラーが CORS 起因か」という 3 つの判断を一箇所に集め、createStreamFnAgent.streamFn 互換の関数に包む。AgentInterfacesetupSessionSubscription のタイミングでこれを session.streamFn に差し、以後すべての LLM 呼び出しがこの層を通る。

責務

  1. provider ごとにプロキシ判定:shouldUseProxyForProvider(provider, apiKey) は内製のホワイトリストを持つ。zaiopenai-codex は強制プロキシ、Anthropic の OAuth token(sk-ant-oat-*)はプロキシ、それ以外は直結。packages/web-ui/src/utils/proxy-utils.ts:19-51 参照。
  2. baseUrl の書き換え:applyProxyIfNeededmodel.baseUrl${proxyUrl}/?url=${encodeURIComponent(baseUrl)} に包む。元の model は書き換えない。packages/web-ui/src/utils/proxy-utils.ts:61-82 参照。
  3. CORS エラーの識別:isCorsErrorTypeError: Failed to fetchNetworkError、メッセージに cors/cross-origin を含むものにマッチする。packages/web-ui/src/utils/proxy-utils.ts:94-118 参照。
  4. streamFn のラップ:createStreamFn(getProxyUrl)(model, context, options) => Promise を返し、内部で直結かプロキシかを決める。packages/web-ui/src/utils/proxy-utils.ts:127-139 参照。

設計動機

なぜ host に毎回 model を書き換えさせないのか。host はどの provider がプロキシ要るかを知らないからだ。Anthropic の通常 API key は直結で通るが OAuth token はプロキシ必須、OpenAI Codex は常にプロキシ、Z-AI も常にプロキシ。これらのルールは proxy-utils.ts に集めて保守し、provider 追加時に 1 箇所直せば済むようにする。createStreamFngetProxyUrl コールバックを受け取り、storage を直接読まない設計なのは、AgentInterface がユーザー設定の proxy.enabledproxy.url を動的に読む必要があるためだ。組み立て時点で一度読んで固定してはいけない。

直結優先の方針:!apiKey || !proxyUrl のときはそのまま streamSimple(model, context, options) を呼び、無駄な model オブジェクトのコピーを避ける。key と proxyUrl が両方揃ったときだけ applyProxyIfNeeded を呼び、本当に書き換えるか判定する。大半の provider は shouldUseProxyForProvider が false を返すので、元の model をそのまま受け取る。

主要ファイル

createStreamFn のロジック全体は短く、状況に応じて streamSimple を呼ぶだけ:

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 は switch で各 provider の方針を並べ、デフォルトは 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":
        // ... 其他直连 provider ...
            return false;
        default:
            return false;
    }
}

データフロー

LLM リクエストが Agent から streamSimple に至るまでの判断経路:

境界と失敗

  • proxy 未設定:getProxyUrlundefined を返すので、そのまま streamSimple で直結する。proxy が無くて落ちることはない。packages/web-ui/src/utils/proxy-utils.ts:132-134 参照。
  • model に baseUrl が無い:applyProxyIfNeeded は元の model をそのまま返し、エラーも出さない。host に処理を委ねる。packages/web-ui/src/utils/proxy-utils.ts:67-70 参照。
  • 未知の provider:shouldUseProxyForProvider はデフォルト false。新 provider はコード修正なしで直結動作する。代償として、その provider が実際にプロキシを要する場合、CORS エラーが発火する。
  • CORS エラー識別の偽陽性:TypeError: Failed to fetch はネットワーク切断でも起きるが、isCorsError は CORS 扱いする。extract-document.ts はこれを使ってプロキシパスへ切り替えるかを決める。packages/web-ui/src/tools/extract-document.ts:108-119 参照。
  • streamFn 差し替えの条件:AgentInterfacesession.streamFn === streamSimple のときだけ差し替える。host が独自 streamFn を既に注入している場合は上書きしない。packages/web-ui/src/components/AgentInterface.ts:138 参照。

まとめ

proxy-utils はブラウザ側の CORS 判断をいくつかの純粋関数に収束させる:shouldUseProxyForProviderapplyProxyIfNeededisCorsErrorcreateStreamFnAgentInterface は組み立て時に createStreamFn で一層包み、以後の LLM 呼び出しは毎回 provider と key に応じて動的に判断する。組み立ての流れは AgentInterface セッションホスト、依存する storage の proxy 設定は AppStorage と IndexedDB を参照。