@7ots/cli 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/lib/assistant.mjs +6 -2
- package/cli/lib/config.mjs +2 -1
- package/cli/lib/pet-server.mjs +8 -0
- package/cli/lib/prompts.mjs +31 -17
- package/cli/lib/prowl.mjs +110 -0
- package/cli/pet/settings.html +4 -1
- package/dist/7ots.esm.js +56 -56
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +56 -56
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +1 -1
- package/package.json +17 -14
- package/server/platform/db.mjs +5 -0
- package/server/platform/routes.mjs +1 -0
- package/server/platform/runtime.mjs +2 -2
- package/server/platform/vm.mjs +408 -16
- package/server/tts.mjs +17 -4
- package/src/character/Character.js +1 -1
- package/src/character/parts.js +10 -0
- package/src/character3d/Character3D.js +105 -2
- package/src/i18n/messages/character.js +3 -0
- package/src/i18n/messages/platform.js +18 -0
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 7ots
|
|
2
2
|
|
|
3
3
|
> An ot is the user's personal AI companion with its own face, personality and voice. It lives on their desktop as a pet
|
|
4
|
-
> (`npx @7ots/cli pet`), watches over their coding agent (Claude Code /
|
|
4
|
+
> (`npx @7ots/cli pet`), watches over their coding agent (Claude Code hooks + bash/zsh hooks), sets reminders, watches prices and
|
|
5
5
|
> processes, can see the screen if allowed, joins apuchat video calls and can work as an Orquesta assistant inside a project.
|
|
6
6
|
> Every ot's identity (face, personality, voice, meet avatar) is reproducible from a short seed. 7ots integrates apuchat,
|
|
7
7
|
> apumail and voice providers; it does not host audio or mail.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7ots/cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "7ots.com —
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "7ots.com — your AI companion with a face of its own: a desktop pet that watches over your coding agent (Claude Code and shell hooks), a personal assistant (reminders, alerts, notes, opens sites, sees your screen if allowed) and a reproducible identity (face, personality, voice) for any agent — in apuchat meet calls, Orquesta projects or your own server.",
|
|
5
5
|
"homepage": "https://7ots.com",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -45,22 +45,25 @@
|
|
|
45
45
|
"node": ">=20"
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
|
+
"ai-companion",
|
|
49
|
+
"desktop-pet",
|
|
50
|
+
"tamagotchi",
|
|
51
|
+
"personal-assistant",
|
|
52
|
+
"agent-identity",
|
|
48
53
|
"ai-agent",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
54
|
+
"claude-code",
|
|
55
|
+
"coding-agent",
|
|
56
|
+
"hooks",
|
|
57
|
+
"reminders",
|
|
58
|
+
"avatar",
|
|
59
|
+
"voice",
|
|
60
|
+
"meet",
|
|
61
|
+
"orquesta",
|
|
62
|
+
"prowl",
|
|
53
63
|
"mcp",
|
|
54
64
|
"claude",
|
|
55
65
|
"openai",
|
|
56
|
-
"
|
|
57
|
-
"avatar",
|
|
58
|
-
"i18n",
|
|
59
|
-
"proactive",
|
|
60
|
-
"cli",
|
|
61
|
-
"pet",
|
|
62
|
-
"tamagotchi",
|
|
63
|
-
"claude-code"
|
|
66
|
+
"cli"
|
|
64
67
|
],
|
|
65
68
|
"dependencies": {
|
|
66
69
|
"@anthropic-ai/sdk": "^0.129.0",
|
package/server/platform/db.mjs
CHANGED
|
@@ -208,6 +208,11 @@ export function openDb(path = process.env.PLATFORM_DB) {
|
|
|
208
208
|
migrateLookPrints(db);
|
|
209
209
|
// VMs paid by 7ots (provider 'orquesta') are opt-in per account; your own server never needs it (vm.mjs).
|
|
210
210
|
if (!db.prepare('PRAGMA table_info(accounts)').all().some((c) => c.name === 'vm_enabled')) db.exec('ALTER TABLE accounts ADD COLUMN vm_enabled INTEGER NOT NULL DEFAULT 0');
|
|
211
|
+
// VMs created through Orquesta: its last-seen VM status, when it was checked and the monthly price it quoted.
|
|
212
|
+
const vmCols = db.prepare('PRAGMA table_info(ots_vms)').all().map((c) => c.name);
|
|
213
|
+
if (!vmCols.includes('orq_status')) db.exec("ALTER TABLE ots_vms ADD COLUMN orq_status TEXT NOT NULL DEFAULT ''");
|
|
214
|
+
if (!vmCols.includes('orq_checked_at')) db.exec('ALTER TABLE ots_vms ADD COLUMN orq_checked_at INTEGER');
|
|
215
|
+
if (!vmCols.includes('price_cents')) db.exec('ALTER TABLE ots_vms ADD COLUMN price_cents INTEGER');
|
|
211
216
|
return db;
|
|
212
217
|
}
|
|
213
218
|
|
|
@@ -380,6 +380,7 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
|
|
|
380
380
|
if (sub === '' && req.method === 'DELETE') {
|
|
381
381
|
await stopOtsChannels(ots.id);
|
|
382
382
|
await connect.release(account, ots);
|
|
383
|
+
await vm.releaseOts(ots.id); // its 7ots server (Orquesta VM) goes too; ots_vms cascades with the ot
|
|
383
384
|
deleteOts(ots.id);
|
|
384
385
|
return send(res, 200, { ok: true });
|
|
385
386
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { createLLM } from '../llm.mjs';
|
|
17
|
-
import { synthesize as synthesizeWith, ttsProvider } from '../tts.mjs';
|
|
17
|
+
import { synthesize as synthesizeWith, ttsProvider, openaiTtsKey } from '../tts.mjs';
|
|
18
18
|
import { startAgentChannels } from '../channels/index.mjs';
|
|
19
19
|
import { apumailConfigFromEnv } from '../channels/apumail.mjs';
|
|
20
20
|
import { apuchatConfigFromEnv } from '../channels/apuchat.mjs';
|
|
@@ -75,7 +75,7 @@ export function otsEnv(ots) {
|
|
|
75
75
|
|
|
76
76
|
// Voz: la suya o la de apuchat de la plataforma.
|
|
77
77
|
let platformTts = false;
|
|
78
|
-
if (!TTS_KEYS.some((k) => env[k]) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
|
|
78
|
+
if (!TTS_KEYS.some((k) => (k === 'OPENAI_API_KEY' ? openaiTtsKey(env) : env[k])) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
|
|
79
79
|
platformTts = true;
|
|
80
80
|
// On the platform's token the engine and model are the platform's, not the ot's.
|
|
81
81
|
delete env.APUCHAT_VOICE_PROVIDER;
|
package/server/platform/vm.mjs
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The machine where an ot's assistant worker runs, and the desktop → VM offload channel (docs/VM-PLAN.md).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Provider 'byo': the owner brings their own Linux box: the dashboard hands out a one-time
|
|
5
5
|
* `7ve_` code and the exact command; `7ots vm enroll` trades it for a `7vm_` token bound to that one
|
|
6
|
-
* ot and `7ots vm worker` connects outbound only (no SSH keys ever reach 7ots).
|
|
7
|
-
*
|
|
6
|
+
* ot and `7ots vm worker` connects outbound only (no SSH keys ever reach 7ots).
|
|
7
|
+
* Provider 'orquesta': a VM paid by 7ots, gated by accounts.vm_enabled and ORQUESTA_VM_TOKEN (an
|
|
8
|
+
* org-wide Orquesta key, never shown to anyone). 7ots creates project `ot-<id>` + its VM with a
|
|
9
|
+
* bootstrap that enrolls it the same way (code valid VM_ORQ_ENROLL_MIN, default 60, to cover the boot).
|
|
10
|
+
* Quotas VM_PER_ACCOUNT (default 1) and VM_MAX_TOTAL (default 10). Deleting the VM, the ot or the
|
|
11
|
+
* account tears down VM and project; a reconciliation every VM_RECONCILE_MIN (30) compares
|
|
12
|
+
* GET /api/v1/vms with ots_vms, retries failed teardowns and logs orphans (VM_REAP_ORPHANS=1 deletes them).
|
|
13
|
+
* Env: ORQUESTA_URL, ORQUESTA_VM_SIZE (default: cheapest), ORQUESTA_VM_SIZES ('slug[:usd],…'), ORQUESTA_VM_REGION,
|
|
14
|
+
* ORQUESTA_VM_REF_PREFIX ('7ots:', distinct per 7ots install sharing one org), SEVENOTS_VM_CLI (pinned, 0.1.6).
|
|
8
15
|
*
|
|
9
16
|
* Dashboard (session + X-7ots-Admin, via routes.mjs):
|
|
10
|
-
* GET /api/platform/ots/:id/vm { vm|null,
|
|
17
|
+
* GET /api/platform/ots/:id/vm { vm|null, jobs, kinds, daily, paidVms, hosted: { available, canCreate, reason, sizes, … } }
|
|
18
|
+
* POST /api/platform/ots/:id/vm { provider: 'orquesta', size? } → 202 { vm } (provisioning in the background)
|
|
11
19
|
* POST /api/platform/ots/:id/vm/enroll { provider?: 'byo' } → { code, expiresIn, command }
|
|
12
|
-
* DELETE /api/platform/ots/:id/vm revokes the worker,
|
|
20
|
+
* DELETE /api/platform/ots/:id/vm revokes the worker; byo → terminated, orquesta → 202 terminating → terminated
|
|
13
21
|
*
|
|
14
22
|
* Worker on the VM (Authorization: Bearer 7vm_…):
|
|
15
23
|
* POST /api/vm/enroll { code } → { token, ots } (no auth, rate-limited, code used once)
|
|
@@ -37,7 +45,7 @@ import { identityPrompt } from '../identity.mjs';
|
|
|
37
45
|
import { errorText, reqT } from '../i18n.mjs';
|
|
38
46
|
import { getDb, tx } from './db.mjs';
|
|
39
47
|
import { newId, sha256, token } from './crypto.mjs';
|
|
40
|
-
import { getOts, getOwnOts } from './store.mjs';
|
|
48
|
+
import { getOts, getOwnOts, getPlatformState, setPlatformState } from './store.mjs';
|
|
41
49
|
import { otsStep } from './runtime.mjs';
|
|
42
50
|
|
|
43
51
|
const penv = process.env;
|
|
@@ -113,6 +121,7 @@ function vmView(vm) {
|
|
|
113
121
|
enrollExpiresAt: vm.enroll_hash ? vm.enroll_exp : null,
|
|
114
122
|
error: vm.error || null,
|
|
115
123
|
since: vm.created_at,
|
|
124
|
+
...(vm.provider === 'orquesta' ? { orqStatus: vm.orq_status || null, ip: vm.ip || null, monthlyPrice: vm.price_cents ? vm.price_cents / 100 : null, checkedAt: vm.orq_checked_at || null } : {}),
|
|
116
125
|
};
|
|
117
126
|
}
|
|
118
127
|
|
|
@@ -180,6 +189,329 @@ function workerJob(j, ots) {
|
|
|
180
189
|
return { id: j.id, kind: j.kind, input: j.input, context: j.context, prompt, createdAt: j.created_at };
|
|
181
190
|
}
|
|
182
191
|
|
|
192
|
+
// ───────────── VMs paid by 7ots, created through Orquesta (provider 'orquesta') ─────────────
|
|
193
|
+
//
|
|
194
|
+
// One Orquesta project `ot-<otsId>` per VM (Orquesta allows one VM per project), created with the
|
|
195
|
+
// org-wide ORQUESTA_VM_TOKEN; the VM boots with a bootstrap that enrolls it with a one-time 7ve_
|
|
196
|
+
// code, exactly as an owner's own server would. ots_vms.status: creating (Orquesta calls in flight)
|
|
197
|
+
// → pending (VM booting, waiting for the worker) → enrolled · error · terminating → terminated.
|
|
198
|
+
// orq_status mirrors Orquesta's own VM status (checked at most every 15 s).
|
|
199
|
+
|
|
200
|
+
const ORQ = () => (penv.ORQUESTA_URL || 'https://getorquesta.com').replace(/\/+$/, '');
|
|
201
|
+
const orqToken = () => String(penv.ORQUESTA_VM_TOKEN || '').trim();
|
|
202
|
+
export const hostedConfigured = () => !!orqToken();
|
|
203
|
+
const REF_PREFIX = () => penv.ORQUESTA_VM_REF_PREFIX || '7ots:';
|
|
204
|
+
const ORQ_ENROLL_TTL = () => Math.max(10, Number(penv.VM_ORQ_ENROLL_MIN || 60)) * 60_000; // covers the boot
|
|
205
|
+
const ORQ_CACHE_MS = 15_000;
|
|
206
|
+
const MAX_TOTAL = () => Math.max(0, Number(penv.VM_MAX_TOTAL ?? 10));
|
|
207
|
+
const PER_ACCOUNT = () => Math.max(0, Number(penv.VM_PER_ACCOUNT ?? 1));
|
|
208
|
+
const RECONCILE_MS = () => Math.max(0.01, Number(penv.VM_RECONCILE_MIN || 30)) * 60_000;
|
|
209
|
+
const PROJECT_RETRY_MS = () => Number(penv.VM_ORQ_RETRY_MS || 10_000);
|
|
210
|
+
const LIVE_ORQ = "provider = 'orquesta' AND status <> 'terminated'";
|
|
211
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
212
|
+
|
|
213
|
+
/** The CLI the VM installs, pinned (SEVENOTS_VM_CLI: '0.1.6' or '@7ots/cli@0.1.6'). */
|
|
214
|
+
function cliSpec() {
|
|
215
|
+
const v = String(penv.SEVENOTS_VM_CLI || '0.1.6').trim();
|
|
216
|
+
const s = v.startsWith('@7ots/cli@') ? v : `@7ots/cli@${v}`;
|
|
217
|
+
if (!/^@7ots\/cli@\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(s)) throw new Error('SEVENOTS_VM_CLI must be an exact version');
|
|
218
|
+
return s;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Sizes known (ORQUESTA_VM_SIZES 'slug[:usd/month],…'; prices from Orquesta's AWS catalogue). */
|
|
222
|
+
function hostedSizes() {
|
|
223
|
+
const list = String(penv.ORQUESTA_VM_SIZES || 't3.micro:17,t3.small:28,t3.medium:49')
|
|
224
|
+
.split(/[\s,]+/)
|
|
225
|
+
.map((x) => x.split(':'))
|
|
226
|
+
.filter(([id]) => /^[a-z0-9][a-z0-9.-]{1,40}$/.test(id))
|
|
227
|
+
.map(([id, p]) => ({ id, monthlyPrice: Number(p) > 0 ? Number(p) : null }));
|
|
228
|
+
return list.length ? list : [{ id: 't3.small', monthlyPrice: null }];
|
|
229
|
+
}
|
|
230
|
+
/** Always the cheapest size (ORQUESTA_VM_SIZE overrides only if listed). */
|
|
231
|
+
function defaultSize() {
|
|
232
|
+
const all = hostedSizes();
|
|
233
|
+
const forced = all.find((s) => s.id === penv.ORQUESTA_VM_SIZE);
|
|
234
|
+
if (forced) return forced.id;
|
|
235
|
+
return all.reduce((a, b) => ((b.monthlyPrice ?? Infinity) < (a.monthlyPrice ?? Infinity) ? b : a)).id;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Monthly price in cents from whatever Orquesta returns (its catalogue counts cents). */
|
|
239
|
+
function priceCents(v) {
|
|
240
|
+
const c = v?.priceMonthly ?? v?.price_monthly ?? v?.size?.priceMonthly ?? v?.size?.price_monthly;
|
|
241
|
+
if (Number(c) > 0) return Math.round(Number(c));
|
|
242
|
+
const usd = v?.monthly_price_usd ?? v?.monthlyPriceUsd ?? v?.monthly_price ?? v?.monthlyPrice;
|
|
243
|
+
return Number(usd) > 0 ? Math.round(Number(usd) * 100) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
class OrqError extends Error {}
|
|
247
|
+
|
|
248
|
+
async function orq(method, path, body, timeoutMs = 20_000) {
|
|
249
|
+
let res;
|
|
250
|
+
try {
|
|
251
|
+
res = await fetch(`${ORQ()}/api/v1${path}`, {
|
|
252
|
+
method,
|
|
253
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${orqToken()}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
254
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
255
|
+
redirect: 'manual',
|
|
256
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
257
|
+
});
|
|
258
|
+
} catch (e) {
|
|
259
|
+
throw Object.assign(new OrqError(`Orquesta unreachable (${e.name === 'TimeoutError' ? 'timeout' : e.code || e.name})`), { status: 0 });
|
|
260
|
+
}
|
|
261
|
+
const data = res.status === 204 ? {} : await res.json().catch(() => ({}));
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
const msg = typeof data.error === 'string' ? data.error : data.error?.message || data.message || '';
|
|
264
|
+
throw Object.assign(new OrqError(`Orquesta ${method} ${path.replace(/\/[^/]{20,}/g, '/…')}: HTTP ${res.status}${msg ? ` ${clean(msg, 200)}` : ''}`), { status: res.status, code: data.error?.code || data.code });
|
|
265
|
+
}
|
|
266
|
+
return data;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Bash run as root at the end of the VM's user-data (≤ 8 KB). Every value is validated, never free text. */
|
|
270
|
+
export function bootstrapScript({ server, code, cli = cliSpec() }) {
|
|
271
|
+
if (!/^https?:\/\/[A-Za-z0-9.:[\]-]+(\/[A-Za-z0-9._~/-]*)?$/.test(server)) throw new Error('bad server url');
|
|
272
|
+
if (!/^7ve_[A-Za-z0-9_-]{20,100}$/.test(code)) throw new Error('bad code');
|
|
273
|
+
return `#!/bin/bash
|
|
274
|
+
# 7ots cloud worker: user "ot" runs \`7ots vm worker\` (outbound HTTPS only).
|
|
275
|
+
set -euo pipefail
|
|
276
|
+
exec >>/var/log/7ots-bootstrap.log 2>&1
|
|
277
|
+
echo "[7ots] bootstrap $(date -u +%FT%TZ)"
|
|
278
|
+
CLI='${cli}'
|
|
279
|
+
SERVER='${server}'
|
|
280
|
+
CODE='${code}'
|
|
281
|
+
if ! command -v node >/dev/null 2>&1 || ! node -e 'process.exit(+process.versions.node.split(".")[0] < 20 ? 1 : 0)'; then
|
|
282
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
283
|
+
DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
|
284
|
+
fi
|
|
285
|
+
id ot >/dev/null 2>&1 || useradd --create-home --shell /bin/bash ot
|
|
286
|
+
# Pinned and installed once: the service never fetches code at restart.
|
|
287
|
+
npm install -g --no-fund --no-audit "$CLI"
|
|
288
|
+
BIN="$(command -v 7ots)"
|
|
289
|
+
sudo -u ot -H "$BIN" vm enroll --server "$SERVER" --code "$CODE"
|
|
290
|
+
# System unit (a --user unit would need a login session/linger for "ot").
|
|
291
|
+
cat >/etc/systemd/system/7ots-vm.service <<UNIT
|
|
292
|
+
[Unit]
|
|
293
|
+
Description=7ots cloud worker (7ots vm worker)
|
|
294
|
+
Wants=network-online.target
|
|
295
|
+
After=network-online.target
|
|
296
|
+
|
|
297
|
+
[Service]
|
|
298
|
+
User=ot
|
|
299
|
+
Group=ot
|
|
300
|
+
WorkingDirectory=/home/ot
|
|
301
|
+
Environment=HOME=/home/ot
|
|
302
|
+
ExecStart=$BIN vm worker
|
|
303
|
+
Restart=always
|
|
304
|
+
RestartSec=10
|
|
305
|
+
NoNewPrivileges=true
|
|
306
|
+
PrivateTmp=true
|
|
307
|
+
ProtectSystem=full
|
|
308
|
+
|
|
309
|
+
[Install]
|
|
310
|
+
WantedBy=multi-user.target
|
|
311
|
+
UNIT
|
|
312
|
+
systemctl daemon-reload
|
|
313
|
+
systemctl enable --now 7ots-vm
|
|
314
|
+
echo "[7ots] worker started"
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function setRow(otsId, fields, where = '') {
|
|
319
|
+
const keys = Object.keys(fields);
|
|
320
|
+
return getDb()
|
|
321
|
+
.prepare(`UPDATE ots_vms SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE ots_id = ?${where ? ` AND ${where}` : ''}`)
|
|
322
|
+
.run(...keys.map((k) => fields[k]), now(), otsId).changes;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const inflight = new Map(); // ots id → provisioning promise
|
|
326
|
+
const checking = new Map(); // ots id → status refresh promise
|
|
327
|
+
|
|
328
|
+
function teardownQueue() {
|
|
329
|
+
try {
|
|
330
|
+
const q = JSON.parse(getPlatformState('vm_teardowns') || '[]');
|
|
331
|
+
return Array.isArray(q) ? q : [];
|
|
332
|
+
} catch {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function queueTeardown(item) {
|
|
337
|
+
const q = teardownQueue().filter((x) => x.pid !== item.pid);
|
|
338
|
+
setPlatformState('vm_teardowns', JSON.stringify([...q, { ...item, at: now() }].slice(-500)));
|
|
339
|
+
}
|
|
340
|
+
function dequeueTeardown(pid) {
|
|
341
|
+
setPlatformState('vm_teardowns', JSON.stringify(teardownQueue().filter((x) => x.pid !== pid)));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Terminates the project's VM, then the project. 404 = already gone. 409 on the project = VM still going away. */
|
|
345
|
+
async function destroy(pid, { retries = 6 } = {}) {
|
|
346
|
+
if (!pid) return;
|
|
347
|
+
const p = `/projects/${encodeURIComponent(pid)}`;
|
|
348
|
+
try {
|
|
349
|
+
await orq('DELETE', `${p}/vm`, null, 60_000);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
if (e.status !== 404) throw e;
|
|
352
|
+
}
|
|
353
|
+
for (let i = 0; ; i++) {
|
|
354
|
+
try {
|
|
355
|
+
return void (await orq('DELETE', p));
|
|
356
|
+
} catch (e) {
|
|
357
|
+
if (e.status === 404) return;
|
|
358
|
+
if (e.status !== 409 || i >= retries) throw e;
|
|
359
|
+
await sleep(PROJECT_RETRY_MS());
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Runs after DELETE (and after a provisioning in flight ends): row 'terminating' → 'terminated'. */
|
|
365
|
+
async function finishTeardown(otsId) {
|
|
366
|
+
await inflight.get(otsId)?.catch(() => {});
|
|
367
|
+
const row = vmRow(otsId);
|
|
368
|
+
if (!row || row.status !== 'terminating') return;
|
|
369
|
+
try {
|
|
370
|
+
await destroy(row.orq_project_id);
|
|
371
|
+
setRow(otsId, { status: 'terminated', orq_status: 'terminated', ip: '', error: '' }, "status = 'terminating'");
|
|
372
|
+
dequeueTeardown(row.orq_project_id);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
console.warn(`[7ots] vm teardown ${otsId}:`, e.message);
|
|
375
|
+
setRow(otsId, { error: clean(e.message, 500) }, "status = 'terminating'"); // reconciliation retries
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Creates project + VM. The row may be deleted (ot gone) or set 'terminating' meanwhile: then it undoes its own work. */
|
|
380
|
+
async function provision(ots, { server, code, size, region }) {
|
|
381
|
+
let pid = '';
|
|
382
|
+
const gone = () => !vmRow(ots.id);
|
|
383
|
+
try {
|
|
384
|
+
const p = await orq('POST', '/projects', { name: `ot-${ots.id}`, description: clean(`7ots cloud worker for "${ots.name}" (ot ${ots.id}, account ${ots.accountId})`, 300) });
|
|
385
|
+
pid = String(p.project?.id || '');
|
|
386
|
+
if (!pid) throw new OrqError('Orquesta returned no project id');
|
|
387
|
+
setRow(ots.id, { orq_project_id: pid });
|
|
388
|
+
if (gone()) return void (await destroy(pid, { retries: 0 }).catch(() => queueTeardown({ pid, otsId: ots.id })));
|
|
389
|
+
if (vmRow(ots.id).status !== 'creating') return; // DELETE came first: finishTeardown takes it from here
|
|
390
|
+
const r = await orq('POST', `/projects/${encodeURIComponent(pid)}/vm`, { size, ...(region ? { region } : {}), external_ref: `${REF_PREFIX()}${ots.id}`, bootstrap: bootstrapScript({ server, code }) }, 6 * 60_000);
|
|
391
|
+
const v = r.vm || {};
|
|
392
|
+
if (gone()) return void (await destroy(pid).catch(() => queueTeardown({ pid, otsId: ots.id })));
|
|
393
|
+
setRow(ots.id, { orq_vm_id: String(v.id || ''), orq_status: clean(v.status, 40), ip: clean(v.ip, 60), price_cents: priceCents(v) ?? vmRow(ots.id).price_cents, orq_checked_at: now() }, "status <> 'terminated'");
|
|
394
|
+
setRow(ots.id, { status: 'pending' }, "status = 'creating'");
|
|
395
|
+
} catch (e) {
|
|
396
|
+
console.warn(`[7ots] vm create ${ots.id}:`, e.message);
|
|
397
|
+
if (gone()) return void (pid && (await destroy(pid).catch(() => queueTeardown({ pid, otsId: ots.id }))));
|
|
398
|
+
if (vmRow(ots.id).status !== 'creating') return;
|
|
399
|
+
// Undo what exists (a timed-out create may still have made the VM): ids cleared only once it is gone.
|
|
400
|
+
let undone = !pid;
|
|
401
|
+
if (pid) undone = await destroy(pid).then(() => true, () => false);
|
|
402
|
+
setRow(ots.id, { status: 'error', error: clean(e.message, 500), enroll_hash: null, enroll_exp: null, ...(undone ? { orq_project_id: '', orq_vm_id: '', orq_status: '' } : {}) }, "status = 'creating'");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Orquesta's view of the VM, at most every 15 s per ot (shared by concurrent readers). */
|
|
407
|
+
async function refreshHosted(otsId, { force = false } = {}) {
|
|
408
|
+
const row = vmRow(otsId);
|
|
409
|
+
if (!row || row.provider !== 'orquesta' || !row.orq_project_id || !hostedConfigured()) return row;
|
|
410
|
+
if (!['pending', 'enrolled', 'error'].includes(row.status) || inflight.has(otsId)) return row;
|
|
411
|
+
if (!force && row.orq_checked_at && now() - row.orq_checked_at < ORQ_CACHE_MS) return row;
|
|
412
|
+
if (!checking.has(otsId)) {
|
|
413
|
+
const p = (async () => {
|
|
414
|
+
try {
|
|
415
|
+
const v = (await orq('GET', `/projects/${encodeURIComponent(row.orq_project_id)}/vm`, null, 8000)).vm;
|
|
416
|
+
if (!v) throw Object.assign(new OrqError('no vm'), { status: 404 });
|
|
417
|
+
const st = clean(v.status, 40);
|
|
418
|
+
const fields = { orq_status: st, ip: clean(v.ip, 60), orq_checked_at: now(), ...(v.id ? { orq_vm_id: String(v.id) } : {}) };
|
|
419
|
+
const pc = priceCents(v);
|
|
420
|
+
if (pc) fields.price_cents = pc;
|
|
421
|
+
setRow(otsId, fields);
|
|
422
|
+
if (/^(error|failed)$/i.test(st)) setRow(otsId, { status: 'error', error: clean(v.error || `Orquesta reports the VM as ${st}`, 500) }, "status = 'pending'");
|
|
423
|
+
else if (/^(terminated|deleted|destroyed)$/i.test(st)) setRow(otsId, { status: 'error', error: 'The VM was terminated outside 7ots' }, "status IN ('pending', 'enrolled')");
|
|
424
|
+
else if (/^(running|active)$/i.test(st)) {
|
|
425
|
+
const cur = vmRow(otsId);
|
|
426
|
+
if (cur.status === 'pending' && cur.enroll_exp && cur.enroll_exp < now()) setRow(otsId, { status: 'error', error: 'The server did not connect before its enrollment code expired; delete it and create it again', enroll_hash: null, enroll_exp: null }, "status = 'pending'");
|
|
427
|
+
}
|
|
428
|
+
} catch (e) {
|
|
429
|
+
if (e.status === 404) {
|
|
430
|
+
setRow(otsId, { orq_status: 'gone', orq_checked_at: now() });
|
|
431
|
+
setRow(otsId, { status: 'error', error: 'The VM no longer exists in Orquesta' }, "status IN ('pending', 'enrolled')");
|
|
432
|
+
} else {
|
|
433
|
+
setRow(otsId, { orq_checked_at: now() }); // keep the cache window on errors too
|
|
434
|
+
console.warn(`[7ots] vm status ${otsId}:`, e.message);
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
checking.delete(otsId);
|
|
438
|
+
}
|
|
439
|
+
})();
|
|
440
|
+
checking.set(otsId, p);
|
|
441
|
+
}
|
|
442
|
+
await checking.get(otsId);
|
|
443
|
+
return vmRow(otsId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Compares Orquesta's VMs with ots_vms: refreshes them, retries teardowns, adopts or flags lost creates and logs orphans. */
|
|
447
|
+
export async function reconcileVms() {
|
|
448
|
+
if (!hostedConfigured()) return null;
|
|
449
|
+
const { vms = [] } = await orq('GET', '/vms', null, 30_000);
|
|
450
|
+
const prefix = REF_PREFIX();
|
|
451
|
+
const ours = vms.filter((v) => String(v.external_ref || '').startsWith(prefix));
|
|
452
|
+
const byRef = new Map(ours.map((v) => [String(v.external_ref).slice(prefix.length), v]));
|
|
453
|
+
const rows = getDb().prepare(`SELECT * FROM ots_vms WHERE ${LIVE_ORQ}`).all();
|
|
454
|
+
const report = { checked: rows.length, orquesta: ours.length, orphans: [], missing: [], retried: 0 };
|
|
455
|
+
for (const row of rows) {
|
|
456
|
+
if (inflight.has(row.ots_id)) continue;
|
|
457
|
+
const v = byRef.get(row.ots_id) || ours.find((x) => row.orq_project_id && String(x.project_id) === row.orq_project_id);
|
|
458
|
+
if (row.status === 'terminating') {
|
|
459
|
+
report.retried++;
|
|
460
|
+
await finishTeardown(row.ots_id);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (row.status === 'creating') {
|
|
464
|
+
// The server stopped mid-create: adopt what Orquesta made, or give up on it.
|
|
465
|
+
if (v) setRow(row.ots_id, { orq_project_id: String(v.project_id || row.orq_project_id), orq_vm_id: String(v.id || ''), orq_status: clean(v.status, 40), ip: clean(v.ip, 60), status: 'pending' }, "status = 'creating'");
|
|
466
|
+
else if (now() - row.updated_at > 15 * 60_000) setRow(row.ots_id, { status: 'error', error: 'Creation was interrupted' }, "status = 'creating'");
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (!v && row.orq_project_id) {
|
|
470
|
+
report.missing.push(row.ots_id);
|
|
471
|
+
setRow(row.ots_id, { orq_status: 'gone', orq_checked_at: now() });
|
|
472
|
+
if (!online(row)) setRow(row.ots_id, { status: 'error', error: 'The VM no longer exists in Orquesta' }, "status IN ('pending', 'enrolled')");
|
|
473
|
+
console.warn(`[7ots] vm reconcile: ${row.ots_id} has no VM in Orquesta`);
|
|
474
|
+
} else if (v) setRow(row.ots_id, { orq_status: clean(v.status, 40), ip: clean(v.ip, 60), orq_checked_at: now(), ...(v.id ? { orq_vm_id: String(v.id) } : {}) });
|
|
475
|
+
}
|
|
476
|
+
// Teardowns that failed when an ot was deleted.
|
|
477
|
+
for (const item of teardownQueue()) {
|
|
478
|
+
report.retried++;
|
|
479
|
+
await destroy(item.pid).then(() => dequeueTeardown(item.pid), (e) => console.warn(`[7ots] vm teardown retry ${item.pid}:`, e.message));
|
|
480
|
+
}
|
|
481
|
+
const live = new Set(rows.map((r) => r.ots_id));
|
|
482
|
+
const queued = new Set(teardownQueue().map((x) => x.pid));
|
|
483
|
+
for (const [otsId, v] of byRef) {
|
|
484
|
+
if (live.has(otsId) || queued.has(String(v.project_id))) continue;
|
|
485
|
+
if (/^(terminated|deleted|destroyed)$/i.test(String(v.status))) continue;
|
|
486
|
+
report.orphans.push({ otsId, vmId: v.id, projectId: v.project_id, status: v.status });
|
|
487
|
+
console.warn(`[7ots] vm reconcile: orphan VM ${v.id} (project ${v.project_id}, ref ${v.external_ref}) has no live ot`);
|
|
488
|
+
if (penv.VM_REAP_ORPHANS === '1') await destroy(String(v.project_id)).catch((e) => console.warn('[7ots] vm orphan teardown:', e.message));
|
|
489
|
+
}
|
|
490
|
+
setPlatformState('vm_orphans', JSON.stringify({ at: now(), orphans: report.orphans, missing: report.missing }));
|
|
491
|
+
return report;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Before an ot (or its account) is deleted: revoke its worker and tear its Orquesta VM down (queued if Orquesta fails). */
|
|
495
|
+
export async function releaseOtsVm(otsId) {
|
|
496
|
+
const row = vmRow(otsId);
|
|
497
|
+
if (!row) return;
|
|
498
|
+
getDb().prepare('UPDATE ots_vms SET agent_hash = NULL, enroll_hash = NULL, enroll_exp = NULL, updated_at = ? WHERE ots_id = ?').run(now(), otsId);
|
|
499
|
+
if (row.provider !== 'orquesta' || row.status === 'terminated') return;
|
|
500
|
+
setRow(otsId, { status: 'terminating' });
|
|
501
|
+
if (inflight.has(otsId)) return; // provision() sees the row gone and undoes its own work
|
|
502
|
+
if (!row.orq_project_id) return;
|
|
503
|
+
try {
|
|
504
|
+
await destroy(row.orq_project_id, { retries: 0 });
|
|
505
|
+
} catch (e) {
|
|
506
|
+
console.warn(`[7ots] vm teardown on delete ${otsId}:`, e.message);
|
|
507
|
+
queueTeardown({ pid: row.orq_project_id, otsId });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export async function releaseAccountVms(accountId) {
|
|
512
|
+
for (const { ots_id } of getDb().prepare('SELECT ots_id FROM ots_vms WHERE account_id = ?').all(accountId)) await releaseOtsVm(ots_id);
|
|
513
|
+
}
|
|
514
|
+
|
|
183
515
|
/**
|
|
184
516
|
* @param {{ send: Function, readJson: Function, rateLimit: (req) => boolean, base: (req) => string }} deps
|
|
185
517
|
*/
|
|
@@ -353,20 +685,69 @@ export function createVm({ send, readJson, rateLimit, base }) {
|
|
|
353
685
|
|
|
354
686
|
// ───────────── dashboard (/api/platform/ots/:id/vm…) ─────────────
|
|
355
687
|
|
|
688
|
+
/** What the dashboard needs to offer a 7ots server (and why not, when it can't). */
|
|
689
|
+
function hostedInfo(account) {
|
|
690
|
+
const enabled = account.vm_enabled === 1;
|
|
691
|
+
const configured = hostedConfigured();
|
|
692
|
+
const used = getDb().prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ} AND account_id = ?`).get(account.id).n;
|
|
693
|
+
const total = getDb().prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ}`).get().n;
|
|
694
|
+
const reason = !enabled ? 'notEnabled' : !configured ? 'notConfigured' : used >= PER_ACCOUNT() ? 'accountLimit' : total >= MAX_TOTAL() ? 'full' : null;
|
|
695
|
+
return { enabled, configured, available: enabled && configured, canCreate: !reason, reason, sizes: hostedSizes().filter((z) => z.id === defaultSize()), defaultSize: defaultSize(), region: penv.ORQUESTA_VM_REGION || null, used, max: PER_ACCOUNT() };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** POST /vm {provider:'orquesta', size?}: reserves the slot (quotas, atomically) and provisions in the background. */
|
|
699
|
+
async function createHosted(req, res, account, ots, body) {
|
|
700
|
+
const t = reqT(req);
|
|
701
|
+
if (account.vm_enabled !== 1) return send(res, 403, { error: t('platform.vm.paidOff') });
|
|
702
|
+
if (!hostedConfigured()) return send(res, 503, { error: t('platform.vm.paidUnavailable') });
|
|
703
|
+
const size = defaultSize();
|
|
704
|
+
let server;
|
|
705
|
+
try {
|
|
706
|
+
server = base(req);
|
|
707
|
+
bootstrapScript({ server, code: `7ve_${'x'.repeat(30)}` }); // validates the URL and SEVENOTS_VM_CLI before spending anything
|
|
708
|
+
} catch (e) {
|
|
709
|
+
console.warn('[7ots] vm create:', e.message);
|
|
710
|
+
return send(res, 503, { error: t('platform.vm.paidUnavailable') });
|
|
711
|
+
}
|
|
712
|
+
const region = penv.ORQUESTA_VM_REGION || '';
|
|
713
|
+
const code = `7ve_${token()}`;
|
|
714
|
+
const tm = now();
|
|
715
|
+
const price = hostedSizes().find((s) => s.id === size).monthlyPrice;
|
|
716
|
+
const err = tx((d) => {
|
|
717
|
+
const cur = d.prepare('SELECT * FROM ots_vms WHERE ots_id = ?').get(ots.id);
|
|
718
|
+
if (cur && cur.status !== 'terminated' && (cur.provider === 'orquesta' || cur.status === 'enrolled')) return [409, t('platform.vm.exists')];
|
|
719
|
+
if (inflight.has(ots.id)) return [409, t('platform.vm.exists')];
|
|
720
|
+
if (d.prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ} AND account_id = ?`).get(account.id).n >= PER_ACCOUNT()) return [403, t('platform.vm.accountLimit', { max: PER_ACCOUNT() })];
|
|
721
|
+
if (d.prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ}`).get().n >= MAX_TOTAL()) return [503, t('platform.vm.full')];
|
|
722
|
+
d.prepare(`INSERT INTO ots_vms (ots_id, account_id, provider, status, size, region, enroll_hash, enroll_exp, price_cents, created_at, updated_at) VALUES (?, ?, 'orquesta', 'creating', ?, ?, ?, ?, ?, ?, ?)
|
|
723
|
+
ON CONFLICT(ots_id) DO UPDATE SET account_id = excluded.account_id, provider = 'orquesta', status = 'creating', orq_project_id = '', orq_vm_id = '', orq_status = '', orq_checked_at = NULL, size = excluded.size, region = excluded.region,
|
|
724
|
+
ip = '', error = '', enroll_hash = excluded.enroll_hash, enroll_exp = excluded.enroll_exp, agent_hash = NULL, agent_seen_at = NULL, worker_version = '', worker_info = '{}', price_cents = excluded.price_cents, created_at = excluded.created_at, updated_at = excluded.updated_at`)
|
|
725
|
+
.run(ots.id, account.id, size, region, sha256(code), tm + ORQ_ENROLL_TTL(), price ? Math.round(price * 100) : null, tm, tm);
|
|
726
|
+
return null;
|
|
727
|
+
});
|
|
728
|
+
if (err) return send(res, err[0], { error: err[1] });
|
|
729
|
+
const job = provision(ots, { server, code, size, region }).finally(() => inflight.delete(ots.id));
|
|
730
|
+
inflight.set(ots.id, job);
|
|
731
|
+
return send(res, 202, { vm: vmView(vmRow(ots.id)), hosted: hostedInfo(account) });
|
|
732
|
+
}
|
|
733
|
+
|
|
356
734
|
/** @returns {Promise<boolean>} true if it served the route */
|
|
357
735
|
async function dashboard(req, res, account, ots, sub) {
|
|
358
736
|
if (sub !== '/vm' && !sub.startsWith('/vm/')) return false;
|
|
359
737
|
const t = reqT(req);
|
|
360
|
-
|
|
738
|
+
let vm = vmRow(ots.id);
|
|
361
739
|
if (sub === '/vm' && req.method === 'GET') {
|
|
362
740
|
reap(ots.id);
|
|
363
|
-
|
|
741
|
+
if (vm?.provider === 'orquesta') vm = await refreshHosted(ots.id);
|
|
742
|
+
return send(res, 200, { vm: vmView(vm), jobs: recentJobs(ots.id), kinds: otsVmKinds(ots), allKinds: VM_KINDS, daily: { used: jobsLastDay(ots.id), max: DAILY() }, paidVms: account.vm_enabled === 1, hosted: hostedInfo(account) }), true;
|
|
364
743
|
}
|
|
365
|
-
if (sub === '/vm/enroll' && req.method === 'POST') {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
if (provider === 'orquesta') return
|
|
369
|
-
if (provider !== 'byo') return send(res, 400, { error: t('server.http.badRequest') }), true;
|
|
744
|
+
if ((sub === '/vm' || sub === '/vm/enroll') && req.method === 'POST') {
|
|
745
|
+
const body = await readJson(req, 4096);
|
|
746
|
+
const provider = body.provider || (sub === '/vm' ? 'orquesta' : 'byo');
|
|
747
|
+
if (provider === 'orquesta') return await createHosted(req, res, account, ots, body), true;
|
|
748
|
+
if (provider !== 'byo' || sub !== '/vm/enroll') return send(res, 400, { error: t('server.http.badRequest') }), true;
|
|
749
|
+
// A 7ots server must be deleted first: re-enrolling would leave its VM running unattached.
|
|
750
|
+
if (vm?.provider === 'orquesta' && vm.status !== 'terminated') return send(res, 409, { error: t('platform.vm.exists') }), true;
|
|
370
751
|
const code = `7ve_${token()}`;
|
|
371
752
|
const tm = now();
|
|
372
753
|
if (vm) getDb().prepare("UPDATE ots_vms SET enroll_hash = ?, enroll_exp = ?, provider = 'byo', status = CASE WHEN status = 'terminated' THEN 'pending' ELSE status END, updated_at = ? WHERE ots_id = ?").run(sha256(code), tm + ENROLL_TTL, tm, ots.id);
|
|
@@ -375,12 +756,23 @@ export function createVm({ send, readJson, rateLimit, base }) {
|
|
|
375
756
|
}
|
|
376
757
|
if (sub === '/vm' && req.method === 'DELETE') {
|
|
377
758
|
if (!vm) return send(res, 200, { ok: true }), true;
|
|
378
|
-
|
|
759
|
+
const hosted = vm.provider === 'orquesta' && vm.status !== 'terminated';
|
|
760
|
+
getDb().prepare(`UPDATE ots_vms SET agent_hash = NULL, enroll_hash = NULL, enroll_exp = NULL, status = '${hosted ? 'terminating' : 'terminated'}', updated_at = ? WHERE ots_id = ?`).run(now(), ots.id);
|
|
379
761
|
for (const j of getDb().prepare("SELECT * FROM vm_jobs WHERE ots_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')").all(ots.id)) setStatus(j, 'cancelled', { error: 'worker revoked' });
|
|
380
|
-
return send(res, 200, { ok: true }), true;
|
|
762
|
+
if (!hosted) return send(res, 200, { ok: true }), true;
|
|
763
|
+
finishTeardown(ots.id); // background: VM, then project; reconciliation retries if Orquesta fails
|
|
764
|
+
return send(res, 202, { ok: true, vm: vmView(vmRow(ots.id)) }), true;
|
|
381
765
|
}
|
|
382
766
|
return send(res, 404, { error: t('server.http.unknownRoute') }), true;
|
|
383
767
|
}
|
|
384
768
|
|
|
385
|
-
|
|
769
|
+
// Reconciliation with Orquesta (every VM_RECONCILE_MIN, default 30), only when hosted VMs are configured.
|
|
770
|
+
if (hostedConfigured() && !createVm.timer) {
|
|
771
|
+
const run = () => reconcileVms().catch((e) => console.warn('[7ots] vm reconcile:', e.message));
|
|
772
|
+
createVm.timer = setInterval(run, RECONCILE_MS());
|
|
773
|
+
createVm.timer.unref?.();
|
|
774
|
+
setTimeout(run, Math.min(60_000, RECONCILE_MS())).unref?.();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return { worker, device, dashboard, releaseOts: releaseOtsVm, releaseAccount: releaseAccountVms, reconcile: reconcileVms };
|
|
386
778
|
}
|
package/server/tts.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* grok → xAI (POST https://api.x.ai/v1/tts). XAI_API_KEY, XAI_TTS_VOICE (eve, ara, leo, rex, sal o voz propia)
|
|
12
12
|
* fish → Fish Audio (POST https://api.fish.audio/v1/tts). FISH_API_KEY, FISH_VOICE_ID (reference_id;
|
|
13
13
|
* vacío = voz propia del modelo), FISH_MODEL (vacío = s2.1-pro; va en la cabecera `model`)
|
|
14
|
-
* openai → (compatibilidad) OPENAI_API_KEY, OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
|
|
14
|
+
* openai → (compatibilidad) OPENAI_API_KEY (solo si LLM_BASE_URL es OpenAI o está vacío; si no, OPENAI_TTS_API_KEY), OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
|
|
15
15
|
* none → 501: el widget usa la voz del navegador (speechSynthesis)
|
|
16
16
|
*
|
|
17
17
|
* La voz de la identidad del agente (server/identity.mjs → voice) manda sobre las variables:
|
|
@@ -39,10 +39,22 @@ export function ttsProvider(env = process.env) {
|
|
|
39
39
|
if (env.ELEVENLABS_API_KEY) return 'elevenlabs';
|
|
40
40
|
if (env.XAI_API_KEY) return 'grok';
|
|
41
41
|
if (env.FISH_API_KEY) return 'fish';
|
|
42
|
-
if (env
|
|
42
|
+
if (openaiTtsKey(env)) return 'openai';
|
|
43
43
|
return 'none';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* ¿OPENAI_API_KEY sirve para la voz de OpenAI? No si el LLM apunta a otro servicio compatible
|
|
48
|
+
* (LLM_BASE_URL = DeepSeek, Groq, OpenRouter…): esa clave es de ese servicio y OpenAI la rechazaría
|
|
49
|
+
* (antes: auto-detección → 'openai' → 502 en cada frase). OPENAI_TTS_API_KEY la fuerza.
|
|
50
|
+
*/
|
|
51
|
+
export function openaiTtsKey(env = process.env) {
|
|
52
|
+
if (env.OPENAI_TTS_API_KEY) return env.OPENAI_TTS_API_KEY;
|
|
53
|
+
if (!env.OPENAI_API_KEY) return '';
|
|
54
|
+
const base = String(env.LLM_BASE_URL || '').trim();
|
|
55
|
+
return !base || /^https?:\/\/api\.openai\.com(\/|$)/i.test(base) ? env.OPENAI_API_KEY : '';
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
export async function synthesize({ text, voice = {}, env = process.env }) {
|
|
47
59
|
const provider = PROVIDERS.includes(voice.provider) ? voice.provider : voice.provider === 'browser' ? 'none' : ttsProvider(env);
|
|
48
60
|
const input = String(text || '').slice(0, 1500);
|
|
@@ -120,8 +132,9 @@ export async function synthesize({ text, voice = {}, env = process.env }) {
|
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
if (provider === 'openai') {
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
const key = openaiTtsKey(env);
|
|
136
|
+
if (!key) throw i18nError(501, 'server.tts.noKey', { provider: 'OpenAI', key: env.OPENAI_API_KEY ? 'OPENAI_TTS_API_KEY' : 'OPENAI_API_KEY' });
|
|
137
|
+
const openai = await openaiClient(key); // TTS siempre contra OpenAI (no LLM_BASE_URL)
|
|
125
138
|
const model = voice.model || env.OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
|
|
126
139
|
const res = await openai.audio.speech.create({
|
|
127
140
|
model,
|
|
@@ -208,7 +208,7 @@ export function randomCharacter(seed = Math.random() * 1e9) {
|
|
|
208
208
|
return `#${[f(0), f(8), f(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
209
209
|
};
|
|
210
210
|
const groups = new Map();
|
|
211
|
-
for (const a of ACCESSORIES) if (a.group !== 'extra') groups.set(a.group, [...(groups.get(a.group) || []), a]);
|
|
211
|
+
for (const a of ACCESSORIES) if (a.group !== 'extra' && a.random !== false) groups.set(a.group, [...(groups.get(a.group) || []), a]);
|
|
212
212
|
const acc = [];
|
|
213
213
|
for (const [, list] of groups) if (rnd() < 0.35) acc.push({ id: pick(list).id });
|
|
214
214
|
return normalizeCharacter({
|