// AI RELAY

Self-Hosted AI Relay

Put all your API keys on one idle device and fill in just one address on your phone — AI chat, AI coding and AI plugin generation, all smoothly connected.

Why do you need a relay?

CosmosBox's AI tools (AI chat, AI coding, AI plugin generation) use BYOK — you bring your own API keys for each LLM provider. Connecting to multiple providers directly means one key and one BaseURL per provider, plus switching configurations every time you change models.

A self-hosted relay folds all of that into a single address: configure each provider key once on the relay device, and on your phone you only fill in one relay address + one relay key. Model names are routed to the right upstream automatically. The relay also retries with exponential backoff when a provider is busy, making weak-network experience more stable.

Direct multi-provider vs. relay

AspectDirect multi-providerWith relay
App setupOne key and one address per providerJust one address + one key
Switching modelsManually switch provider configsModel name routed to the right upstream
Busy provider (429)Fails immediately, retry manuallyAutomatic exponential backoff retry
Key managementScattered across devicesStored only on the relay device

How it works

The relay is a very lightweight OpenAI-compatible forwarding service that runs on your own device. Your request goes to the relay first; the relay finds the matching provider by model name, forwards it, and streams the result back to your phone. The whole service is a single zero-dependency Node.js file — no third-party packages, no database.

  1. 1The CosmosBox app on your phone sends a request → relay address
  2. 2The relay matches the model name to an upstream (e.g. deepseek-v4-flash → DeepSeek)
  3. 3The upstream generates content and streams it word by word back to the relay
  4. 4The relay forwards it untouched to your phone, displayed in real time

Preparation

  • One idle device: an old Android phone, an Android TV box or an old computer all work (it does not run models, resource usage is minimal)
  • Old phone / TV box: install Termux (TV boxes need a sideloaded APK); old computer: install Node.js 18 or newer
  • At least one LLM API key: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI… any OpenAI-compatible provider works

One-click deploy (old phone / TV box · Termux)

