Skip to content

Proxy CORS et createStreamFn

源码版本v0.73.1

Côté navigateur, appeler l'API LLM passe inévitablement par le CORS. Le fichier proxy-utils.ts de pi-web-ui centralise trois décisions — « faut-il passer par le proxy ? », « comment réécrire baseUrl ? », « l'erreur est-elle du CORS ? » — puis les emballe via createStreamFn en une fonction compatible avec Agent.streamFn. AgentInterface la branche sur session.streamFn dans setupSessionSubscription ; ensuite, chaque appel LLM passe par cette couche.

Responsabilités

  1. Décider du proxy selon le provider : shouldUseProxyForProvider(provider, apiKey) embarque une liste blanche ; zai et openai-codex passent toujours par le proxy, les jetons OAuth Anthropic (sk-ant-oat-*) passent par le proxy, les autres providers sont en connexion directe, voir packages/web-ui/src/utils/proxy-utils.ts:19-51.
  2. Réécrire baseUrl : applyProxyIfNeeded enveloppe model.baseUrl en ${proxyUrl}/?url=${encodeURIComponent(baseUrl)}, sans muter le model d'origine, voir packages/web-ui/src/utils/proxy-utils.ts:61-82.
  3. Reconnaître les erreurs CORS : isCorsError matche TypeError: Failed to fetch, NetworkError, ainsi que les messages contenant cors/cross-origin, voir packages/web-ui/src/utils/proxy-utils.ts:94-118.
  4. Emballer streamFn : createStreamFn(getProxyUrl) retourne (model, context, options) => Promise ; il décide en interne entre connexion directe et proxy, voir packages/web-ui/src/utils/proxy-utils.ts:127-139.

Motivations de design

Pourquoi ne pas laisser l'hôte modifier le model avant chaque appel ? Parce que l'hôte ne sait pas quel provider nécessite un proxy — une clé API Anthropic classique fonctionne en direct, mais un jeton OAuth doit passer par le proxy ; OpenAI Codex passe toujours par le proxy ; Z-Ai toujours aussi. Ces règles sont écrites dans proxy-utils.ts et centralisées : ajouter un provider ne demande qu'une seule modification. createStreamFn reçoit un callback getProxyUrl plutôt que de lire directement le storage, parce que AgentInterface doit lire dynamiquement les réglages proxy.enabled et proxy.url choisis par l'utilisateur, sans les figer une fois pour toutes à l'assemblage.

Stratégie connexion directe prioritaire : quand !apiKey || !proxyUrl, on appelle directement streamSimple(model, context, options) pour éviter une copie inutile du model. Ce n'est qu'avec à la fois la clé et le proxyUrl qu'on appelle applyProxyIfNeeded pour décider d'une éventuelle réécriture — la plupart des providers retournent false à shouldUseProxyForProvider et récupèrent le model d'origine.

Fichiers clés

La logique de createStreamFn est très courte ; le cœur, c'est un appel conditionnel à 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 énumère les stratégies par provider dans un switch, avec false par défaut :

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":
        // ... autres providers en connexion directe ...
            return false;
        default:
            return false;
    }
}

Flux de données

Le chemin de décision d'une requête LLM, de l'Agent vers streamSimple :

Limites et cas d'échec

  • Proxy non configuré : getProxyUrl retourne undefined, on prend directement streamSimple en connexion directe ; pas de crash si le proxy n'est pas configuré, voir packages/web-ui/src/utils/proxy-utils.ts:132-134.
  • Model sans baseUrl : applyProxyIfNeeded retourne le model d'origine sans erreur ; à l'hôte de gérer, voir packages/web-ui/src/utils/proxy-utils.ts:67-70.
  • Provider inconnu : shouldUseProxyForProvider renvoie false par défaut ; un nouveau provider fonctionne en connexion directe sans modification de code — en contrepartie, s'il a réellement besoin d'un proxy, on déclenchera une erreur CORS.
  • Faux positifs sur l'identification CORS : TypeError: Failed to fetch peut aussi provenir d'une coupure réseau ; isCorsError le classera à tort comme CORS. extract-document.ts s'en sert pour décider de basculer vers le chemin proxy, voir packages/web-ui/src/tools/extract-document.ts:108-119.
  • Condition de remplacement de streamFn : AgentInterface ne remplace que si session.streamFn === streamSimple ; si l'hôte a déjà injecté son propre streamFn, on ne l'écrase pas, voir packages/web-ui/src/components/AgentInterface.ts:138.

Pour résumer

proxy-utils ramène les décisions CORS côté navigateur à quelques fonctions pures : shouldUseProxyForProvider, applyProxyIfNeeded, isCorsError, createStreamFn. À l'assemblage, AgentInterface emballe le tout via createStreamFn ; ensuite, chaque appel LLM est décidé dynamiquement selon provider et clé. Pour le flux d'assemblage, voir AgentInterface, hôte de session ; pour les réglages proxy dépendant du storage, voir AppStorage et IndexedDB.