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。