// AI RELAY

Relais IA auto-hébergé

Regroupez toutes vos clés API sur un appareil inactif et ne remplissez qu'une adresse sur votre téléphone — chat IA, programmation IA et génération de plugins IA, tout se connecte en douceur.

Pourquoi un relais ?

Les outils IA de CosmosBox (chat IA, programmation IA, génération de plugins IA) fonctionnent en mode BYOK : vous apportez vos propres clés API pour chaque fournisseur. En connexion directe avec plusieurs fournisseurs, il faut une clé et une BaseURL par fournisseur, et changer de configuration à chaque changement de modèle.

Un relais auto-hébergé condense tout cela en une seule adresse : les clés de chaque fournisseur se configurent une fois sur l'appareil relais, et sur le téléphone vous ne remplissez qu'une adresse de relais + une clé de relais. Les noms de modèles sont routés automatiquement vers le bon fournisseur. Le relais réessaie aussi avec un backoff exponentiel quand un fournisseur est occupé, ce qui stabilise l'expérience sur réseau faible.

Connexion directe multiple vs. relais

AspectDirecte multi-fournisseurAvec relais
Configuration appUne clé et une adresse par fournisseurUne seule adresse + une clé
Changement de modèleChangement manuel de configurationNom du modèle routé automatiquement
Fournisseur occupé (429)Échec immédiat, nouvel essai manuelNouvel essai automatique avec backoff exponentiel
Gestion des clésÉparpillées sur les appareilsStockées uniquement sur l'appareil relais

Comment ça marche

Le relais est un service de transfert très léger compatible OpenAI qui tourne sur votre propre appareil. Votre requête va d'abord au relais ; il trouve le fournisseur selon le nom du modèle, transfère, et renvoie le résultat en streaming vers votre téléphone. Tout le service tient dans un seul fichier Node.js sans dépendance — aucun paquet tiers, aucune base de données.

  1. 1L'app CosmosBox sur le téléphone envoie la requête → adresse du relais
  2. 2Le relais associe le nom du modèle à un fournisseur (ex. deepseek-v4-flash → DeepSeek)
  3. 3Le fournisseur génère le contenu et le diffuse mot par mot en retour
  4. 4Le relais transfère tel quel ; l'app l'affiche en temps réel

