nexu-io/open-design · error · Error
leonardo.ai non-JSON
Error message
leonardo.ai non-JSON: ${truncate(submitText, 200)} What it means
Thrown when Leonardo.ai's POST /generations returned 2xx but the body failed JSON.parse. This means the server returned a 2xx status with a non-JSON payload (HTML error page, plain text, empty body). The message includes the first 200 chars (truncated) of whatever came back so the operator can identify the content shape.
Solutions
- Inspect the truncated body in the error — HTML/doctype indicates a gateway or maintenance page; retry shortly.
- If you set a custom baseUrl in provider config, confirm it points at the JSON API root (…/api/rest/v1) and not at the marketing site or a console URL.
- If a Cloudflare challenge is persistent, route the daemon through a network egress that is not flagged, or contact Leonardo support.
- Retry with exponential backoff — interstitials are usually transient.
Example fix
// before — wrong baseUrl points at HTML site credentials.baseUrl = 'https://leonardo.ai' // → 200 OK with HTML → [465] // after credentials.baseUrl = 'https://cloud.leonardo.ai/api/rest/v1'
Defensive patterns
Strategy: try-catch
Validate before calling
// Reject a misconfigured baseUrl before it ever produces HTML responses.
function assertLeonardoBaseUrl(baseUrl?: string): void {
if (!baseUrl) return; // default is fine
try {
const u = new URL(baseUrl);
if (!u.pathname.includes('/api/rest')) {
throw new Error(`Leonardo baseUrl looks like an HTML surface (${baseUrl}); expected .../api/rest/v1`);
}
} catch (e) { throw e; }
} Try / catch
// Distinguish parse failure from real HTTP failure so the operator gets the right hint.
let submitData: any;
try {
submitData = JSON.parse(submitText);
} catch {
// If it's HTML, this is almost certainly a gateway/maintenance page — retryable.
const looksLikeHtml = /^\s*<(?:!doctype|html|body)/i.test(submitText);
throw new Error(
`leonardo.ai non-JSON (${looksLikeHtml ? 'html/gateway' : 'unknown'}): ${truncate(submitText, 200)}`,
);
} Prevention
- Validate baseUrl shape (must include /api/rest) when the user saves provider config.
- Use JSON-only accept headers (`Accept: application/json`) to discourage gateways from serving HTML.
- In staging, run a smoke render after any Leonardo config change to catch HTML-gateway regressions early.
When it happens
Trigger: submitResp.ok is true but JSON.parse(submitText) throws — Leonardo returns 200 with an HTML maintenance page, a Cloudflare interstitial, an empty string, or a plain-text error. Distinct from [464] which fires on non-2xx status.
Common situations: Cloudflare/WAF challenge page served with 200; Leonardo API gateway returning a cached HTML error; proxy or reverse-proxy in front of the API rewriting the response; baseUrl misconfigured to point at a non-API host that serves HTML.
Related errors
- grok video non-JSON
- grok poll non-JSON
- leonardo.ai generation timed out after 2 minutes
- no Leonardo.ai API key — configure it in Settings or set…
- unsupported leonardo.ai model
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/e82d53efc01fdb8e.
Report an issue: GitHub.
Appendix: source
Thrown at apps/daemon/src/media/index.ts:2241
const submitResp = await fetch(`${baseUrl}/generations`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
}));
const submitText = await submitResp.text();
if (!submitResp.ok) {
throw new Error(`leonardo.ai submit ${submitResp.status}: ${truncate(submitText, 240)}`);
}
let submitData: any;
try {
submitData = JSON.parse(submitText);
} catch {
throw new Error(`leonardo.ai non-JSON: ${truncate(submitText, 200)}`);
}
const generationId = submitData?.sdGenerationJob?.generationId;
if (!generationId) {
throw new Error('leonardo.ai response missing generationId');
}
// Poll for completion
const maxPollMs = 120000; // 2 minutes
const pollIntervalMs = 2000; // 2 seconds
const startedAt = Date.now();
let imageUrl: string | null = null;
while (Date.now() - startedAt < maxPollMs) {
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
const pollResp = await fetch(`${baseUrl}/generations/${generationId}`, withMediaRequestInit(ctx, {
headers: {View on GitHub (pinned to 5be4028344)