Proxy VisiDogVisiDog Proxy

Le relais CORS qui permet à VisiDog d'appeler les API LLM depuis votre navigateur. Code source, prérequis, installation et configuration.The CORS relay that lets VisiDog call LLM APIs from your browser. Source code, prerequisites, installation and configuration.

À quoi sert le proxy ?What is the proxy for?

VisiDog s'exécute entièrement dans votre navigateur. Les API LLM (OpenAI, Mistral, Anthropic…) n'acceptent généralement pas les requêtes cross-origin (CORS) : sans relais, votre navigateur serait bloqué. Le proxy VisiDog reçoit votre requête, la transmet à l'API cible (l'URL est passée dans l'en-tête X-Target-URL), puis renvoie la réponse — en gérant le CORS pour vous.

VisiDog runs entirely in your browser. LLM APIs (OpenAI, Mistral, Anthropic…) generally do not accept cross-origin requests (CORS): without a relay, your browser would be blocked. The VisiDog proxy receives your request, forwards it to the target API (the URL is passed in the X-Target-URL header), then sends the response back — handling CORS for you.

Votre navigateur (VisiDog)Your browser (VisiDog) Votre proxyYour proxy API LLMLLM API

Par défaut, VisiDog utilise le proxy hébergé à l'adresse /proxy du même domaine. Cette page vous permet d'héberger le vôtre, sur votre propre domaine, pour un contrôle total (rate limit, liste blanche de cibles, blocage anti-SSRF des cibles internes, données).

By default, VisiDog uses the proxy hosted at /proxy on the same domain. This page lets you host your own, on your own domain, for full control (rate limit, target whitelist, anti-SSRF blocking of internal targets, data).

PrérequisPrerequisites

InstallationInstallation

  1. Copiez le fichier proxy.php (code ci-dessous) sur votre hébergement, par exemple à la racine de votre domaine.
  2. Copy the proxy.php file (code below) to your hosting, for example at your domain root.
  3. Vérifiez que l'URL répond : ouvrez https://votre-domaine.com/proxy.php. Vous devez obtenir une réponse JSON d'erreur (origine refusée). Un HTTP 500 signifie que PHP ou curl manque.
  4. Check that the URL responds: open https://your-domain.com/proxy.php. You should get a JSON error response (origin rejected). An HTTP 500 means PHP or curl is missing.
  5. (Facultatif) Ajustez le tableau $CONFIG en haut du fichier — voir les options ci-dessous.
  6. (Optional) Adjust the $CONFIG array at the top of the file — see the options below.

Options de configurationConfiguration options