Préparation

  • Un appareil inactif : vieux téléphone Android, box TV Android ou vieux PC (il n'exécute pas de modèles, consommation minimale)
  • Vieux téléphone / box TV : installer Termux (les box TV nécessitent un APK chargé en sideload) ; vieux PC : installer Node.js 18 ou plus récent
  • Au moins une clé API de LLM : DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI… tout fournisseur compatible OpenAI fonctionne

Déploiement en un clic (vieux téléphone / box TV · Termux)

Copiez entièrement le script install-termux.sh ci-dessous (ou récupérez-le depuis le projet relais IA), puis exécutez-le dans Termux : le script installe Node.js, génère la configuration avec un assistant interactif (saisie des clés de chaque fournisseur une par une), démarre le relais et affiche les réglages pour votre téléphone.

install-termux.sh (script en un clic)
#!/data/data/com.termux/files/usr/bin/bash
# ============================================================
# 宇宙核 AI 中继 · Termux 一键安装脚本(旧手机 / 电视盒子)
# 用法:在 Termux 里执行  bash install-termux.sh
# 或先 curl 下载本脚本再执行。
# 步骤:装 Node → 生成 config.json(向导式)→ 启动 → 打印配置信息
# ============================================================
set -e

echo "========================================"
echo "  宇宙核 AI 中继 · 一键安装"
echo "========================================"

# ---------- 1. 安装 Node.js ----------
if ! command -v node >/dev/null 2>&1; then
  echo ""
  echo "[1/4] 安装 Node.js(约 1-2 分钟)..."
  pkg update -y
  pkg install -y nodejs
else
  echo "[1/4] Node.js 已安装: $(node -v)"
fi

# ---------- 2. 准备中继文件 ----------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RELAY="$SCRIPT_DIR/relay.mjs"
if [ ! -f "$RELAY" ]; then
  echo ""
  echo "[2/4] 下载 relay.mjs ..."
  # 官网教程页有完整代码;此处尝试常见来源(无则提示手动创建)
  curl -fsSL -o "$RELAY" https://yuzhouhe.com/relay/relay.mjs \
    || echo "⚠️ 自动下载失败:请从官网教程页复制 relay.mjs 代码保存为 $RELAY"
  if [ ! -f "$RELAY" ]; then
    echo "请先解决 relay.mjs 缺失,再重新运行本脚本。"
    exit 1
  fi
else
  echo "[2/4] relay.mjs 已存在"
fi

# ---------- 3. 配置向导 ----------
CONFIG="$SCRIPT_DIR/config.json"
if [ ! -f "$CONFIG" ]; then
  echo ""
  echo "[3/4] 首次运行,创建配置(各家 API Key 只保存在本机,不会上传)..."
  read -rp "中继端口(默认 8000): " PORT
  PORT="${PORT:-8000}"
  read -rp "中继 Key(手机 App 里要填,建议 16 位以上随机字符): " RELAY_KEY
  while [ -z "$RELAY_KEY" ]; do
    read -rp "中继 Key 不能为空,请重新输入: " RELAY_KEY
  done

  cat > "$CONFIG" <<EOF
{
  "port": $PORT,
  "relayKey": "$RELAY_KEY",
  "timeoutSeconds": 300,
  "maxRetries": 2,
  "log": true,
  "upstreams": [
EOF
  echo "  现在逐个添加上游服务商(留空服务商名称 = 完成)"
  FIRST=1
  while true; do
    read -rp "服务商名称(如 deepseek / qwen / kimi,留空结束): " NAME
    [ -z "$NAME" ] && break
    read -rp "  $NAME 的 BaseURL(如 https://api.deepseek.com): " BASE
    read -rp "  $NAME 的 API Key: " KEY
    read -rp "  模型列表(逗号分隔,如 deepseek-v4-flash,deepseek-chat): " MODELS
    if [ -n "$BASE" ] && [ -n "$KEY" ] && [ -n "$MODELS" ]; then
      [ "$FIRST" = "0" ] && echo "    ," >> "$CONFIG"
      cat >> "$CONFIG" <<EOF
    {
      "name": "$NAME",
      "baseUrl": "$BASE",
      "apiKey": "$KEY",
      "models": [$(echo "$MODELS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/.*/"&"/' | paste -sd,)]
    }
EOF
      FIRST=0
    else
      echo "  ⚠️ 信息不完整,跳过该服务商"
    fi
  done
  echo "  ]" >> "$CONFIG"
  echo "}" >> "$CONFIG"
else
  echo "[3/4] config.json 已存在(如需修改直接编辑它)"
fi

# ---------- 4. 启动 ----------
echo ""
echo "[4/4] 启动中继..."
termux-wake-lock 2>/dev/null || true   # 防止息屏断网(不支持时忽略)

# 后台启动(关闭 Termux 窗口后继续运行;重启手机后需重新运行本脚本)
nohup node "$RELAY" "$CONFIG" > "$SCRIPT_DIR/relay.log" 2>&1 &
sleep 1
if pgrep -f "node .*relay.mjs" >/dev/null 2>&1; then
  echo ""
  echo "✅ 中继已启动!日志: $SCRIPT_DIR/relay.log"
else
  echo "❌ 启动失败,请查看 $SCRIPT_DIR/relay.log"
  exit 1
fi

# 显示局域网地址
IP=$(ifconfig 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' | head -1 | awk '{print $2}')
PORT=$(grep '"port"' "$CONFIG" | grep -oE '[0-9]+' | head -1)

echo ""
echo "========================================"
echo "  在「宇宙核」App 里这样配置:"
echo "  1. AI 设置 → 我的服务商 → 添加服务商"
echo "  2. 名称:中继"
echo "  3. BaseURL:http://$IP:$PORT/v1"
echo "  4. API Key:你在上一步设置的中继 Key"
echo "  5. 模型:你在 config.json 里配置的任意模型名"
echo "  手机与这台设备需在同一 Wi-Fi"
echo "========================================"
  1. 1Dans Termux, exécutez : pkg update -y && pkg install -y nodejs (première fois uniquement)
  2. 2Exécutez bash install-termux.sh
  3. 3Suivez l'assistant : port du relais (8000 par défaut), clé du relais (16+ caractères aléatoires recommandé), puis BaseURL / clé API / liste de modèles de chaque fournisseur
  4. 4Au démarrage, le script affiche les réglages pour le téléphone — il n'y a plus qu'à les reporter dans l'app
💡
Saisir des commandes avec la télécommande de la TV est pénible : branchez un clavier USB, ou connectez-vous en SSH via le LAN. Après un redémarrage du téléphone, le relais ne démarre pas seul — relancez bash install-termux.sh (la configuration est sauvegardée et il démarrera directement).

Déploiement manuel (tout appareil · Node.js)

Pas envie du script en un clic, ou vous préférez un ordinateur / NAS ? Trois étapes manuelles. Les deux blocs de code ci-dessous sont le programme complet du relais et un exemple de configuration : copiez-les, sauvegardez-les et suivez les étapes.

  1. 1Installez Node.js 18+ (vérifiez avec node -v)
  2. 2Sauvegardez relay.mjs dans un répertoire quelconque ; copiez config.example.json en config.json et saisissez vos clés API
  3. 3Exécutez node relay.mjs config.json
  4. 4Le terminal affiche les infos de démarrage et l'adresse du relais — configurez le téléphone selon la section « Configurer dans l'app »
config.example.json (exemple de configuration)
{
  "port": 8000,
  "relayKey": "改成一段长随机字符串(手机 App 里要填它)",
  "timeoutSeconds": 300,
  "maxRetries": 2,
  "log": true,
  "upstreams": [
    {
      "name": "deepseek",
      "baseUrl": "https://api.deepseek.com",
      "apiKey": "sk-你的DeepSeek密钥",
      "models": [
        "deepseek-v4-flash",
        "deepseek-v4-pro"
      ]
    },
    {
      "name": "qwen",
      "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
      "apiKey": "sk-你的通义千问密钥",
      "models": [
        "qwen3.7-plus",
        "qwen3.7-max"
      ]
    },
    {
      "name": "kimi",
      "baseUrl": "https://api.moonshot.cn/v1",
      "apiKey": "sk-你的Kimi密钥",
      "models": [
        "kimi-k3",
        "kimi-k3-flash"
      ]
    },
    {
      "name": "glm",
      "baseUrl": "https://open.bigmodel.cn/api/paas/v4",
      "apiKey": "你的智谱GLM密钥",
      "models": [
        "glm-5.2",
        "glm-5.2-flash"
      ]
    }
  ]
}
relay.mjs (programme complet, à sauvegarder sous relay.mjs)
#!/usr/bin/env node
// ============================================================
// 宇宙核 AI 中继(自托管 · 零依赖 · Node 单文件)
// ============================================================
// 用途:把 DeepSeek / 通义 / Kimi 等各家 API Key 集中放在一台
//       闲置设备(旧手机 Termux / 电视盒子 / 旧电脑)上,
//       手机上的「宇宙核」App 只填一个中继地址 + 中继 Key,
//       即可使用所有上游模型——换模型不用再逐个改 Key。
//
// 功能:
//   POST /v1/chat/completions   按模型路由到上游(流式 / 非流式透传)
//   GET  /v1/models             聚合所有上游的模型列表
//   GET  /health                健康检查
//   鉴权:Authorization: Bearer <relayKey>
//   丝滑增强:上游 429/5xx 指数退避重试、请求超时、stdout 日志
//
// 用法:node relay.mjs [config.json]   默认读取同目录 config.json
// 依赖:仅 Node.js 内置模块(http/https),无需 npm install
// ============================================================
import { createServer } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { request as httpRequest } from 'node:http';
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { networkInterfaces } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = process.argv[2] || join(ROOT, 'config.json');

// ---------- 配置 ----------
function loadConfig() {
  let cfg;
  try {
    cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
  } catch (e) {
    console.error(`[中继] 无法读取配置 ${CONFIG_PATH}: ${e.message}`);
    console.error('[中继] 请先复制 config.example.json 为 config.json 并填入你的 API Key');
    process.exit(1);
  }
  if (!Array.isArray(cfg.upstreams) || cfg.upstreams.length === 0) {
    console.error('[中继] 配置中缺少 upstreams 列表(至少一个上游服务商)');
    process.exit(1);
  }
  if (!cfg.relayKey) {
    console.warn('[中继] ⚠️ 未设置 relayKey,中继将不校验任何请求(任何人可用你的上游额度!)');
    console.warn('[中继]    请在 config.json 中设置 relayKey 为一段长随机字符串');
  }
  return {
    port: cfg.port || 8000,
    host: cfg.host || '0.0.0.0', // 服务器部署可设 127.0.0.1 仅本机反代访问
    relayKey: cfg.relayKey || '',
    timeoutMs: (cfg.timeoutSeconds || 300) * 1000,
    maxRetries: cfg.maxRetries ?? 2, // 429/5xx 重试次数
    log: cfg.log !== false,
    upstreams: cfg.upstreams.map((u) => ({
      name: u.name || 'upstream',
      baseUrl: (u.baseUrl || '').replace(/\/+$/, ''),
      apiKey: u.apiKey || '',
      models: Array.isArray(u.models) ? u.models : [],
    })),
  };
}
const cfg = loadConfig();

const BACKOFF_MS = [500, 1000, 2000, 4000];
const log = (msg) => { if (cfg.log) console.log(msg); };

// ---------- 模型路由 ----------
function pickUpstream(model) {
  if (!model) return null;
  const m = model.toLowerCase();
  // 1. 精确匹配
  for (const u of cfg.upstreams) {
    if (u.models.some((x) => x.toLowerCase() === m)) return u;
  }
  // 2. 前缀匹配(deepseek-* → deepseek)
  for (const u of cfg.upstreams) {
    if (u.models.some((x) => m.startsWith(x.toLowerCase().replace('*', '')) && x.includes('*'))) return u;
  }
  // 3. 上游名前缀匹配(模型名以服务商名开头)
  for (const u of cfg.upstreams) {
    if (m.startsWith(u.name.toLowerCase() + '-') || m.startsWith(u.name.toLowerCase() + '/')) return u;
  }
  // 4. 兜底:单上游直接转发
  if (cfg.upstreams.length === 1) return cfg.upstreams[0];
  return null;
}

// ---------- 上游请求(带 429/5xx 退避重试) ----------
function upstreamRequest(upstream, path, headers, body) {
  return new Promise((resolve, reject) => {
    const url = new URL(upstream.baseUrl + path);
    const doReq = url.protocol === 'https:' ? httpsRequest : httpRequest;
    const req = doReq(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${upstream.apiKey}`,
        ...headers,
      },
    }, resolve);
    req.setTimeout(cfg.timeoutMs, () => {
      req.destroy(new Error('上游请求超时'));
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

async function sendWithRetry(upstream, path, headers, body) {
  let attempt = 0;
  for (;;) {
    const t0 = Date.now();
    const response = await upstreamRequest(upstream, path, headers, body);
    const code = response.statusCode || 0;
    if ((code === 429 || code >= 500) && attempt < cfg.maxRetries) {
      // 先排空响应体再重试(释放连接)
      response.resume();
      await new Promise((r) => response.on('end', r));
      const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
      log(`  ↻ [${upstream.name}] HTTP ${code} 繁忙,${wait}ms 后重试(${attempt + 1}/${cfg.maxRetries})`);
      await new Promise((r) => setTimeout(r, wait));
      attempt++;
      continue;
    }
    return { response, elapsedMs: Date.now() - t0, retries: attempt };
  }
}

// ---------- 处理 /v1/chat/completions ----------
/** 兼容旧版 App 思考参数:thinking:{enabled:true} → thinking:{type:'enabled'}
 *  DeepSeek 新接口要求 type 字段,旧格式会被 400 拒绝;返回是否发生了转换 */
function normalizeThinking(payload) {
  const t = payload && payload.thinking;
  if (t && typeof t === 'object' && t.type === undefined && typeof t.enabled === 'boolean') {
    payload.thinking = { ...t, type: t.enabled ? 'enabled' : 'disabled' };
    return true;
  }
  return false;
}

async function handleChat(req, res, authBody) {
  // 解析请求体
  const raw = await readBody(req);
  let payload;
  try {
    payload = JSON.parse(raw);
  } catch {
    return sendJson(res, 400, { error: { message: '请求体不是合法 JSON' } });
  }
  const model = typeof payload.model === 'string' ? payload.model : '';
  const upstream = pickUpstream(model);
  if (!upstream) {
    const available = cfg.upstreams.flatMap((u) => u.models).join('、') || '(未配置模型列表)';
    return sendJson(res, 400, {
      error: { message: `没有找到能处理模型 "${model}" 的上游。可用模型:${available}` },
    });
  }

  const isStream = payload.stream === true;
  // 兼容旧版 App:thinking 参数格式转换后重序列化再转发
  const bodyForUpstream = normalizeThinking(payload) ? JSON.stringify(payload) : raw;
  const t0 = Date.now();
  try {
    const { response, elapsedMs, retries } = await sendWithRetry(
      upstream, '/chat/completions',
      { Accept: 'text/event-stream' }, bodyForUpstream, // 原样透传 body(含 stream/model/messages)
    );
    const code = response.statusCode || 502;
    res.writeHead(code, {
      'Content-Type': response.headers['content-type'] || 'application/json',
      'Cache-Control': 'no-cache',
      'X-Relay-Upstream': upstream.name,
    });
    if (code === 200 && isStream) {
      // 流式透传:把上游 SSE 原样转发(含 keep-alive 空行)
      response.pipe(res);
      response.on('end', () => {
        log(`[${new Date().toLocaleTimeString()}] ${model} → ${upstream.name}  200 流式  ${Date.now() - t0}ms${retries ? `(重试${retries}次)` : ''}`);
        res.end();
      });
    } else {
      // 非流式 / 错误响应:读完再回
      const chunks = [];
      response.on('data', (c) => chunks.push(c));
      response.on('end', () => {
        const body = Buffer.concat(chunks);
        log(`[${new Date().toLocaleTimeString()}] ${model} → ${upstream.name}  ${code}  ${Date.now() - t0}ms${retries ? `(重试${retries}次)` : ''}`);
        res.end(body);
      });
      response.on('error', () => res.end());
    }
  } catch (e) {
    log(`[${new Date().toLocaleTimeString()}] ${model} → ${upstream.name}  失败: ${e.message}`);
    sendJson(res, 502, { error: { message: `中继转发失败:${e.message}` } });
  }
}

// ---------- 处理 /v1/models ----------
function handleModels(res) {
  const data = cfg.upstreams.flatMap((u) =>
    u.models.map((id) => ({
      id,
      object: 'model',
      owned_by: u.name,
      // 中继标记:宇宙核 App 列表里可识别
      relay: u.name,
    })),
  );
  sendJson(res, 200, { object: 'list', data });
}

// ---------- 工具 ----------
function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    req.on('error', reject);
  });
}

function sendJson(res, code, obj) {
  const body = JSON.stringify(obj);
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(body);
}

function checkAuth(req, res) {
  if (!cfg.relayKey) return true; // 未配置 Key = 开放(配置时警告过)
  const header = req.headers['authorization'] || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : '';
  if (token === cfg.relayKey) return true;
  sendJson(res, 401, { error: { message: '中继 Key 无效(Authorization: Bearer <你的中继Key>)' } });
  return false;
}

// ---------- 服务器 ----------
const server = createServer((req, res) => {
  const path = (req.url || '').split('?')[0];
  // 全量请求日志(含鉴权失败),便于远程排查 App 端问题
  log(`[请求] ${req.method} ${path} ← ${req.headers['x-forwarded-for'] || req.socket.remoteAddress || '?'}`);
  // 容错:BaseURL 少填 /v1 时自动兼容(/models 等同 /v1/models)
  const p = !path.startsWith('/v1') &&
    (path.startsWith('/models') || path.startsWith('/chat/completions') || path.startsWith('/tasks'))
    ? '/v1' + path
    : path;
  if (req.method === 'GET' && path === '/health') {
    return sendJson(res, 200, { status: 'ok', name: 'yuzhouhe-ai-relay', time: Date.now() });
  }
  if (!checkAuth(req, res)) return;
  if (req.method === 'GET' && p === '/v1/models') return handleModels(res);
  if (req.method === 'POST' && p === '/v1/chat/completions') {
    return handleChat(req, res).catch((e) => sendJson(res, 500, { error: { message: e.message } }));
  }
  // ============ 任务代理(中继 2.0):中继持有上游 SSE,App 轮询拿结果 ============
  // 解决手机 App 直接握 SSE 长连接、切后台被系统断流的问题(元宝式体验)
  if (req.method === 'POST' && p === '/v1/tasks') {
    return handleTaskCreate(req, res);
  }
  const taskMatch = p.match(/^\/v1\/tasks\/([A-Za-z0-9-]+)$/);
  if (req.method === 'GET' && taskMatch) {
    const task = tasks.get(taskMatch[1]);
    if (!task) return sendJson(res, 404, { error: { message: '任务不存在(中继重启后任务丢失)' } });
    return sendJson(res, 200, {
      taskId: task.id,
      status: task.status,
      reasoning: task.reasoning,
      content: task.content,
      error: task.error || null,
    });
  }
  if (req.method === 'GET' && path === '/') {
    return sendJson(res, 200, {
      name: 'yuzhouhe-ai-relay',
      version: '2.1.0',
      endpoints: [
        'GET /health',
        'GET /v1/models',
        'POST /v1/chat/completions',
        'POST /v1/tasks',
        'GET /v1/tasks/{id}',
      ],
      tip: '在宇宙核 App 的 AI 设置里,把 BaseURL 填为 http://<本机IP>:端口/v1,API Key 填为中继 Key',
    });
  }
  sendJson(res, 404, { error: { message: 'Not Found' } });
});

// ============ 任务代理实现 ============

/** 任务存储:内存 Map(中继重启丢失,README 有说明);上限防内存膨胀 */
const tasks = new Map();
const MAX_TASKS = Number(process.env.RELAY_MAX_TASKS || 100);

async function handleTaskCreate(req, res) {
  let payload;
  try {
    const raw = await readBody(req);
    payload = JSON.parse(raw);
  } catch {
    return sendJson(res, 400, { error: { message: '请求体不是合法 JSON' } });
  }
  const model = typeof payload.model === 'string' ? payload.model : '';
  const upstream = pickUpstream(model);
  if (!upstream) {
    return sendJson(res, 400, { error: { message: `没有找到能处理模型 "${model}" 的上游` } });
  }
  if (tasks.size >= MAX_TASKS) {
    return sendJson(res, 429, { error: { message: `任务数已达上限(${MAX_TASKS}),请稍后再试` } });
  }

  const task = {
    id: randomUUID(),
    status: 'running',   // running | done | error
    reasoning: '',
    content: '',
    error: null,
    model,
    upstream: upstream.name,
    createdAt: Date.now(),
    aborted: false,
  };
  tasks.set(task.id, task);
  log(`[${new Date().toLocaleTimeString()}] [任务] ${task.id} 创建 ${model} → ${upstream.name}`);

  // 后台执行:中继持有上游 SSE(App 切后台/断网不影响),增量存储,App 轮询拉取
  normalizeThinking(payload); // 兼容旧版 App 思考参数格式
  runTask(task, upstream, JSON.stringify(payload)).catch((e) => {
    task.status = 'error';
    task.error = e.message;
    log(`[任务] ${task.id} 失败: ${e.message}`);
  });

  sendJson(res, 202, { taskId: task.id, status: 'running' });
}

async function runTask(task, upstream, body) {
  const t0 = Date.now();
  const { response } = await sendWithRetry(upstream, '/chat/completions', {}, body);
  const code = response.statusCode || 502;
  if (code !== 200) {
    const err = await readBody(response);
    task.status = 'error';
    task.error = `上游错误 ${code}: ${err.slice(0, 200)}`;
    return;
  }
  // 解析 SSE(兼容非流式 JSON 响应)
  const ct = (response.headers['content-type'] || '').toLowerCase();
  if (!ct.includes('text/event-stream')) {
    const raw = await readBody(response);
    try {
      const j = JSON.parse(raw);
      task.content = j?.choices?.[0]?.message?.content ?? '';
      task.reasoning = j?.choices?.[0]?.message?.reasoning_content ?? '';
    } catch {
      task.error = '上游返回非 JSON';
      task.status = 'error';
      return;
    }
    task.status = 'done';
    log(`[任务] ${task.id} 完成(非流式) ${Date.now() - t0}ms`);
    return;
  }
  // 流式:逐行解析增量(含 reasoning_content / content / finish_reason)
  response.setEncoding('utf8');
  let buf = '';
  response.on('data', (c) => {
    buf += c;
    let idx;
    while ((idx = buf.indexOf('\n')) >= 0) {
      const line = buf.slice(0, idx).trim();
      buf = buf.slice(idx + 1);
      if (!line.startsWith('data: ')) continue; // 忽略空行 / : keep-alive
      const data = line.slice(6);
      if (data === '[DONE]') continue;
      try {
        const j = JSON.parse(data);
        const choice = j?.choices?.[0];
        if (!choice) continue;
        if (typeof choice.finish_reason === 'string') {
          // 记录结束原因(任务结束条件由 [DONE] 或流结束触发,此处仅记录)
          task.finishReason = choice.finish_reason;
        }
        const delta = choice.delta || {};
        if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
          task.reasoning += delta.reasoning_content;
        }
        if (typeof delta.content === 'string' && delta.content) {
          task.content += delta.content;
        }
      } catch { /* 非法行忽略 */ }
    }
  });
  response.on('end', () => {
    if (task.status !== 'error') {
      task.status = 'done';
      log(`[任务] ${task.id} 完成 ${Date.now() - t0}ms,${task.content.length} 字符`);
    }
  });
  response.on('error', (e) => {
    task.status = 'error';
    task.error = `上游流中断: ${e.message}`;
  });
}


server.listen(cfg.port, cfg.host, () => {
  const upstreams = cfg.upstreams.map((u) => `${u.name}(${u.models.length}模型)`).join(',');
  console.log('========================================');
  console.log('  宇宙核 AI 中继已启动');
  console.log(`  地址: http://${cfg.host || '0.0.0.0'}:${cfg.port}`);
  console.log(`  上游: ${upstreams}`);
  console.log(`  中继Key: ${cfg.relayKey ? cfg.relayKey.slice(0, 4) + '****' : '(未设置!开放模式)'}`);
  console.log('  手机 App 配置: BaseURL = http://<本机IP>:' + cfg.port + '/v1');
  console.log('  局域网 IP 查看: 同网段手机浏览器访问 http://' + lanIp() + ':' + cfg.port + '/health');
  console.log('========================================');
});

// 尽力探测局域网 IP(打印提示用)
function lanIp() {
  try {
    for (const list of Object.values(networkInterfaces())) {
      for (const it of list || []) {
        if (it.family === 'IPv4' && !it.internal && it.address.startsWith('192.168.')) return it.address;
      }
    }
  } catch {}
  return '<本机IP>';
}

Configurer dans l'app CosmosBox

  1. 1Ouvrez l'app → Réglages → Réglages IA
  2. 2« Mes fournisseurs » → Ajouter un fournisseur
  3. 3Nom : relais (au choix) ; BaseURL : http://<IP-de-l'appareil-relais>:<port>/v1 ; Clé API : votre clé de relais ; Modèle : n'importe quel nom de modèle de config.json (ex. deepseek-v4-flash)
  4. 4Retournez dans chat IA / programmation IA / génération de plugins IA et choisissez le fournisseur ajouté — les trois outils partagent la même configuration
⚠️
Le téléphone et l'appareil relais doivent être sur le même Wi-Fi (même LAN). L'IP de l'appareil relais figure dans le journal de démarrage du relais, ou dans les réglages réseau de l'appareil.

L'utiliser hors de chez soi (accès distant)

Par défaut le relais ne fonctionne que sur votre Wi-Fi domestique. Pour l'utiliser dehors, il faut un tunnel chiffré reliant votre relais à Internet. Deux options — rien n'est intégré à l'app ; vous choisissez :

Option 1 : frp + votre propre serveur (recommandée, stable en Chine)

Utilisez votre propre serveur cloud (panel BT / Linux, 2 cœurs 2 Go suffisent) comme tunnel : le dispositif relais se connecte en sortie au serveur, et votre téléphone accède à https://votre-domaine/v1. Chiffré de bout en bout ; le serveur ne stocke aucune clé — même piraté, les attaquants n'obtiennent rien.

  1. 1Obtenez un serveur cloud (2 cœurs 2 Go), installez le panel BT et Nginx (ignorez si déjà installé)
  2. 2Faites pointer un domaine (ex. relay.example.com) vers l'IP du serveur
  3. 3Téléversez server-frps-guide.sh sur le serveur et exécutez en root : bash server-frps-guide.sh relay.example.com — le script fait automatiquement : télécharger frp, générer un Token aléatoire, autostart systemd, proxy inverse Nginx, certificat HTTPS, limitation de débit
  4. 4Ouvrez TCP 7000 dans le groupe de sécurité cloud (port de connexion des relais)
  5. 5Notez le Token affiché par le script (nécessaire pour configurer le dispositif relais)
  6. 6💡 Vous pouvez donner cette page à n'importe quelle IA, elle déploiera tout pour vous en suivant le script (chaque ligne est commentée)
server-frps-guide.sh (déploiement serveur en un clic, à donner à n'importe quelle IA)
#!/bin/bash
# ============================================================
# 宇宙核 AI 中继 · 服务器端 frps 部署脚本(AI 可读可执行版)
# ============================================================
# 【这是什么】
#   在用户的云服务器(宝塔/Linux)上部署 frp 服务端(frps),
#   为中继设备(旧手机/盒子)提供外网隧道入口。
#
# 【给 AI 的说明】
#   把本脚本和官网教程一起给 AI,AI 可以:
#   1. 在服务器上执行本脚本(需要 root)
#   2. 按脚本输出的提示帮用户完成:安全组放行、DNS 解析
#   3. 然后用 install-frpc.sh 在用户中继设备上建立隧道
#
# 【前置条件】
#   - root 权限
#   - 一个已解析到本服务器的域名(默认 relay.yuzhouhe.com,可改)
#   - 出网可访问 GitHub(下载 frp 二进制)
#
# 【安全性设计】(防拖库/防滥用)
#   - frps 无状态纯转发:不存任何 Key 和用户数据,被攻破也拿不到东西
#   - frpc<->frps 全程 TLS 加密(transport.tls.force)
#   - token 鉴权(随机生成,拒绝未授权隧道)
#   - 管理面板只监听 127.0.0.1(杜绝公网爆破)
#   - nginx 反代层:https + 限速(10r/s)+ 请求体 1MB 上限
# ============================================================
set -e

DOMAIN="${1:-relay.yuzhouhe.com}"      # 访问域名(需已解析到本服务器)
EMAIL="${2:-admin@${DOMAIN#relay.}}"   # 证书邮箱(可改)
FRP_VER="0.70.1"

echo "========================================"
echo "  宇宙核 AI 中继 · frps 部署"
echo "  域名: $DOMAIN"
echo "========================================"

# ---------- 1. 检测系统 ----------
if [ "$(id -u)" != "0" ]; then echo "❌ 需要 root 权限(sudo -i 后重试)"; exit 1; fi
if ! command -v nginx >/dev/null 2>&1; then
  echo "⚠️ 未检测到 nginx(宝塔环境自带)。请先在宝塔面板安装 Nginx 再运行本脚本。"
  exit 1
fi
ARCH=$(uname -m)
case "$ARCH" in
  x86_64|amd64) FRP_ARCH="amd64" ;;
  aarch64|arm64) FRP_ARCH="arm64" ;;
  *) echo "❌ 不支持的架构: $ARCH"; exit 1 ;;
esac

# ---------- 2. 下载并安装 frps ----------
echo "[1/4] 下载 frp v${FRP_VER} (${FRP_ARCH}) ..."
mkdir -p /opt/frp
cd /tmp
curl -fsSL -o frp.tar.gz "https://github.com/fatedier/frp/releases/download/v${FRP_VER}/frp_${FRP_VER}_linux_${FRP_ARCH}.tar.gz"
tar -xzf frp.tar.gz
cp "frp_${FRP_VER}_linux_${FRP_ARCH}/frps" /opt/frp/frps
chmod +x /opt/frp/frps
rm -rf "frp_${FRP_VER}_linux_${FRP_ARCH}" frp.tar.gz

# ---------- 3. 配置(token 自动随机生成) ----------
echo "[2/4] 生成配置(token 随机)..."
TOKEN=$(openssl rand -hex 32)
DASH_PASS=$(openssl rand -hex 12)
cat > /opt/frp/frps.toml <<EOF
# 宇宙核 AI 中继 frps 配置
bindPort = 7000              # 中继设备(frpc)出站连入端口
vhostHTTPPort = 7080         # HTTP vhost 端口(本机 nginx 反代目标)
auth.method = "token"
auth.token = "$TOKEN"
webServer.addr = "127.0.0.1" # 管理面板仅本机
webServer.port = 7500
webServer.user = "yzh"
webServer.password = "$DASH_PASS"
transport.tls.force = true   # frpc<->frps 全程 TLS
log.to = "/opt/frp/frps.log"
log.level = "info"
log.maxDays = 7
EOF

# ---------- 4. systemd 自启 ----------
echo "[3/4] 注册 systemd 服务..."
cat > /etc/systemd/system/frps.service <<EOF
[Unit]
Description=YuzhouHe frps (AI relay tunnel)
After=network.target

[Service]
ExecStart=/opt/frp/frps -c /opt/frp/frps.toml
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable frps >/dev/null 2>&1
systemctl start frps
sleep 1
systemctl is-active frps >/dev/null && echo "  frps 运行中 ✓" || { echo "  ❌ frps 启动失败,查看 journalctl -u frps"; exit 1; }

# ---------- 5. nginx 反代 + HTTPS ----------
echo "[4/4] 配置 nginx 反代 + HTTPS ..."
# 找到 nginx 配置目录(宝塔路径优先)
if [ -d /www/server/panel/vhost/nginx ]; then
  VHOST_DIR="/www/server/panel/vhost/nginx"
elif [ -d /etc/nginx/conf.d ]; then
  VHOST_DIR="/etc/nginx/conf.d"
else
  VHOST_DIR="/etc/nginx"
fi

# 限速 zone(防刷)
if ! grep -q 'zone=relay' /www/server/nginx/conf/nginx.conf 2>/dev/null && [ -f /www/server/nginx/conf/nginx.conf ]; then
  sed -i 's|^http {|http {\n    limit_req_zone $binary_remote_addr zone=relay:10m rate=10r/s;|' /www/server/nginx/conf/nginx.conf
fi

# acme.sh 证书(无则安装)
if [ ! -f /root/.acme.sh/acme.sh ]; then
  curl -s https://get.acme.sh | sh -s email="$EMAIL"
fi
mkdir -p /www/wwwroot/relay-ssl
/root/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot /www/wwwroot/relay-ssl --keylength ec-256 --force 2>/dev/null || true

cat > "$VHOST_DIR/$DOMAIN.conf" <<EOF
# 宇宙核 AI 中继反代(自动生成)
server {
    listen 80;
    server_name $DOMAIN;
    location ^~ /.well-known/acme-challenge/ { root /www/wwwroot/relay-ssl; }
    location / { return 301 https://\$host\$request_uri; }
}
server {
    listen 443 ssl;
    server_name $DOMAIN;
    ssl_certificate /root/.acme.sh/${DOMAIN}_ecc/fullchain.cer;
    ssl_certificate_key /root/.acme.sh/${DOMAIN}_ecc/${DOMAIN}.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    location / {
        proxy_pass http://127.0.0.1:7080;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_buffering off;               # SSE 流式必须关缓冲
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
        client_max_body_size 1m;
        limit_req zone=relay burst=20 nodelay;
    }
}
EOF

NGINX_BIN=$(command -v nginx || echo /www/server/nginx/sbin/nginx)
$NGINX_BIN -t && $NGINX_BIN -s reload

echo ""
echo "========================================"
echo "✅ frps 部署完成"
echo "========================================"
echo "  域名:   https://$DOMAIN"
echo "  frp 端口: 7000(需在云安全组/防火墙放行 TCP 7000)"
echo "  Token:  $TOKEN"
echo "  面板:   http://127.0.0.1:7500 (仅本机) 用户 yzh 密码 $DASH_PASS"
echo ""
echo "【接下来(需要用户/协作 AI 完成)】"
echo "  1. 云控制台安全组放行 TCP 7000(frpc 连入)"
echo "  2. DNS 解析 $DOMAIN → 本服务器 IP(若未解析)"
echo "  3. 在用户中继设备(旧手机 Termux)运行 install-frpc.sh"
echo "     - 服务器地址/Token 填上面的值"
echo "     - 访问域名填 $DOMAIN"
echo "  4. 完成后面向公网的地址是 https://$DOMAIN/v1"
echo "========================================"
  1. 1Sur le dispositif relais (vieux téléphone Termux), exécutez : bash install-frpc.sh
  2. 2Suivez l'assistant : adresse du serveur, port 7000, Token, domaine d'accès, port local du relais (8000 par défaut)
  3. 3Une fois le tunnel actif, mettez la BaseURL de l'app sur https://votre-domaine/v1 — utilisez-la dehors ; à la maison l'adresse LAN fonctionne aussi, les deux coexistent
install-frpc.sh (configuration du tunnel sur le dispositif relais)
#!/data/data/com.termux/files/usr/bin/bash
# ============================================================
# 宇宙核 AI 中继 · frpc 外网通道一键配置(Termux)
# 作用:中继设备(旧手机/盒子)通过 frp 隧道连到你自己的服务器,
#       出门在外用手机访问 http://relay.yuzhouhe.com 即可连上家中继。
# 前置:1) 服务器已部署 frps(见官网教程「服务器端部署」)
#       2) 已在服务器安全组放行 7000 端口
# 用法:bash install-frpc.sh
# ============================================================
set -e

echo "========================================"
echo "  宇宙核 AI 中继 · 外网通道(frpc)"
echo "========================================"

# ---------- 1. 收集参数 ----------
read -rp "frps 服务器地址(IP 或域名,如 47.82.104.224): " SERVER
[ -z "$SERVER" ] && echo "服务器地址不能为空" && exit 1
read -rp "frps 端口(默认 7000): " PORT
PORT="${PORT:-7000}"
read -rp "frps Token(部署 frps 时生成,在服务器 /opt/frp/frps.toml 里): " TOKEN
[ -z "$TOKEN" ] && echo "Token 不能为空" && exit 1
read -rp "访问域名(如 relay.yuzhouhe.com,需已解析到服务器): " DOMAIN
[ -z "$DOMAIN" ] && echo "域名不能为空" && exit 1
read -rp "中继本地端口(默认 8000,与 config.json 的 port 一致): " LOCAL_PORT
LOCAL_PORT="${LOCAL_PORT:-8000}"

# ---------- 2. 安装 frpc ----------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FRPC="$SCRIPT_DIR/frpc"
if [ ! -f "$FRPC" ]; then
  echo ""
  echo "[1/3] 下载 frpc(Termux arm64)..."
  ARCH=$(uname -m)
  case "$ARCH" in
    aarch64|arm64) FRP_ARCH="arm64" ;;
    *) echo "⚠️ 不支持的架构: $ARCH(本脚本仅支持 arm64)"; exit 1 ;;
  esac
  # 官网教程提供下载地址模板;此处尝试官方 GitHub(失败可手动下载放同目录)
  FRP_VER="0.70.1"
  curl -fsSL -o "$SCRIPT_DIR/frp.tar.gz" \
    "https://github.com/fatedier/frp/releases/download/v${FRP_VER}/frp_${FRP_VER}_linux_${FRP_ARCH}.tar.gz" \
    || { echo "⚠️ 自动下载失败:请从 frp 官方 GitHub 下载 frp_${FRP_VER}_linux_${FRP_ARCH}.tar.gz,"
         echo "   解压后把 frpc 文件放到本目录再运行本脚本"; exit 1; }
  tar -xzf "$SCRIPT_DIR/frp.tar.gz"
  cp "frp_${FRP_VER}_linux_${FRP_ARCH}/frpc" "$FRPC"
  chmod +x "$FRPC"
  rm -rf "$SCRIPT_DIR/frp.tar.gz" "frp_${FRP_VER}_linux_${FRP_ARCH}"
  echo "  frpc 已安装"
else
  echo "[1/3] frpc 已存在"
fi

# ---------- 3. 生成 frpc 配置 ----------
echo ""
echo "[2/3] 生成 frpc.toml ..."
cat > "$SCRIPT_DIR/frpc.toml" <<EOF
# 宇宙核 AI 中继 frpc 配置(由 install-frpc.sh 生成)
serverAddr = "$SERVER"
serverPort = $PORT
auth.token = "$TOKEN"

[[proxies]]
name = "relay"
type = "http"
localIP = "127.0.0.1"
localPort = $LOCAL_PORT
customDomains = ["$DOMAIN"]
EOF

# ---------- 4. 启动 ----------
echo ""
echo "[3/3] 启动 frpc ..."
termux-wake-lock 2>/dev/null || true
nohup "$FRPC" -c "$SCRIPT_DIR/frpc.toml" > "$SCRIPT_DIR/frpc.log" 2>&1 &
sleep 2
if pgrep -f "frpc -c" >/dev/null 2>&1; then
  echo ""
  echo "✅ frpc 已启动!日志: $SCRIPT_DIR/frpc.log"
  echo "   出门在外时,中继地址填: http://$DOMAIN/v1"
else
  echo "❌ frpc 启动失败,请查看 $SCRIPT_DIR/frpc.log"
  exit 1
fi

echo ""
echo "========================================"
echo "  在「宇宙核」App 里这样配置:"
echo "  AI 设置 → 服务商 → 中继"
echo "  BaseURL:http://$DOMAIN/v1"
echo "  API Key:你的中继 Key(config.json 里的 relayKey)"
echo "  局域网内用 http://<中继IP>:8000/v1 也可以,两者并存"
echo "========================================"

Option 2 : Tailscale (zéro configuration serveur, alternative)

  1. 1Installez Tailscale sur le dispositif relais (app Android ou Termux) et sur le téléphone, connectez-vous avec le même compte
  2. 2Quand les deux extrémités rejoignent le même réseau virtuel, mettez la BaseURL de l'app sur http://IP-Tailscale-du-relais:8000/v1
  3. 3Chiffrement WireGuard complet et accessible depuis n'importe quel réseau ; inconvénient : Tailscale est un service étranger, parfois instable sur les réseaux chinois

Politique de sécurité (pourquoi ne pas craindre les attaquants)

  • 🔒frps est un pur transmetteur sans état : il ne stocke ni clés API ni données utilisateur — même un serveur piraté ne livre rien
  • 🔒frpc ↔ frps est chiffré TLS de bout en bout ; vos requêtes ne peuvent pas être espionnées sur Internet
  • 🔒Authentification par Token : les appareils non autorisés ne peuvent pas créer de tunnel
  • 🔒Le panneau d'administration frps n'écoute que sur localhost, jamais exposé
  • 🔒La couche proxy Nginx a une limitation de débit (10 requêtes/s) et un plafond de taille de requête contre les abus
  • 🔒Vos clés API des fournisseurs restent toujours uniquement sur le dispositif relais (config.json) ; l'app ne détient que la clé du relais

Questions fréquentes

Puis-je utiliser le relais hors de chez moi ?

Oui. Utilisez frp + votre propre serveur (recommandé, voir la section « L'utiliser hors de chez soi ») ou Tailscale. Une fois le tunnel actif, mettez la BaseURL de l'app sur l'adresse publique.

Le serveur du relais peut-il être attaqué ? Mes clés fuiront-elles ?

frps est un pur transmetteur sans stockage de clés ; le tunnel est entièrement chiffré TLS avec authentification par token ; le panneau n'est jamais exposé ; la couche Nginx limite le débit. Même si le serveur est piraté, les attaquants n'obtiennent aucune de vos clés API.

Le relais consomme-t-il beaucoup de ressources ?

Non. Il ne fait que transférer des requêtes et n'exécute jamais de modèles — la mémoire utilisée est généralement de quelques dizaines de Mo, rien pour un vieux téléphone ; au repos, presque aucune batterie.

Le relais fonctionne-t-il après un redémarrage du téléphone ?

Le relais dans Termux ne démarre pas automatiquement au démarrage. Après un redémarrage, relancez bash install-termux.sh — le script détecte la configuration existante et démarre directement.

Puis-je l'utiliser hors du même Wi-Fi ?

Oui, mais nous déconseillons d'exposer directement le port sur Internet. Utilisez Tailscale pour mettre le téléphone et l'appareil relais dans le même réseau virtuel — accessible depuis n'importe quel réseau et entièrement chiffré.

Le relais est-il sécurisé ? Mes clés peuvent-elles fuiter ?

Les clés sont stockées uniquement sur votre appareil relais ; le téléphone détient la clé du relais plutôt que les clés des fournisseurs. Changez absolument relayKey dans config.json en une longue chaîne aléatoire et désactivez la redirection de port du port du relais sur votre routeur.

Quels modèles et fournisseurs sont pris en charge ?

Tout fournisseur compatible OpenAI : DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI, Gemini (endpoint compatible OpenAI), Claude (via OpenRouter), etc. Ajoutez-les un par un dans la liste upstreams de config.json ; le routage prend en charge la correspondance exacte et par préfixe.

Comment migrer en changeant d'appareil ?

Copiez config.json (il contient vos clés de fournisseurs) du répertoire du relais vers le nouvel appareil, puis exécutez relay.mjs ou le script en un clic. Sur le téléphone, changez simplement l'IP dans la BaseURL vers celle du nouvel appareil.

MODE MATRIX : ACTIF — recommencez ou ÉCHAP pour quitter