// AI RELAY

自前AI中継

各社のAPIキーを1台のアイドル端末に集約し、スマホでは1つのアドレスを入力するだけ——AIチャット・AIプログラミング・AIプラグイン生成がすべてスムーズに接続。

なぜ中継が必要か

CosmosBoxのAIツール(AIチャット・AIプログラミング・AIプラグイン生成)はBYOK方式——各LLMプロバイダーのAPIキーを自分で用意します。複数のプロバイダーに直接接続すると、プロバイダーごとにキーとBaseURLが必要になり、モデルを変えるたびに設定を切り替えることになります。

自前中継があれば、これがすべて1つのアドレスに集約されます:各社のキーは中継端末で一度だけ設定し、スマホには中継アドレス+中継キーを1つ入力するだけ。モデル名は自動的に該当プロバイダーへルーティングされます。また、プロバイダーが混雑しているときは指数バックオフで自動リトライするため、弱いネットワークでも安定します。

複数プロバイダー直結 vs 中継

項目複数直結中継利用
アプリ設定プロバイダーごとにキーとアドレスアドレス1つ+キー1つだけ
モデル切替設定を手動で切替モデル名で自動ルーティング
混雑時(429)即失敗・手動リトライ指数バックオフで自動リトライ
キー管理端末ごとに散在中継端末だけに保存

仕組み

中継は、自分の端末で動く超軽量のOpenAI互換転送サービスです。リクエストはまず中継に届き、モデル名から該当プロバイダーを見つけて転送し、結果をストリーミングでスマホに返します。サービス全体は依存ゼロのNode.js単一ファイル——サードパーティのパッケージもデータベースも不要です。

  1. 1スマホのCosmosBoxアプリがリクエスト送信 → 中継アドレス
  2. 2中継がモデル名をプロバイダーにマッチ(例:deepseek-v4-flash → DeepSeek)
  3. 3プロバイダーが内容を生成し、1語ずつストリーミングで返す
  4. 4中継がそのまま転送し、アプリがリアルタイム表示

準備

  • アイドル端末1台:古いAndroidスマホ・Android TVボックス・旧PCどれでもOK(モデルは実行しないためリソース消費は極小)
  • 古いスマホ / TVボックス:Termuxをインストール(TVボックスはAPKのサイドロードが必要);旧PC:Node.js 18以上をインストール
  • LLMのAPIキーを1つ以上:DeepSeek・通義千問・Kimi・智譜GLM・OpenAI……OpenAI互換プロトコルのプロバイダーならどれでも

ワンクリック構築(古いスマホ / TVボックス · Termux)

下のinstall-termux.shを丸ごとコピーし(またはAI中継プロジェクトから取得)、Termuxで実行するだけ:スクリプトがNode.jsをインストールし、対話式ウィザードで設定を生成(各社キーを1つずつ入力)、中継を起動してスマホ側の設定情報を表示します。

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ステップ。下の2つのコードブロックが中継プログラム本体と設定例です。コピーして保存し、手順に従ってください。

  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プラグイン生成に戻り、追加したプロバイダーを選択——3つのツールは同じ設定を共有
⚠️
スマホと中継端末は同じWi-Fi(同じLAN)に接続してください。中継端末のIPは中継の起動ログか、端末のネットワーク設定で確認できます。

外出先での利用(リモートアクセス)

中継は初期状態では自宅のWi-Fi内でのみ利用できます。外出先で使うには、自宅の中継をインターネットに接続する暗号化トンネルが必要です。2つの選択肢があります——アプリには組み込まれていません。お好みで選んでください:

方法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・通義千問・Kimi・智譜GLM・OpenAI・Gemini(OpenAI互換エンドポイント)・Claude(OpenRouter経由)など。config.jsonのupstreamsに1つずつ追加してください。ルーティングは完全一致と前方一致の両方に対応しています。

端末を替えるときの移行方法は?

中継ディレクトリのconfig.json(各社キーを含む)を新しい端末にコピーし、relay.mjsまたはワンクリックスクリプトを実行します。スマホ側はBaseURLのIPを新しい端末のIPに変更するだけです。

マトリックスモード: ON — 再入力するか ESC で終了