From 10c781cf161faa5428e7c979126ada765c3a1835 Mon Sep 17 00:00:00 2001 From: hjbrandt <34381881+hjbrandt@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:32:51 +0200 Subject: [PATCH] Doctor: ElevenLabs probe reports the diagnosed cause and its remedy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the ElevenLabs TTS probe failed with quota_exceeded, Doctor printed the generic "set ELEVENLABS_VOICE_ID to an API-permitted voice" fix. The key and voice were fine; the monthly character quota was simply used up. The hint sent the operator to the wrong place. A probe result may now carry its own fixCmd, and probeAll prefers it over the capability's static one. All three diagnosed ElevenLabs causes use it: - quota_exceeded (new branch): detail shows ElevenLabs' own message (quota size, credits remaining); fix points at the plan or the monthly reset. - famous_voice_not_permitted: fix says to pick a premade/cloned voice. - paid_plan_required: fix says to upgrade the plan or pick a premade voice. The error-body parser lives in lib/ElevenLabsError.ts so it can be tested without running the CLI. It handles the 200-char truncation the probe applies to the body (a long message arrives as cut-off JSON) and returns null for non-JSON bodies so the caller falls back to its own wording. Verified on a real quota-exhausted account: bun Doctor.ts --network now prints "ElevenLabs quota exceeded — This request exceeds your quota of 34292. You have 0 credits remaining, ..." with the plan fix beneath it. Co-Authored-By: Claude Fable 5.1 --- LifeOS/install/LIFEOS/TOOLS/Doctor.ts | 35 ++++++++++++++++--- .../LIFEOS/TOOLS/lib/ElevenLabsError.test.ts | 34 ++++++++++++++++++ .../LIFEOS/TOOLS/lib/ElevenLabsError.ts | 18 ++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.test.ts create mode 100644 LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.ts diff --git a/LifeOS/install/LIFEOS/TOOLS/Doctor.ts b/LifeOS/install/LIFEOS/TOOLS/Doctor.ts index 9ac4d38f97..07a64d3aae 100644 --- a/LifeOS/install/LIFEOS/TOOLS/Doctor.ts +++ b/LifeOS/install/LIFEOS/TOOLS/Doctor.ts @@ -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'); @@ -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; + probeNetwork?: () => Promise; 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 }> { @@ -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 /.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 /.env', + }; } return { ok: false, detail: `TTS failed (${res.status}): ${errText.slice(0, 120)}` }; } catch { @@ -715,7 +740,7 @@ async function probeAll(network: boolean): Promise { 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); diff --git a/LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.test.ts b/LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.test.ts new file mode 100644 index 0000000000..2cedef85db --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.test.ts @@ -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('502 Bad Gateway')).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(); + }); +}); diff --git a/LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.ts b/LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.ts new file mode 100644 index 0000000000..89ad773ddd --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/lib/ElevenLabsError.ts @@ -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; + } +}