// AI RELAY

자체 AI 릴레이

각사 API 키를 유휴 기기 한 대에 모아두고 휴대폰에는 주소 하나만 입력하면 — AI 채팅·AI 코딩·AI 플러그인 생성이 모두 매끄럽게 연결됩니다.

릴레이가 필요한 이유

CosmosBox의 AI 도구(AI 채팅, AI 코딩, AI 플러그인 생성)는 BYOK 방식입니다 — 각 LLM 제공업체의 API 키를 직접 준비하죠. 여러 제공업체에 직접 연결하면 제공업체마다 키와 BaseURL이 필요하고, 모델을 바꿀 때마다 설정을 전환해야 합니다.

자체 릴레이는 이를 하나의 주소로 모아줍니다: 각 제공업체 키는 릴레이 기기에서 한 번만 설정하고, 휴대폰에는 릴레이 주소 하나 + 릴레이 키 하나만 입력하면 됩니다. 모델 이름은 자동으로 해당 제공업체로 라우팅됩니다. 또 제공업체가 혼잡할 때 지수 백오프로 자동 재시도하여 약한 네트워크에서도 더 안정적입니다.

여러 제공업체 직접 연결 vs 릴레이

항목직접 연결릴레이 사용
앱 설정제공업체마다 키와 주소 하나씩주소 하나 + 키 하나만
모델 변경설정을 수동으로 전환모델 이름으로 자동 라우팅
혼잡 시(429)즉시 실패, 수동 재시도지수 백오프 자동 재시도
키 관리여러 기기에 분산릴레이 기기에만 저장

작동 방식

릴레이는 내 기기에서 실행되는 초경량 OpenAI 호환 전달 서비스입니다. 요청은 먼저 릴레이로 가고, 릴레이가 모델 이름으로 제공업체를 찾아 전달한 뒤 결과를 스트리밍으로 휴대폰에 돌려줍니다. 서비스 전체는 의존성이 없는 Node.js 단일 파일 — 서드파티 패키지도 데이터베이스도 필요 없습니다.

  1. 1휴대폰의 CosmosBox 앱이 요청 전송 → 릴레이 주소
  2. 2릴레이가 모델 이름을 제공업체에 매칭(예: deepseek-v4-flash → DeepSeek)
  3. 3제공업체가 내용을 생성하고 한 글자씩 스트리밍으로 반환
  4. 4릴레이가 그대로 전달하고 앱이 실시간 표시

준비물

  • 유휴 기기 1대: 오래된 Android 휴대폰·Android TV 박스·구형 PC 모두 가능(모델을 실행하지 않아 리소스 소모가 극히 적음)
  • 오래된 휴대폰 / TV 박스: Termux 설치(TV 박스는 APK 사이드로드 필요); 구형 PC: Node.js 18 이상 설치
  • LLM API 키 1개 이상: DeepSeek·Qwen·Kimi·Zhipu GLM·OpenAI… OpenAI 호환 프로토콜이면 모두 가능

원클릭 구축(오래된 휴대폰 / TV 박스 · Termux)

아래 install-termux.sh를 통째로 복사해(또는 AI 릴레이 프로젝트에서 받아) Termux에서 실행하면 됩니다: 스크립트가 Node.js를 설치하고, 대화형 마법사로 설정을 생성(각 제공업체 키를 하나씩 입력)하며, 릴레이를 시작하고 휴대폰용 설정 정보를 출력합니다.

install-termux.sh(원클릭 스크립트)
#!/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. 1Termux에서 실행: pkg update -y && pkg install -y nodejs(첫 번째만)
  2. 2bash install-termux.sh 실행
  3. 3마법사에 따라 입력: 릴레이 포트(기본 8000), 릴레이 키(16자 이상 랜덤 권장), 각 제공업체의 BaseURL / API 키 / 모델 목록
  4. 4시작 성공 후 스크립트가 휴대폰용 설정을 출력 — 그대로 앱에 입력
💡
TV 리모컨으로 명령 입력은 힘듭니다: USB 키보드를 연결하거나 LAN에서 SSH로 접속하세요. 휴대폰을 재부팅하면 릴레이가 자동 시작되지 않습니다 — bash install-termux.sh를 다시 실행하면 설정이 저장되어 있어 바로 시작됩니다.

