CORS 代理與 createStreamFn
瀏覽器側呼叫 LLM API,繞不開 CORS。pi-web-ui 的 proxy-utils.ts 把「要不要走代理」「怎麼改 baseUrl」「錯誤是不是 CORS」三個判斷集中起來,再透過 createStreamFn 包裝成 Agent.streamFn 相容的函式。AgentInterface 在 setupSessionSubscription 時把它掛到 session.streamFn,之後每次 LLM 呼叫都走這層。
職責
- 按 provider 決定走代理:
shouldUseProxyForProvider(provider, apiKey)內建一張白名單,zai、openai-codex強制走代理,Anthropic OAuth token(sk-ant-oat-*)走代理,其餘 provider 直連,見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 只需要改一處。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。