Configuration dans VisiDogConfiguration in VisiDog

  1. Ouvrez l'onglet Config de votre application VisiDog.
  2. Open the Config tab of your VisiDog application.
  3. Dans le champ « URL du proxy (relais CORS) » (paramètres d'évolution), saisissez l'URL de votre proxy, par exemple https://votre-domaine.com/proxy.php.
  4. In the "Proxy URL (CORS relay)" field (evolution settings), enter your proxy URL, for example https://your-domain.com/proxy.php.
  5. Enregistrez puis cliquez sur « Tester la connexion ». VisiDog envoie automatiquement au proxy l'URL cible (X-Target-URL) et la clé API (Authorization).
  6. Save, then click "Test connection". VisiDog automatically sends the target URL (X-Target-URL) and the API key (Authorization) to the proxy.
  7. En cas de succès (« Connexion réussie »), tous les appels LLM passent par votre proxy.
  8. On success ("Connection OK"), all LLM calls go through your proxy.
  9. Le proxy forward aussi l'auth Anthropic native (X-Api-Key, Anthropic-Version) et les headers d'attribution applicative des passerelles (HTTP-Referer, X-Title, X-OpenRouter-Title, X-OpenRouter-Categories, X-4everland-*) — envoyés automatiquement par VisiDog sur les cibles reconnues (OpenRouter, Vercel AI Gateway, 4everland, AnyRouter) pour l'attribution de l'application dans leurs rankings ; les autres fournisseurs les ignorent.
  10. The proxy also forwards native Anthropic auth (X-Api-Key, Anthropic-Version) and gateway app-attribution headers (HTTP-Referer, X-Title, X-OpenRouter-Title, X-OpenRouter-Categories, X-4everland-*) — sent automatically by VisiDog to recognized targets (OpenRouter, Vercel AI Gateway, 4everland, AnyRouter) for app attribution in their rankings; other providers ignore them.

Le codeThe code

Voici le code complet du proxy (proxy.php), sous licence MIT. Copiez-le tel quel sur votre serveur.

Here is the complete proxy code (proxy.php), MIT licensed. Copy it as-is to your server.

<?php
/**
 * VisiDog Proxy — Relai générique d'APIs tierces avec CORS dynamique.
 * Accepte toute target HTTPS sous réserve que l'origin soit visidog.com ou *.visidog.com
 *
 * MIT License
 *
 * Copyright (c) 2026 VisiDog
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

// ---------- Configuration ----------

$CONFIG = [
	'timeout' => 120,
	// Endpoints de génération d'images (/v1/images/*) : gpt-image-1 en qualité haute peut
	// dépasser le timeout générique — aligné sur les 180 s des endpoints spécialisés du
	// proxy d'entreprise (proxy-visidog.php).
	'image_timeout' => 180,
	'max_body_size' => 10 * 1024 * 1024,
	'allowed_methods' => ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
	'rate_limit' => ['enabled' => true, 'rpm' => 60, 'rph' => 1000],
	// Sécurité cibles (anti-SSRF) :
	// - https_only  : refuse http:// (les cibles doivent être en HTTPS).
	// - block_internal : bloque localhost, IP privées/réservées (10/8, 172.16/12, 192.168/16,
	//   127/8, 169.254/16 métadonnées cloud, link-local, multicast…) et les hôtes non résolubles.
	// - target_whitelist : si non vide, seuls ces hosts sont acceptés (exact ou *.dom).
	// - max_redirects : les redirections sont suivies manuellement et re-validées (max N).
	'https_only' => true,
	'block_internal' => true,
	'target_whitelist' => [],
	'max_redirects' => 5,
	// Flux SSE : pas de limite de durée totale (borné par l'activité), timeout de connexion
	// et coupure si la cible reste silencieuse plus de stream_idle_timeout secondes.
	'stream_connect_timeout' => 30,
	'stream_idle_timeout' => 600,
];

// ---------- Fonctions pures (CORS, anti-SSRF) — testables en CLI ----------

function isAllowedOrigin($o, $md) {
	if (!$o) return false;
	$h = parse_url($o, PHP_URL_HOST);
	if (!$h) return false;
	return $h === $md || str_ends_with($h, '.' . $md);
}

function sendCors($origin) {
	header('Access-Control-Allow-Origin: ' . $origin);
	header('Vary: Origin');
	header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
	// En plus des headers de transport (X-Target-*) : auth Anthropic native (x-api-key,
	// anthropic-version) et headers d'attribution applicative (HTTP-Referer, X-Title,
	// X-OpenRouter-*, X-4everland-*) — les passerelles les utilisent pour identifier
	// l'appelant ; les fournisseurs qui ne les connaissent pas les ignorent.
	header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Target-URL, X-Target-Headers, X-Api-Key, Anthropic-Version, HTTP-Referer, X-Title, X-OpenRouter-Title, X-OpenRouter-Categories, X-4everland-Title, X-4everland-Categories');
	header('Access-Control-Max-Age: 86400');
}

function isLoopbackHost($host) {
	$h = strtolower(trim($host, '[]'));
	if ($h === 'localhost' || $h === '::1' || str_ends_with($h, '.localhost')) return true;
	return (bool)preg_match('/^127\.\d+\.\d+\.\d+$/', $h);
}

function ipv4InRange($ip, $net, $prefix) {
	$ip = ip2long($ip);
	$net = ip2long($net);
	if ($ip === false || $net === false) return false;
	$mask = $prefix === 0 ? 0 : (~0) << (32 - $prefix);
	return ($ip & $mask) === ($net & $mask);
}

function isInternalIpV4($ip) {
	$ranges = [
		['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
		['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
		['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
		['224.0.0.0', 4], ['240.0.0.0', 4]
	];
	foreach ($ranges as [$n, $p]) {
		if (ipv4InRange($ip, $n, $p)) return true;
	}
	return false;
}

function ipv6InPrefix($bin, $netBin, $bits) {
	if ($netBin === false || $bin === false) return false;
	$bytes = intdiv($bits, 8);
	$rest = $bits % 8;
	if ($bytes > 0 && substr($bin, 0, $bytes) !== substr($netBin, 0, $bytes)) return false;
	if ($rest > 0) {
		$mask = (0xFF << (8 - $rest)) & 0xFF;
		if ((ord($bin[$bytes]) & $mask) !== (ord($netBin[$bytes]) & $mask)) return false;
	}
	return true;
}

function isInternalIp($ip) {
	$ip = trim($ip, '[]');
	if (strpos($ip, ':') !== false) {
		$bin = @inet_pton($ip);
		if ($bin === false) return false;
		// IPv4 mappée (::ffff:a.b.c.d) : réévaluer en IPv4.
		if (ipv6InPrefix($bin, @inet_pton('::ffff:0:0'), 96)) {
			return isInternalIpV4(inet_ntop(substr($bin, 12, 4)));
		}
		$ranges = [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]];
		foreach ($ranges as [$n, $p]) {
			if (ipv6InPrefix($bin, @inet_pton($n), $p)) return true;
		}
		return false;
	}
	return isInternalIpV4($ip);
}

function resolveTargetIps($host) {
	$h = trim($host, '[]');
	if (filter_var($h, FILTER_VALIDATE_IP)) return [$h];
	if (isLoopbackHost($h)) return ['127.0.0.1', '::1'];
	$ips = [];
	$recs = @dns_get_record($h, DNS_A | DNS_AAAA);
	if (is_array($recs)) {
		foreach ($recs as $r) {
			if (isset($r['ip'])) $ips[] = $r['ip'];
			elseif (isset($r['ipv6'])) $ips[] = $r['ipv6'];
		}
	}
	if (!$ips) {
		$g = @gethostbynamel($h);
		if (is_array($g)) $ips = array_merge($ips, $g);
	}
	return array_values(array_unique($ips));
}

function hostInWhitelist($host, $whitelist) {
	foreach ($whitelist as $w) {
		$w = strtolower(trim($w));
		if ($w === $host) return true;
		if (str_starts_with($w, '*.') && str_ends_with($host, substr($w, 1))) return true;
		if (str_starts_with($w, '.') && str_ends_with($host, $w)) return true;
	}
	return false;
}

// Retourne '' si la cible est autorisée, sinon une raison de blocage.
function isBlockedTarget($url, $config) {
	if (preg_match('/[\x00-\x1F\x7F]/', $url)) return 'Invalid target URL (control characters)';
	$parts = parse_url($url);
	if (!$parts || empty($parts['host'])) return 'Invalid target URL';
	$scheme = strtolower($parts['scheme'] ?? '');
	$host = strtolower(trim($parts['host'], '[]'));
	if (!in_array($scheme, ['http', 'https'], true)) return 'Target must use http or https';
	$isLocal = isLoopbackHost($host);
	$allowLocal = !empty($config['allow_local_targets']);
	if (($config['https_only'] ?? true) && $scheme !== 'https') {
		if (!($allowLocal && $isLocal)) return 'HTTPS required';
	}
	if (!empty($config['target_whitelist']) && !hostInWhitelist($host, $config['target_whitelist'])) {
		return 'Target not in whitelist';
	}
	if ($config['block_internal'] ?? true) {
		if ($isLocal) {
			if (!$allowLocal) return 'Local/internal target blocked';
		} else {
			$ips = resolveTargetIps($host);
			if (!$ips) return 'Target does not resolve';
			foreach ($ips as $ip) {
				if (isInternalIp($ip)) return 'Internal/private target blocked (' . $ip . ')';
			}
		}
	}
	return '';
}

function resolveRelativeUrl($base, $loc) {
	$locParts = parse_url($loc);
	if ($locParts && !empty($locParts['scheme']) && !empty($locParts['host'])) return $loc;
	if (str_starts_with($loc, '//')) {
		$scheme = parse_url($base, PHP_URL_SCHEME) ?: 'https';
		return $scheme . ':' . $loc;
	}
	$b = parse_url($base);
	$scheme = $b['scheme'] ?? 'https';
	$h = $b['host'] ?? '';
	if (!$h) return $loc;
	$port = isset($b['port']) ? ':' . $b['port'] : '';
	if (str_starts_with($loc, '/')) return $scheme . '://' . $h . $port . $loc;
	$path = $b['path'] ?? '/';
	$dir = substr($path, 0, strrpos($path, '/') + 1);
	return $scheme . '://' . $h . $port . ($dir ?: '/') . $loc;
}

function filterTargetHeaders($input) {
	$out = [];
	$arr = is_array($input) ? $input : (is_string($input) ? (json_decode($input, true) ?: []) : []);
	if (!is_array($arr)) return $out;
	foreach ($arr as $k => $v) {
		if (!is_string($k) || !preg_match('/^[A-Za-z0-9-]+$/', $k)) continue;
		if (is_array($v) || is_object($v)) continue;
		$cleanVal = str_replace(["\r", "\n"], '', (string)$v);
		$out[$k] = $cleanVal;
	}
	return $out;
}

// Garde-fou : si ce fichier est inclus (tests CLI), on ne définit que les fonctions
// pures ci-dessus sans déclencher le traitement de requête.
$IS_MAIN = (PHP_SAPI === 'cli')
	? (($argv[0] ?? '') !== '' && (realpath($argv[0]) === __FILE__ || (isset($argv[1]) && realpath($argv[1]) === __FILE__)))
	: (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__);

if (!$IS_MAIN) return;

$host = $_SERVER['HTTP_HOST'] ?? '';
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$hostOnly = explode(':', $host)[0];
$mainDomain = implode('.', array_slice(explode('.', $hostOnly), -2));

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
	if (!isAllowedOrigin($origin, $mainDomain)) {
		http_response_code(403);
		echo json_encode(['error' => 'Origin not allowed']);
		exit;
	}
	sendCors($origin);
	http_response_code(204);
	exit;
}

if (!isAllowedOrigin($origin, $mainDomain)) {
	http_response_code(403);
	header('Content-Type: application/json');
	echo json_encode(['error' => 'Origin not allowed', 'host' => $host, 'origin' => $origin]);
	exit;
}

sendCors($origin);

$method = $_SERVER['REQUEST_METHOD'];
if (!in_array($method, $CONFIG['allowed_methods'], true)) {
	http_response_code(405);
	header('Content-Type: application/json');
	echo json_encode(['error' => 'Method not allowed']);
	exit;
}

$targetUrl = $_SERVER['HTTP_X_TARGET_URL'] ?? '';
if (!$targetUrl || !filter_var($targetUrl, FILTER_VALIDATE_URL)) {
	http_response_code(400);
	header('Content-Type: application/json');
	echo json_encode(['error' => 'Invalid or missing X-Target-URL']);
	exit;
}

$blocked = isBlockedTarget($targetUrl, $CONFIG);
if ($blocked) {
	http_response_code(403);
	header('Content-Type: application/json');
	echo json_encode(['error' => 'Target blocked', 'detail' => $blocked]);
	exit;
}

if ($CONFIG['rate_limit']['enabled']) {
	$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
	$salt = hash_hmac('sha256', $ip, __FILE__ . (string)filemtime(__FILE__));
	$key = sys_get_temp_dir() . '/vd_rl_' . substr($salt, 0, 32) . '.json';
	$now = time();
	$fp = @fopen($key, 'c+');
	if ($fp && flock($fp, LOCK_EX)) {
		$content = stream_get_contents($fp);
		$window = ($content ? json_decode($content, true) : null) ?: [];
		$window = array_filter($window, fn($t) => is_numeric($t) && ($now - $t < 3600));
		$recent = array_filter($window, fn($t) => $now - $t < 60);
		if (count($recent) >= $CONFIG['rate_limit']['rpm'] || count($window) >= $CONFIG['rate_limit']['rph']) {
			flock($fp, LOCK_UN);
			fclose($fp);
			http_response_code(429);
			header('Content-Type: application/json');
			echo json_encode(['error' => 'Rate limit exceeded', 'retry_after' => 60]);
			exit;
		}
		$window[] = $now;
		ftruncate($fp, 0);
		rewind($fp);
		fwrite($fp, json_encode(array_values($window)));
		fflush($fp);
		flock($fp, LOCK_UN);
		fclose($fp);
	}
}

$body = file_get_contents('php://input');
if (strlen($body) > $CONFIG['max_body_size']) {
	http_response_code(413);
	header('Content-Type: application/json');
	echo json_encode(['error' => 'Body too large']);
	exit;
}

$rawExtra = $_SERVER['HTTP_X_TARGET_HEADERS'] ?? '';
$extraHeaders = filterTargetHeaders($rawExtra);

$headers = [];
// Headers forwardés vers la cible : auth (Bearer / apikey / x-api-key Anthropic natif),
// anthropic-version (requis par /v1/messages), et attribution applicative des passerelles
// (HTTP-Referer, X-Title, X-OpenRouter-*, X-4everland-*) — référencées dans la doc
// OpenRouter (app-attribution) et suivies par les autres passerelles compatibles.
// NB : le header « HTTP-Referer » arrive en $_SERVER['HTTP_HTTP_REFERER'] (préfixe HTTP_
// ajouté par PHP/SAPI à tout header de requête).
$forwardMap = [
	'Authorization' => 'HTTP_AUTHORIZATION',
	'Content-Type' => 'CONTENT_TYPE',
	'apikey' => 'HTTP_APIKEY',
	'Api-Key' => 'HTTP_API_KEY',
	'X-Api-Key' => 'HTTP_X_API_KEY',
	'Anthropic-Version' => 'HTTP_ANTHROPIC_VERSION',
	'HTTP-Referer' => 'HTTP_HTTP_REFERER',
	'X-Title' => 'HTTP_X_TITLE',
	'X-OpenRouter-Title' => 'HTTP_X_OPENROUTER_TITLE',
	'X-OpenRouter-Categories' => 'HTTP_X_OPENROUTER_CATEGORIES',
	'X-4everland-Title' => 'HTTP_X_4EVERLAND_TITLE',
	'X-4everland-Categories' => 'HTTP_X_4EVERLAND_CATEGORIES',
];
foreach ($forwardMap as $h => $srvKey) {
	$v = $_SERVER[$srvKey] ?? '';
	if ($v) $headers[] = "$h: $v";
}
foreach ($extraHeaders as $k => $v) $headers[] = "$k: $v";
if ($body) {
	if (!in_array('Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? ''), $headers, true)) {
		$headers[] = 'Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? 'application/json');
	}
}

$isStream = false;
if ($body) {
	$decodedBody = json_decode($body, true);
	if (is_array($decodedBody) && ($decodedBody['stream'] ?? false)) $isStream = true;
}

// Streaming : transmettre les octets dès réception (TTFB réduit, pas de buffer complet en mémoire).
// Valable pour toutes les réponses, pas seulement le mode SSE.

// Désactiver tout buffering/compression qui casserait le flux :
// - zlib (output_buffering) bufferise par défaut côté PHP ;
// - mod_deflate (Apache) bufferise/compresse et retarde l'envoi des octets.
if (function_exists('apache_setenv')) @apache_setenv('no-gzip', '1');
@ini_set('zlib.output_compression', '0');
@ini_set('implicit_flush', '1');
@ini_set('output_buffering', '0');
while (ob_get_level() > 0) ob_end_flush();
ob_implicit_flush(true);

// Redirections suivies MANUELLEMENT (FOLLOWLOCATION désactivé) : chaque nouvelle cible est
// re-validée par isBlockedTarget — une redirection vers un hôte interne est refusée.
$currentUrl = $targetUrl;
$maxRedirects = max(0, (int)($CONFIG['max_redirects'] ?? 5));
$handled = false;

for ($attempt = 0; $attempt <= $maxRedirects; $attempt++) {
	$ch = curl_init($currentUrl);
	curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
	// HEAD : NOBODY obligatoire — CUSTOMREQUEST seul laisserait cURL attendre un corps
	if ($method === 'HEAD') curl_setopt($ch, CURLOPT_NOBODY, true);
	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
	curl_setopt($ch, CURLOPT_MAXREDIRS, 0);
	// Réutilisation agressive des sockets TCP Keep-Alive pour éviter les handshakes TLS répétés
	curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1);
	curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 120);
	curl_setopt($ch, CURLOPT_TCP_KEEPINTVL, 60);
	// Accepter gzip/deflate : curl envoie l'Accept-Encoding et décompresse automatiquement
	curl_setopt($ch, CURLOPT_ENCODING, 'gzip, deflate');
	curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
	if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);

	// Anti-TOCTOU DNS-rebinding : épingler l'adresse IP validée pour que cURL utilise la même résolution
	$targetHost = parse_url($currentUrl, PHP_URL_HOST);
	$targetPort = parse_url($currentUrl, PHP_URL_PORT) ?: (parse_url($currentUrl, PHP_URL_SCHEME) === 'http' ? 80 : 443);
	if ($targetHost && !filter_var($targetHost, FILTER_VALIDATE_IP)) {
		$targetIps = resolveTargetIps($targetHost);
		$resolveList = [];
		foreach ($targetIps as $tip) {
			if (!isInternalIp($tip)) {
				$resolveList[] = "$targetHost:$targetPort:$tip";
			}
		}
		if (!empty($resolveList)) {
			curl_setopt($ch, CURLOPT_RESOLVE, $resolveList);
		}
	}

	if ($isStream) {
		// Flux SSE : pas de limite de durée totale — une génération longue doit pouvoir tenir.
		// Le flux est borné par l'activité (LOW_SPEED_TIME) : si la cible reste silencieuse
		// plus de stream_idle_timeout secondes, la connexion est coupée.
		curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $CONFIG['stream_connect_timeout'] ?? 30);
		curl_setopt($ch, CURLOPT_TIMEOUT, 0);
		curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1);
		curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, $CONFIG['stream_idle_timeout'] ?? 600);
	} else {
		// Timeout étendu pour les endpoints de génération d'images (voir $CONFIG['image_timeout'])
		$isImageTarget = str_contains((string)parse_url($currentUrl, PHP_URL_PATH), '/v1/images');
		curl_setopt($ch, CURLOPT_TIMEOUT, $isImageTarget ? $CONFIG['image_timeout'] : $CONFIG['timeout']);
	}

	$statusSent = false;
	$statusCode = 0;
	$isRedirect = false;
	$lastLocation = '';
	$responseContentType = '';

	$sendHeaders = function () use (&$statusSent, &$statusCode, &$responseContentType, $isStream) {
		if ($statusSent) return;
		$statusSent = true;
		http_response_code($statusCode ?: 502);
		if ($isStream) {
			header('Content-Type: text/event-stream; charset=utf-8');
			header('Cache-Control: no-cache, no-transform');
			header('X-Accel-Buffering: no'); // désactive le buffering nginx
			header('Connection: keep-alive');
		} else {
			header('Content-Type: ' . ($responseContentType ?: 'application/json'));
		}
	};

	// Status + headers envoyés dès réception des headers de la cible (TTFB minimal),
	// puis le corps est relayé octet par octet. Les redirections (3xx) ne déclenchent
	// PAS l'envoi des headers : seule la réponse finale compte.
	curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$statusCode, &$isRedirect, &$lastLocation, &$responseContentType, $sendHeaders) {
		if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $line, $m)) {
			$statusCode = (int)$m[1];
			$isRedirect = $statusCode >= 300 && $statusCode < 400;
			if (!$isRedirect) $sendHeaders();
		} else if ($isRedirect && stripos($line, 'Location:') === 0) {
			$lastLocation = trim(substr($line, 9));
		} else if (stripos($line, 'Content-Type:') === 0) {
			$responseContentType = trim(substr($line, 13));
		}
		return strlen($line);
	});
	curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) use (&$statusCode, &$isRedirect, &$lastLocation, $sendHeaders) {
		// Libération immédiate si le client a rompu la connexion (Quiet-Watch abort ou Ctrl+C)
		if (connection_aborted()) {
			return 0;
		}
		if (!$statusCode) $statusCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
		// Sur une redirection suivie, le corps n'est pas relayé (réponse intermédiaire).
		$follow = $isRedirect && $lastLocation !== '';
		if (!$follow) {
			$sendHeaders();
			echo $data;
			flush();
		}
		return strlen($data);
	});
	curl_exec($ch);

	$err = curl_error($ch);
	// curl_close() est un no-op depuis PHP 8.0 et déprécié en 8.5 : on ne l'appelle pas
	// (la ressource est libérée en fin de script, et un warning interromprait le flux SSE).

	if ($isRedirect && $lastLocation !== '') {
		$next = filter_var($lastLocation, FILTER_VALIDATE_URL) ? $lastLocation : resolveRelativeUrl($currentUrl, $lastLocation);
		$redirectBlocked = isBlockedTarget($next, $CONFIG);
		if ($redirectBlocked) {
			http_response_code(502);
			header('Content-Type: application/json');
			echo json_encode(['error' => 'Redirect target blocked', 'detail' => $redirectBlocked]);
			exit;
		}
		// Redirection 303 (See Other) ou 302/301 sur POST : transformer en GET sans corps
		if ($statusCode === 303 || (($statusCode === 301 || $statusCode === 302) && $method === 'POST')) {
			$method = 'GET';
			$body = '';
			$headers = array_values(array_filter($headers, function ($h) {
				return !preg_match('/^(Content-Type|Content-Length)\s*:/i', trim($h));
			}));
		}
		// Ne jamais transmettre les identifiants (Authorization, clés, y compris x-api-key
		// Anthropic) vers un autre hôte
		// (équivalent du comportement curl CURLOPT_UNRESTRICTED_AUTH non défini).
		$nextHost = strtolower(trim((parse_url($next, PHP_URL_HOST) ?? ''), '[]'));
		$curHost = strtolower(trim((parse_url($currentUrl, PHP_URL_HOST) ?? ''), '[]'));
		if ($nextHost !== $curHost) {
			$headers = array_values(array_filter($headers, function ($h) {
				return !preg_match('/^(Authorization|apikey|api-key|x-api-key|cookie|proxy-authorization)\s*:/i', trim($h));
			}));
		}
		$currentUrl = $next;
		continue;
	}

	if ($err && !$statusSent && !headers_sent()) {
		http_response_code(502);
		header('Content-Type: application/json');
		echo json_encode(['error' => 'Target unreachable', 'detail' => $err]);
	} elseif (!$statusSent && !headers_sent()) {
		// Réponse 304/3xx sans Location ni corps : aucun fragment de corps n'a
		// déclenché l'envoi des en-têtes — sans ce filet, le client recevait
		// "200 + corps vide" au lieu du statut réel (négociation de cache cassée).
		$sendHeaders();
	}
	$handled = true;
	break;
}

if (!$handled) {
	http_response_code(502);
	header('Content-Type: application/json');
	echo json_encode(['error' => 'Too many redirects']);
}