수동 구축(모든 기기 · Node.js)

원클릭 스크립트가 싫거나 PC / NAS에서 돌리고 싶다면 수동 3단계입니다. 아래 두 코드 블록이 릴레이 프로그램 본체와 설정 예시입니다. 복사해 저장하고 단계를 따르세요.

  1. 1Node.js 18+ 설치(node -v로 확인)
  2. 2relay.mjs를 아무 디렉터리에 저장; config.example.json을 config.json으로 복사해 각 API 키 입력
  3. 3node relay.mjs config.json 실행
  4. 4터미널에 시작 정보와 릴레이 주소가 표시됩니다 — '앱에서 설정' 항목에 따라 휴대폰을 구성
config.example.json(설정 예시)
{
  "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(프로그램 본체, 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>';
}

CosmosBox 앱에서 설정

  1. 1앱 열기 → 설정 → AI 설정
  2. 2'내 제공업체' → 제공업체 추가
  3. 3이름: 릴레이(아무거나); BaseURL: http://<릴레이 기기 IP>:<포트>/v1; API 키: 설정한 릴레이 키; 모델: config.json에 설정한 아무 모델 이름(예: deepseek-v4-flash)
  4. 4AI 채팅 / AI 코딩 / AI 플러그인 생성으로 돌아가 방금 추가한 제공업체 선택 — 세 도구가 같은 설정을 공유
⚠️
휴대폰과 릴레이 기기는 같은 Wi-Fi(같은 LAN)에 있어야 합니다. 릴레이 기기 IP는 릴레이 시작 로그나 기기 네트워크 설정에서 확인할 수 있습니다.

밖에서 사용하기 (원격 접속)

중계는 기본적으로 집 Wi-Fi 안에서만 작동합니다. 밖에서 쓰려면 집 중계를 인터넷에 연결하는 암호화 터널이 필요합니다. 두 가지 방법이 있습니다 — 앱에 내장된 것은 없으며, 직접 선택하세요:

방법 1: frp + 내 서버 (추천·국내 안정)

내 클라우드 서버(BT 패널 / Linux, 2코어 2GB면 충분)를 터널로 사용합니다: 중계 기기는 서버로 나가는 연결을 만들고, 휴대폰은 https://도메인/v1에 접속합니다. 종단 간 암호화되며 서버는 키를 전혀 저장하지 않습니다 — 서버가 뚫려도 공격자는 아무것도 얻지 못합니다.

  1. 1클라우드 서버(2코어 2GB 이상)를 준비하고 BT 패널과 Nginx를 설치(이미 있으면 건너뜀)
  2. 2도메인(예: relay.example.com)을 서버 IP로 연결
  3. 3server-frps-guide.sh를 서버에 올리고 root로 실행: bash server-frps-guide.sh relay.example.com — 스크립트가 자동으로: frp 다운로드·랜덤 Token 생성·systemd 자동 시작·Nginx 리버스 프록시·HTTPS 인증서·속도 제한을 처리합니다
  4. 4클라우드 보안 그룹에서 TCP 7000을 개방(중계 기기 접속 포트)
  5. 5스크립트가 출력하는 Token을 기록(중계 기기 설정에 필요)
  6. 6💡 이 페이지를 아무 AI에게나 주면 스크립트를 따라 전부 구축해 줍니다(모든 줄에 주석 있음)
