nexu-io/open-design · error · Error

openrouter video non-JSON: ${truncate(submitText, 200)}

Error message

openrouter video non-JSON: ${truncate(submitText, 200)}

What it means

Thrown when the video submit endpoint returned HTTP 2xx but JSON.parse failed on the body. OpenRouter's async video submit is contractually JSON (carrying id and polling_url); a non-JSON 2xx means an interceptor rewrote the response or baseUrl is misrouted to a host returning HTML.

Source

Thrown at apps/daemon/src/media/index.ts:2037

      'content-type': 'application/json',
      // OpenRouter attribution headers per
      // https://openrouter.ai/docs/app-attribution
      'HTTP-Referer': 'https://opendesign.dev',
      'X-Title': 'Open Design',
    },
    body: JSON.stringify(body),
  }));
  const submitText = await submitResp.text();
  if (!submitResp.ok) {
    throw new Error(
      `openrouter video submit ${submitResp.status}: ${truncate(submitText, 240)}`,
    );
  }
  let submitData: any;
  try {
    submitData = JSON.parse(submitText);
  } catch {
    throw new Error(`openrouter video non-JSON: ${truncate(submitText, 200)}`);
  }

  const jobId = submitData?.id;
  const pollingUrl = submitData?.polling_url;
  if (!jobId || !pollingUrl) {
    throw new Error(
      `openrouter video submit returned no job id or polling_url: ${truncate(submitText, 200)}`,
    );
  }

  // ── Step 2: Poll until completion ──────────────────────────────────
  const startedAt = Date.now();
  const configuredMaxMs = Number(process.env.OD_OPENROUTER_VIDEO_MAX_POLL_MS);
  const maxMs =
    Number.isFinite(configuredMaxMs) && configuredMaxMs >= 60_000
      ? configuredMaxMs
      : 30 * 60 * 1000; // 30 minutes default
  const configuredPollIntervalMs = Number(process.env.OD_OPENROUTER_VIDEO_POLL_INTERVAL_MS);

View on GitHub (pinned to 5be4028344)

Solutions

  1. Inspect the truncated body in the error — HTML/doctype tags indicate interception/misroute.
  2. Reset credentials.baseUrl to the default or remove it.
  3. Whitelist openrouter.ai through any HTTP proxy.
  4. Retry once for transient interstitials.

Example fix

// before
try { submitData = JSON.parse(submitText); }
catch { throw new Error(`openrouter video non-JSON: ${truncate(submitText, 200)}`); }

// after — distinguish HTML interception
try { submitData = JSON.parse(submitText); }
catch {
  const html = /<html|<!doctype/i.test(submitText);
  throw new Error(
    `openrouter video ${html ? 'returned HTML (interception?)' : 'non-JSON'}: ${truncate(submitText, 200)}`,
  );
}
Defensive patterns

Strategy: validation

Validate before calling

function looksLikeHtmlBody(text: string): boolean {
  return /<html|<!doctype|<title>/i.test(text.slice(0, 200));
}

if (looksLikeHtmlBody(submitText)) {
  throw new Error(`openrouter video submit returned HTML (proxy/interception or wrong baseUrl): ${truncate(submitText, 200)}`);
}

Type guard

function isJsonContentType(resp: Response): boolean {
  const ct = resp.headers.get('content-type') || '';
  return ct.includes('application/json') || ct.includes('+json');
}

Try / catch

try {
  submitData = JSON.parse(submitText);
} catch {
  if (looksLikeHtmlBody(submitText)) {
    throw new Error(`openrouter video returned HTML (interception?): ${truncate(submitText, 200)}`);
  }
  throw new Error(`openrouter video non-JSON: ${truncate(submitText, 200)}`);
}

Prevention

When it happens

Trigger: Proxy/CDN served an HTML page with 200, baseUrl points at a wrong host, or a captive portal intercepted the request. The guard stops the subsequent id/polling_url extraction from running on non-JSON input.

Common situations: Corporate proxy blocking openrouter.ai with an HTML block page, baseUrl typo hitting a parked/marketing domain, or a transient CDN interstitial.

Related errors


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/cdfedee0e1a06c9d. Report an issue: GitHub.