Copy the install-termux.sh script below completely (or get it from the AI relay project), then run it in Termux: the script installs Node.js, generates the config with an interactive wizard (enter each provider's key one by one), starts the relay and prints the settings for your phone.

install-termux.sh (one-click script)
#!/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. 1In Termux run: pkg update -y && pkg install -y nodejs (first time only)
  2. 2Run bash install-termux.sh
  3. 3Follow the wizard: relay port (default 8000), relay key (16+ random characters recommended), then each provider's BaseURL / API key / model list
  4. 4When it starts successfully, the script prints the phone-side settings — just fill them into the app
💡
Typing commands with a TV remote is painful: plug in a USB keyboard, or SSH into the box over your LAN. The relay does not auto-start after a phone reboot — just run bash install-termux.sh again (your config is saved and it will start directly).

Manual deploy (any device · Node.js)

Don't want the one-click script, or want to run it on a computer / NAS? Three steps, manually. The two code blocks below are the complete relay program and a config example — copy and save them, then follow the steps.

  1. 1Install Node.js 18+ (confirm with node -v)
  2. 2Save relay.mjs into any directory; copy config.example.json to config.json and fill in your API keys
  3. 3Run node relay.mjs config.json
  4. 4The terminal prints the startup info and relay address — configure your phone following the "Configure in the app" section
config.example.json (config example)
{
  "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 (complete program, save as 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>';
}

Configure in the CosmosBox app

  1. 1Open the app → Settings → AI Settings
  2. 2"My Providers" → Add provider
  3. 3Name: relay (anything); BaseURL: http://<relay-device-IP>:<port>/v1; API key: your relay key; Model: any model name from config.json (e.g. deepseek-v4-flash)
  4. 4Go back to AI chat / AI coding / AI plugin generation and pick the provider you just added — all three tools share the same config
⚠️
Your phone and the relay device must be on the same Wi-Fi (same LAN). The relay device IP is printed in the relay startup log, or check the device's network settings.

Using it away from home (remote access)

By default the relay only works on your home Wi-Fi. To use it when you're out, you need an encrypted tunnel connecting your home relay to the public internet. Two options — nothing is built into the app; you choose:

Option 1: frp + your own server (recommended, stable in China)

Use your own cloud server (BT panel / Linux, 2 cores 2 GB is plenty) as a tunnel: the relay device connects outbound to the server, and your phone accesses https://your-domain/v1. Fully encrypted end to end; the server stores no keys — even if it's breached, attackers get nothing.

  1. 1Get a cloud server (2 cores 2 GB or more), install the BT panel and Nginx (skip if already installed)
  2. 2Point a domain (e.g. relay.example.com) to the server IP
  3. 3Upload server-frps-guide.sh to the server and run as root: bash server-frps-guide.sh relay.example.com — the script automatically: downloads frp, generates a random Token, sets up systemd autostart, Nginx reverse proxy, HTTPS certificate, rate limiting
  4. 4Open TCP 7000 in the cloud security group (the port relay devices connect to)
  5. 5Note down the Token printed by the script (needed for the relay device config)
  6. 6💡 You can hand this page to any AI and it can deploy everything for you following the script (every line is commented)
server-frps-guide.sh (one-click server deployment, can be handed to any AI)
#!/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. 1On the relay device (old phone Termux) run: bash install-frpc.sh
  2. 2Follow the wizard: server address, port 7000, Token, access domain, relay local port (default 8000)
  3. 3Once the tunnel is up, set the app's BaseURL to https://your-domain/v1 — use it when out; at home the LAN address also works, both can coexist
install-frpc.sh (tunnel config on the relay device)
#!/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 (zero server config, alternative)

  1. 1Install Tailscale on the relay device (Android app or Termux) and on your phone, sign in with the same account
  2. 2Once both ends join the same virtual network, set the app's BaseURL to http://relay-device-Tailscale-IP:8000/v1
  3. 3Fully WireGuard-encrypted and reachable from any network; downside: Tailscale is a foreign service and can be unstable on Chinese networks

Security policy (why you don't need to fear attackers)

  • 🔒frps is a stateless pure forwarder: it stores no API keys and no user data — even a breached server yields nothing
  • 🔒frpc ↔ frps is TLS-encrypted end to end; your requests can't be sniffed on the public internet
  • 🔒Token authentication: unauthorized devices cannot establish a tunnel
  • 🔒The frps admin panel listens on localhost only, never exposed to the public
  • 🔒The Nginx proxy layer has rate limiting (10 req/s) and a request-size cap to prevent abuse
  • 🔒Your provider API keys always stay on the relay device only (config.json); the app only holds the relay key

FAQ

Can I use the relay when away from home?

Yes. Use frp + your own server (recommended, see the 'Using it away from home' section), or Tailscale. Once the tunnel is up, set the app's BaseURL to the public address.

Can the relay server be attacked? Will my keys leak?

frps is a pure forwarder that stores no keys; the tunnel is fully TLS-encrypted with token auth; the admin panel is never exposed; and the Nginx layer rate-limits. Even if the server is breached, attackers get none of your API keys.

Does the relay consume many resources?

No. It only forwards requests and never runs models — memory usage is usually just a few dozen MB, which is nothing for an old phone; when idle it uses almost no battery.

Will the relay still work after the phone reboots?

The relay in Termux does not auto-start on boot. After a reboot, run bash install-termux.sh again — the script detects the existing config and starts directly.

Can I use it outside the same Wi-Fi?

Yes, but we don't recommend exposing the port directly to the public internet. Use Tailscale to put your phone and the relay device into the same virtual network — you can reach it from any network, fully encrypted.

Is the relay secure? Will my keys leak?

Keys are stored only on your own relay device; the phone holds the relay key instead of each provider key. Make sure to change relayKey in config.json to a long random string, and disable port forwarding for the relay port on your router.

Which models and providers are supported?

Every OpenAI-compatible provider: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI, Gemini (OpenAI-compatible endpoint), Claude (via OpenRouter), and more. Add them one by one in the upstreams list of config.json; model routing supports both exact-match and prefix-match.

How do I migrate when changing devices?

Copy config.json (it contains your provider keys) from the relay directory to the new device, then run relay.mjs or the one-click script. On the phone, just change the IP in BaseURL to the new device's IP.

MATRIX MODE: ON — enter again or press ESC to exit