server-frps-guide.sh (서버 측 원클릭 구축, 아무 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. 1중계 기기(구형 휴대폰 Termux)에서 실행: bash install-frpc.sh
  2. 2마법사에 따라 입력: 서버 주소·포트 7000·Token·접속 도메인·중계 로컬 포트(기본 8000)
  3. 3터널이 열리면 앱의 BaseURL을 https://도메인/v1로 설정 — 밖에서 사용; 집에서는 LAN 주소도 사용 가능하며 둘 다 공존합니다
install-frpc.sh (중계 기기 측 터널 설정)
#!/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 "========================================"

방법 2: Tailscale (서버 설정 제로·대안)

  1. 1중계 기기(Android 앱 또는 Termux)와 휴대폰에 Tailscale을 설치하고 같은 계정으로 로그인
  2. 2양쪽이 같은 가상 네트워크에 들어가면 앱의 BaseURL을 http://중계의 TailscaleIP:8000/v1로 설정
  3. 3완전한 WireGuard 암호화로 어느 네트워크에서든 접속 가능; 단점: Tailscale은 해외 서비스라 국내 네트워크에서 가끔 불안정

보안 정책 (공격을 두려워하지 않아도 되는 이유)

  • 🔒frps는 무상태 순수 전달자: API 키도 사용자 데이터도 저장하지 않아 서버가 뚫려도 아무것도 얻을 수 없음
  • 🔒frpc ↔ frps는 종단 간 TLS 암호화로 요청을 공용망에서 엿볼 수 없음
  • 🔒Token 인증: 미승인 기기는 터널을 만들 수 없음
  • 🔒frps 관리 패널은 localhost에서만 수신하며 공개하지 않음
  • 🔒Nginx 프록시 계층에 속도 제한(초당 10회)과 요청 크기 상한으로 악용 방지
  • 🔒각 제공업체 API 키는 항상 중계 기기(config.json)에만 존재하고, 앱은 중계 키만 보관

자주 묻는 질문

밖에서 중계를 사용할 수 있나요?

가능합니다. frp + 내 서버(추천, '밖에서 사용하기' 항목 참조) 또는 Tailscale을 사용하세요. 터널이 열리면 앱의 BaseURL을 공개 주소로 설정하면 됩니다.

중계 서버가 공격받을 수 있나요? 키가 유출되나요?

frps는 키를 저장하지 않는 순수 전달자이고, 터널은 Token 인증을 포함한 완전한 TLS 암호화이며, 패널은 공개되지 않고 Nginx 계층이 속도를 제한합니다. 서버가 뚫려도 공격자는 당신의 API 키를 하나도 얻지 못합니다.

릴레이가 리소스를 많이 먹나요?

아니요. 전달만 하고 모델을 실행하지 않습니다 — 메모리 사용량은 보통 수십 MB 수준으로 오래된 휴대폰에도 부담이 없고, 유휴 시 배터리도 거의 소모하지 않습니다.

휴대폰을 재부팅하면 릴레이가 계속 작동하나요?

Termux의 릴레이는 부팅 시 자동 시작되지 않습니다. 재부팅 후 bash install-termux.sh를 다시 실행하세요 — 설정이 저장되어 있어 바로 시작됩니다.

같은 Wi-Fi 밖에서도 쓸 수 있나요?

가능하지만 포트를 공개 인터넷에 직접 노출하는 것은 권장하지 않습니다. Tailscale로 휴대폰과 릴레이 기기를 같은 가상 네트워크에 넣으면 어느 네트워크에서든 암호화된 상태로 접근할 수 있습니다.

릴레이는 안전한가요? 키가 유출되나요?

키는 내 릴레이 기기에만 저장되고, 휴대폰이 보관하는 것은 릴레이 키이지 각 제공업체 키가 아닙니다. config.json의 relayKey를 반드시 긴 랜덤 문자열로 바꾸고, 라우터에서 릴레이 포트의 포트 포워딩을 꺼두세요.

지원하는 모델·제공업체는?

OpenAI 호환 프로토콜이면 모두 지원: DeepSeek·Qwen·Kimi·Zhipu GLM·OpenAI·Gemini(OpenAI 호환 엔드포인트)·Claude(OpenRouter 경유) 등. config.json의 upstreams 목록에 하나씩 추가하면 되고, 라우팅은 정확 일치와 접두어 일치를 모두 지원합니다.

기기를 바꿀 때 마이그레이션은?

릴레이 디렉터리의 config.json(제공업체 키 포함)을 새 기기에 복사하고 relay.mjs 또는 원클릭 스크립트를 실행하세요. 휴대폰에서는 BaseURL의 IP만 새 기기 IP로 바꾸면 됩니다.

매트릭스 모드: 켜짐 — 다시 입력하거나 ESC로 종료