Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions LifeOS/install/LIFEOS/TOOLS/Doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirS
import { join, basename } from 'path';
import { createHash, randomBytes } from 'crypto';
import { homedir } from "node:os";
import { elevenLabsMessage } from './lib/ElevenLabsError';

const HOME = process.env.HOME ?? process.env.USERPROFILE ?? homedir();
const CONFIG_ROOT = process.env.CLAUDE_CONFIG_DIR || join(HOME, '.claude');
Expand Down Expand Up @@ -80,11 +81,16 @@ interface CapSpec {
// Returns null when the capability has no local configuration at all —
// network probing it would be pre-consent egress (never allowed).
configured: () => boolean;
probeOffline: () => Promise<{ ok: boolean; detail: string }>;
probeNetwork?: () => Promise<{ ok: boolean; detail: string }>;
probeOffline: () => Promise<ProbeResult>;
probeNetwork?: () => Promise<ProbeResult>;
fixCmd: string;
}

// A probe may override the capability's static fixCmd when it has diagnosed a
// specific cause whose remedy differs (e.g. an exhausted vendor quota is not a
// misconfigured voice id). Absent → the CapSpec's fixCmd applies.
interface ProbeResult { ok: boolean; detail: string; fixCmd?: string }

// ── helpers ──────────────────────────────────────────────────────────────────

async function run(cmd: string[], timeoutMs = PROBE_TIMEOUT_MS): Promise<{ code: number; out: string }> {
Expand Down Expand Up @@ -441,14 +447,33 @@ const CAPS: CapSpec[] = [
clearTimeout(timer);
if (res.ok) return { ok: true, detail: 'TTS round-trip OK (real synthesis on the notification path)' };
const errText = (await res.text()).slice(0, 200);
// Monthly character quota exhausted — the key and voice are fine, and
// swapping the voice id does nothing. Surface ElevenLabs' own message
// (it names the quota and remaining credits) and point at the plan.
if (errText.includes('quota_exceeded')) {
const msg = elevenLabsMessage(errText) ?? 'monthly character quota exceeded';
return {
ok: false,
detail: `ElevenLabs quota exceeded — ${msg}`,
fixCmd: 'wait for the ElevenLabs monthly quota reset, or add credits / upgrade the plan at elevenlabs.io (key and voice id are fine)',
};
}
if (errText.includes('famous_voice_not_permitted')) {
return { ok: false, detail: 'configured voice is a famous voice — not usable via API TTS' };
return {
ok: false,
detail: 'configured voice is a famous voice — not usable via API TTS',
fixCmd: 'set ELEVENLABS_VOICE_ID to a premade or cloned voice in <configRoot>/.env (famous voices are blocked on the API)',
};
}
// Plan-tier restriction, not quota (public issue #1496, @waveman2020-sudo):
// free-tier keys 402 with paid_plan_required on ANY library voice — swapping
// voices or waiting for quota reset does not fix it.
if (errText.includes('paid_plan_required')) {
return { ok: false, detail: 'free-tier ElevenLabs plan cannot use library voices via API — upgrade the plan or use a premade/cloned voice' };
return {
ok: false,
detail: 'free-tier ElevenLabs plan cannot use library voices via API',
fixCmd: 'upgrade the ElevenLabs plan, or set ELEVENLABS_VOICE_ID to a premade/cloned voice in <configRoot>/.env',
};
}
return { ok: false, detail: `TTS failed (${res.status}): ${errText.slice(0, 120)}` };
} catch {
Expand Down Expand Up @@ -715,7 +740,7 @@ async function probeAll(network: boolean): Promise<Manifest> {
m.capabilities[cap.id] = {
state: res.ok ? 'live' : 'broken',
checkedAt: new Date().toISOString(), ttlHours: cap.ttlHours,
detail: res.detail, fixCmd: res.ok ? null : cap.fixCmd, probeClass,
detail: res.detail, fixCmd: res.ok ? null : (res.fixCmd ?? cap.fixCmd), probeClass,
};
}
saveManifest(m);
Expand Down
34 changes: 34 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* ElevenLabsError contract tests.
*
* Doctor's ElevenLabs probe truncates the error body to 200 characters before
* reading it, so the parser must survive cut-off JSON, not just well-formed
* JSON. A non-JSON body (proxy HTML, empty) must yield null so the caller
* falls back to its own wording.
*
* Run: bun test LIFEOS/TOOLS/lib/ElevenLabsError.test.ts
*/
import { describe, expect, test } from 'bun:test';
import { elevenLabsMessage } from './ElevenLabsError';

describe('elevenLabsMessage', () => {
test('reads detail.message from a complete body', () => {
const body = '{"detail":{"type":"invalid_request","code":"quota_exceeded","message":"over by 1 credit"}}';
expect(elevenLabsMessage(body)).toBe('over by 1 credit');
});

test('reads the message from a body truncated mid-string', () => {
const body = '{"detail":{"type":"invalid_request","code":"quota_exceeded","message":"This request exceeds your quota of 34292. You hav';
expect(elevenLabsMessage(body)).toBe('This request exceeds your quota of 34292. You hav');
});

test('returns null for a non-JSON body', () => {
expect(elevenLabsMessage('<html><body>502 Bad Gateway</body></html>')).toBeNull();
expect(elevenLabsMessage('')).toBeNull();
});

test('returns null when detail.message is missing or empty', () => {
expect(elevenLabsMessage('{"detail":{"code":"quota_exceeded","message":""}}')).toBeNull();
expect(elevenLabsMessage('{"detail":"plain string"}')).toBeNull();
});
});
18 changes: 18 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* ElevenLabsError.ts — read the human message out of an ElevenLabs error body.
*
* ElevenLabs answers a failed request with
* `{"detail":{"type":"…","code":"quota_exceeded","message":"…"}}`. Doctor's
* TTS probe keeps only the first 200 characters of that body, so a long
* message arrives as cut-off JSON. This helper returns the message either
* way, or null when the body is not that shape (HTML from a proxy, empty).
*/
export function elevenLabsMessage(body: string): string | null {
try {
const msg = JSON.parse(body)?.detail?.message;
return typeof msg === 'string' && msg.length > 0 ? msg : null;
} catch {
const m = body.match(/"message"\s*:\s*"([^"]*)/);
return m?.[1] || null;
}
}