Соберите все ключи API на одном неиспользуемом устройстве и введите на телефоне всего один адрес — ИИ-чат, ИИ-программирование и генерация ИИ-плагинов, всё работает плавно.
ИИ-инструменты YuzhouHe (ИИ-чат, ИИ-программирование, генерация ИИ-плагинов) работают в режиме BYOK — вы приносите свои ключи API каждого провайдера. При прямом подключении к нескольким провайдерам нужен отдельный ключ и BaseURL для каждого, а при смене модели приходится переключать конфигурацию.
Своё реле сводит всё это к одному адресу: ключи каждого провайдера настраиваются один раз на устройстве реле, а на телефоне вы вводите лишь адрес реле + ключ реле. Имена моделей автоматически маршрутизируются к нужному провайдеру. Кроме того, реле автоматически повторяет запросы с экспоненциальной задержкой, когда провайдер занят, — стабильнее и при слабой сети.
| Аспект | Прямое подключение | С реле |
|---|---|---|
| Настройка в приложении | Ключ и адрес на каждого провайдера | Всего один адрес + один ключ |
| Смена модели | Ручное переключение конфигурации | Модель автоматически маршрутизируется |
| Провайдер занят (429) | Сразу ошибка, повтор вручную | Автоматический повтор с экспоненциальной задержкой |
| Управление ключами | Разбросаны по устройствам | Только на устройстве реле |
Реле — это очень лёгкий сервис пересылки, совместимый с OpenAI, который работает на вашем устройстве. Запрос сначала идёт на реле; реле находит провайдера по имени модели, пересылает запрос и возвращает результат потоком на телефон. Весь сервис — один файл Node.js без зависимостей: ни сторонних пакетов, ни базы данных.
Скопируйте скрипт install-termux.sh ниже полностью (или возьмите его из проекта AI-реле) и запустите в Termux: скрипт установит Node.js, создаст конфигурацию в интерактивном мастере (ключи каждого провайдера вводятся по одному), запустит реле и выведет настройки для телефона.
#!/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 "========================================"
Не нужен скрипт в один клик или хочется запустить на ПК / NAS? Три ручных шага. Два блока кода ниже — это полная программа реле и пример конфигурации. Скопируйте, сохраните и следуйте шагам.
{
"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"
]
}
]
}#!/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) {
// 解析请求体(超限/异常 → 413)
let raw;
try {
raw = await readBody(req);
} catch {
return sendJson(res, 413, { error: { message: '请求体过大' } });
}
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 });
}
// ---------- 工具 ----------
/** 请求体上限:正常请求远小于 2MB,超限拒收,防恶意超大 body 打满内存 */
const MAX_BODY_BYTES = 2 * 1024 * 1024;
function readBody(req, maxBytes = MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let tooBig = false;
req.on('data', (c) => {
if (tooBig) return; // 超限后继续消费并丢弃:保证连接正常结束、客户端能收到 413
total += c.length;
if (total > maxBytes) {
tooBig = true;
chunks.length = 0; // 释放已收内容
const err = new Error('请求体过大');
err.statusCode = 413;
reject(err);
return;
}
chunks.push(c);
});
req.on('end', () => {
if (!tooBig) 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,
...(task.selfCheckNote ? { selfCheckNote: task.selfCheckNote } : {}),
});
}
// 取消任务:中断上游流并标记 cancelled(App 停止按钮)
const cancelMatch = p.match(/^\/v1\/tasks\/([A-Za-z0-9-]+)\/cancel$/);
if (req.method === 'POST' && cancelMatch) {
const task = tasks.get(cancelMatch[1]);
if (!task) return sendJson(res, 404, { error: { message: '任务不存在' } });
task.aborted = true;
const live = activeStreams.get(task.id);
if (live) {
live.destroy(); // 触发上游流 error → 按 aborted 标记为 cancelled
} else {
task.status = 'cancelled';
}
task.updatedAt = Date.now();
return sendJson(res, 200, { taskId: task.id, status: 'cancelled' });
}
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 activeStreams = new Map(); // taskId -> 上游响应(取消任务用)
const MAX_TASKS = Number(process.env.RELAY_MAX_TASKS || 100);
/** 任务 TTL:完成后超过该时长自动清理(App 轮询早已结束,无影响);测试可用短值 */
const TASK_TTL_MS = Number(process.env.RELAY_TASK_TTL_MS || 30 * 60 * 1000);
/** 清理过期任务(终态 + 超过 TTL):防止长期使用积累到上限后新任务被拒 */
function pruneTasks() {
const now = Date.now();
for (const [id, t] of tasks) {
if (t.status !== 'running' && now - (t.updatedAt || t.createdAt) > TASK_TTL_MS) {
tasks.delete(id);
activeStreams.delete(id);
}
}
}
// 定期清理兜底;创建任务时也会惰性清理。interval 跟随 TTL(测试用短值可快速验证)
setInterval(pruneTasks, Math.max(1000, Math.min(60 * 1000, TASK_TTL_MS / 3)));
async function handleTaskCreate(req, res) {
let payload;
try {
const raw = await readBody(req);
payload = JSON.parse(raw);
} catch (e) {
if (e && e.statusCode === 413) {
return sendJson(res, 413, { error: { message: '请求体过大' } });
}
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}" 的上游` } });
}
pruneTasks(); // 惰性清理过期任务(防积累到上限)
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(),
updatedAt: Date.now(), // 最后内容更新时间(TTL 清理依据)
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;
task.updatedAt = Date.now();
log(`[任务] ${task.id} 失败: ${e.message}`);
});
sendJson(res, 202, { taskId: task.id, status: 'running' });
}
async function runTask(task, upstream, body) {
const t0 = Date.now();
const MAX_CONTINUATIONS = 3; // 截断自动续写轮数上限
const model = JSON.parse(body).model || ''; // 自检等后续阶段需要模型名
let rounds = 0;
let currentBody = body;
// 续写:把已有内容并入消息历史,追加"继续"指令(模型从停止处接着输出)
// 续写轮固定关闭思考:截断往往是思考吃光预算导致,续写再思考会重复烧时间/额度
const continuationBody = () => {
const p = JSON.parse(currentBody);
const m = String(p.model || '').toLowerCase();
if (m.startsWith('deepseek') || m.startsWith('glm') || m.startsWith('kimi')) {
p.thinking = { type: 'disabled' };
}
const msgs = [
...(p.messages || []),
{ role: 'assistant', content: task.content },
{ role: 'user', content: '输出被截断,请直接从上次停止处继续输出剩余内容,不要重复已生成的部分。' },
];
return JSON.stringify({ ...p, messages: msgs });
};
// 单轮流式消费:解析增量(含 reasoning/content/finish_reason),流结束后回调 finishReason
const consumeStream = (response, onEnd) => {
const code = response.statusCode || 502;
if (code !== 200) {
readBody(response, Infinity).then((err) => {
task.status = 'error';
task.error = `上游错误 ${code}: ${err.slice(0, 200)}`;
onEnd(null);
});
return;
}
const ct = (response.headers['content-type'] || '').toLowerCase();
if (!ct.includes('text/event-stream')) {
// 非流式 JSON 响应
readBody(response, Infinity).then((raw) => {
try {
const j = JSON.parse(raw);
task.content += j?.choices?.[0]?.message?.content ?? '';
task.reasoning += j?.choices?.[0]?.message?.reasoning_content ?? '';
task.updatedAt = Date.now();
onEnd(j?.choices?.[0]?.finish_reason ?? null);
} catch {
task.error = '上游返回非 JSON';
task.status = 'error';
onEnd(null);
}
});
return;
}
// 流式:逐行解析增量
activeStreams.set(task.id, response);
response.setEncoding('utf8');
let buf = '';
let finishReason = null;
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') 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;
}
task.updatedAt = Date.now();
} catch { /* 非法行忽略 */ }
}
});
response.on('end', () => { activeStreams.delete(task.id); onEnd(finishReason); });
response.on('error', (e) => {
activeStreams.delete(task.id);
if (task.aborted) {
task.status = 'cancelled'; // 用户主动取消
} else {
task.status = 'error';
task.error = `上游流中断: ${e.message}`;
}
onEnd(null);
});
};
// 轮次循环:截断(length)→ 续写轮;stop / 错误 / 超轮数 → 收尾
await new Promise((resolve) => {
const round = () => {
sendWithRetry(upstream, '/chat/completions', {}, currentBody).then(({ response }) => {
consumeStream(response, (finishReason) => {
if (task.status === 'error' || task.aborted) {
if (task.aborted && task.status !== 'error') task.status = 'cancelled';
resolve();
return;
}
if (finishReason === 'length' && rounds < MAX_CONTINUATIONS) {
rounds++;
log(`[任务] ${task.id} 输出截断,自动续写 ${rounds}/${MAX_CONTINUATIONS}`);
currentBody = continuationBody();
round();
} else {
resolve();
}
});
});
};
round();
});
// 生成后自检(agentic self-check):仅文件协议内容、且内容可疑(无 DONE 标记 /
// 缺入口文件 / 无文件块)时,让模型审查并自动修复一轮,修复结果合并进任务内容
if (task.status === 'running' && task.content.trim() !== '') {
try {
if (await selfCheck(task, upstream, model)) {
task.selfCheckNote = '已自动修复不完整内容';
task.updatedAt = Date.now();
log(`[任务] ${task.id} 自检发现不完整内容并已自动修复`);
}
} catch (e) {
log(`[任务] ${task.id} 自检跳过: ${e.message}`);
}
}
if (task.status === 'running') {
task.status = 'done';
task.updatedAt = Date.now(); // TTL 从完成时刻起算
log(`[任务] ${task.id} 完成 ${Date.now() - t0}ms,${task.content.length} 字符${rounds ? `(续写${rounds}轮)` : ''}`);
}
}
// ---------- 生成后自检:内容完整性自动审查与修复(对标桌面 Agent 的自检循环) ----------
const ENTRY_HINTS = ['index.html', 'README.md', 'main.js', 'main.py', 'app.py', 'Dockerfile'];
/// 是否值得自检:仅文件协议内容参与;完整信号 = 有 DONE 标记 + 文件块 + 常见入口 → 跳过
function needsSelfCheck(content) {
if (!content.includes('=== FILE:')) return false; // 非文件协议(闲聊/单文件 HTML)不自检
const hasDone = content.includes('=== DONE ===');
const blocks = [...content.matchAll(/=== FILE: ([^\n]+) ===/g)];
const hasEntry = blocks.some((m) => ENTRY_HINTS.some((h) => m[1].includes(h)));
return !(hasDone && blocks.length > 0 && hasEntry);
}
/// 解析文件协议文本为 path → {content, start, end}(start/end 供原地替换)
function parseFileBlocks(text) {
const out = new Map();
const re = /=== FILE: ([^\n]+) ===\n([\s\S]*?)(?=\n=== FILE: |\n=== DONE ===|$)/g;
let m;
while ((m = re.exec(text)) !== null) {
out.set(m[1].trim(), { content: m[2], start: m.index, end: m.index + m[0].length });
}
return out;
}
/// 把自检返回的修复文件合并进原内容:已有文件原地替换,新增文件插到 DONE 标记前
function applyFixes(original, fixes) {
const orig = parseFileBlocks(original);
const fix = parseFileBlocks(fixes);
let result = original;
const repl = [...fix.entries()]
.filter(([p]) => orig.has(p))
.sort((a, b) => orig.get(b[0]).start - orig.get(a[0]).start); // 从后往前避免偏移
for (const [p, f] of repl) {
const o = orig.get(p);
result = result.slice(0, o.start) + `=== FILE: ${p} ===\n${f.content}` + result.slice(o.end);
}
const added = [...fix.entries()].filter(([p]) => !orig.has(p));
if (added.length) {
const append = '\n' + added.map(([p, f]) => `=== FILE: ${p} ===\n${f.content}`).join('\n');
const doneIdx = result.lastIndexOf('\n=== DONE ===');
if (doneIdx >= 0) result = result.slice(0, doneIdx) + append + result.slice(doneIdx);
else result = result + append;
}
return result;
}
/// 自检一轮:模型审查生成内容,返回是否有修复合并进任务
async function selfCheck(task, upstream, model) {
const content = task.content;
if (!needsSelfCheck(content)) return false;
const checkPrompt = '你是代码质量审查员。下面是 AI 生成的项目文件(标记协议)。请检查:\n'
+ '1. 是否缺少项目入口文件(web/前端项目必须有 index.html)\n'
+ '2. 文件内容是否被截断/不完整(如未闭合的标签、引号、括号、花括号)\n'
+ '3. 文件引用是否缺失(CSS/JS 引用了不存在的文件)\n'
+ '发现问题时,用以下标记输出**修复后的完整文件**(只输出有问题的文件,不要解释、不要输出其他文字):\n'
+ '=== FILE: 相对路径 ===\n修复后的完整内容\n'
+ '所有文件都正常时,只输出 OK 两个字母。\n\n'
+ '生成内容:\n' + content;
const checkBody = JSON.stringify({
model,
messages: [{ role: 'user', content: checkPrompt }],
stream: true,
max_tokens: 16384,
thinking: { type: 'disabled' }, // 自检求快求稳(DeepSeek/Kimi/GLM 系 schema)
});
const { response } = await sendWithRetry(upstream, '/chat/completions', {}, checkBody);
const code = response.statusCode || 502;
if (code !== 200) {
await new Promise((r) => { response.resume(); response.on('end', r); });
return false;
}
let fixText = '';
const ct = (response.headers['content-type'] || '').toLowerCase();
if (ct.includes('text/event-stream')) {
await new Promise((resolve, reject) => {
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;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const d = JSON.parse(data)?.choices?.[0]?.delta || {};
if (typeof d.content === 'string') fixText += d.content;
} catch { /* 非法行忽略 */ }
}
});
response.on('end', resolve);
response.on('error', reject);
});
} else {
const raw = await new Promise((resolve) => {
const chunks = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
try {
fixText = JSON.parse(raw)?.choices?.[0]?.message?.content || '';
} catch { /* 非 JSON 忽略 */ }
}
const trimmed = fixText.trim();
if (trimmed === '' || trimmed === 'OK' || !fixText.includes('=== FILE:')) return false;
task.content = applyFixes(task.content, fixText);
return true;
}
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>';
}
По умолчанию реле работает только в домашнем Wi-Fi. Чтобы пользоваться им на улице, нужен зашифрованный туннель от домашнего реле в интернет. Два варианта — ничего не встроено в приложение; выбираете вы:
Используйте свой облачный сервер (панель BT / Linux, 2 ядра 2 ГБ достаточно) как туннель: устройство реле подключается исходящим соединением к серверу, а телефон обращается к https://ваш-домен/v1. Сквозное шифрование; сервер не хранит ключи — даже взломанный сервер не даст атакующим ничего.
#!/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 "========================================"
#!/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 "========================================"
Да. Используйте frp + свой сервер (рекомендуется, см. раздел «Использование вне дома») или Tailscale. После установки туннеля укажите в приложении BaseURL публичного адреса.
frps — чистый пересыльщик без хранения ключей; туннель полностью шифруется TLS с токен-аутентификацией; панель не открывается наружу; слой Nginx ограничивает скорость. Даже при взломе сервера атакующие не получат ни одного вашего ключа API.
Нет. Оно только пересылает запросы и никогда не запускает модели — память обычно занимает несколько десятков МБ, для старого телефона это пустяк; в простое почти не расходует батарею.
Реле в Termux не запускается автоматически при загрузке. После перезагрузки снова выполните bash install-termux.sh — скрипт обнаружит сохранённую конфигурацию и запустится сразу.
Да, но не рекомендуем открывать порт напрямую в интернет. Используйте Tailscale, чтобы объединить телефон и устройство реле в одну виртуальную сеть — доступ из любой сети, полностью зашифровано.
Ключи хранятся только на вашем устройстве реле; на телефоне хранится ключ реле, а не ключи провайдеров. Обязательно замените relayKey в config.json на длинную случайную строку и отключите проброс порта реле на роутере.
Любой совместимый с OpenAI провайдер: DeepSeek, Qwen, Kimi, Zhipu GLM, OpenAI, Gemini (совместимая конечная точка), Claude (через OpenRouter) и другие. Добавляйте их по одному в список upstreams в config.json; маршрутизация поддерживает точное и префиксное совпадение.
Скопируйте config.json (в нём ключи провайдеров) из папки реле на новое устройство и запустите relay.mjs или скрипт в один клик. На телефоне просто замените IP в BaseURL на IP нового устройства.