CORS プロキシと createStreamFn
ブラウザ側から LLM API を叩くには CORS が避けられない。pi-web-ui の proxy-utils.ts は「プロキシを通すか」「baseUrl をどう書き換えるか」「エラーが CORS 起因か」という 3 つの判断を一箇所に集め、createStreamFn で Agent.streamFn 互換の関数に包む。AgentInterface は setupSessionSubscription のタイミングでこれを session.streamFn に差し、以後すべての LLM 呼び出しがこの層を通る。
責務
- provider ごとにプロキシ判定:
shouldUseProxyForProvider(provider, apiKey)は内製のホワイトリストを持つ。zai、openai-codexは強制プロキシ、Anthropic の OAuth token(sk-ant-oat-*)はプロキシ、それ以外は直結。packages/web-ui/src/utils/proxy-utils.ts:19-51参照。 - baseUrl の書き換え:
applyProxyIfNeededはmodel.baseUrlを${proxyUrl}/?url=${encodeURIComponent(baseUrl)}に包む。元の model は書き換えない。packages/web-ui/src/utils/proxy-utils.ts:61-82参照。 - CORS エラーの識別:
isCorsErrorはTypeError: Failed to fetch、NetworkError、メッセージにcors/cross-originを含むものにマッチする。packages/web-ui/src/utils/proxy-utils.ts:94-118参照。 - 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 箇所直せば済むようにする。createStreamFn が getProxyUrl コールバックを受け取り、storage を直接読まない設計なのは、AgentInterface がユーザー設定の proxy.enabled と proxy.url を動的に読む必要があるためだ。組み立て時点で一度読んで固定してはいけない。
直結優先の方針:!apiKey || !proxyUrl のときはそのまま streamSimple(model, context, options) を呼び、無駄な model オブジェクトのコピーを避ける。key と proxyUrl が両方揃ったときだけ applyProxyIfNeeded を呼び、本当に書き換えるか判定する。大半の provider は shouldUseProxyForProvider が false を返すので、元の model をそのまま受け取る。
主要ファイル
packages/web-ui/src/utils/proxy-utils.ts:19-51—shouldUseProxyForProvider。provider と key のプレフィックスで判定。packages/web-ui/src/utils/proxy-utils.ts:61-82—applyProxyIfNeeded。新しい model を返し、元の model は触らない。packages/web-ui/src/utils/proxy-utils.ts:94-118—isCorsError。ブラウザの複数エラー表現にマッチ。packages/web-ui/src/utils/proxy-utils.ts:127-139—createStreamFn。組み立て地点。packages/web-ui/src/components/AgentInterface.ts:138-143—AgentInterfaceがcreateStreamFnを呼び、AppStorageから proxy.url を読むクロージャを渡す。
createStreamFn のロジック全体は短く、状況に応じて streamSimple を呼ぶだけ:
// 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:
// 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 未設定:
getProxyUrlがundefinedを返すので、そのまま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 差し替えの条件:
AgentInterfaceはsession.streamFn === streamSimpleのときだけ差し替える。host が独自 streamFn を既に注入している場合は上書きしない。packages/web-ui/src/components/AgentInterface.ts:138参照。
まとめ
proxy-utils はブラウザ側の CORS 判断をいくつかの純粋関数に収束させる:shouldUseProxyForProvider、applyProxyIfNeeded、isCorsError、createStreamFn。AgentInterface は組み立て時に createStreamFn で一層包み、以後の LLM 呼び出しは毎回 provider と key に応じて動的に判断する。組み立ての流れは AgentInterface セッションホスト、依存する storage の proxy 設定は AppStorage と IndexedDB を参照。