{"record":{"id":"61f0b565135fe904","repo":"nexu-io/open-design","slug":"openrouter-job-laststatus-reason","errorCode":null,"errorMessage":"openrouter job ${lastStatus}: ${reason}","messagePattern":"openrouter job (.+?): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/daemon/src/media/index.ts","lineNumber":2116,"sourceCode":"      onProgress(\n        `openrouter job ${jobId} status=${lastStatus || 'pending'} (elapsed ${elapsedSec}s)`,\n      );\n    }\n\n    if (lastStatus === 'completed') {\n      videoUrls = pollData?.unsigned_urls || null;\n      break;\n    }\n    if (\n      lastStatus === 'failed'\n      || lastStatus === 'expired'\n      || lastStatus === 'cancelled'\n    ) {\n      const reasonRaw =\n        pollData?.error?.message || pollData?.error || lastStatus;\n      const reason =\n        typeof reasonRaw === 'string' ? reasonRaw : JSON.stringify(reasonRaw);\n      throw new Error(`openrouter job ${lastStatus}: ${reason}`);\n    }\n  }\n\n  if (!videoUrls || videoUrls.length === 0) {\n    const elapsedSec = Math.round((Date.now() - startedAt) / 1000);\n    const ceilingSec = Math.round(maxMs / 1000);\n    throw new Error(\n      `openrouter video timed out after ${elapsedSec}s waiting for status=completed `\n      + `(last status: ${lastStatus || 'pending'}, ceiling ${ceilingSec}s). `\n      + `If your jobs legitimately need longer, raise OD_OPENROUTER_VIDEO_MAX_POLL_MS.`,\n    );\n  }\n\n  // ── Step 3: Download the video binary ──────────────────────────────\n  // unsigned_urls are often third-party CDNs where sending our API key\n  // would leak credentials. However, sometimes OpenRouter returns a proxied\n  // openrouter.ai URL that still requires authorization. We only attach the\n  // auth header if the host is explicitly allowlisted as openrouter.ai.","sourceCodeStart":2098,"sourceCodeEnd":2134,"githubUrl":"https://github.com/nexu-io/open-design/blob/5be4028344c2eb4c667c5a97bda8f750c5597ef7/apps/daemon/src/media/index.ts#L2098-L2134","documentation":"Thrown when pollData.status is one of the terminal failure states — 'failed', 'expired', or 'cancelled' — and the loop aborts immediately rather than continuing to poll. The message embeds the status and a reason string derived from pollData.error.message (preferred), pollData.error, or the bare status, so the operator sees why the underlying provider killed the job.","triggerScenarios":"Underlying video provider (Seedance, Kling, etc.) returned status=failed with an error detail, the job exceeded OpenRouter's TTL and went expired, the user/account cancelled the job out-of-band, or the provider rejected the input (e.g. disallowed content in the prompt or the i2v frame).","commonSituations":"Prompt tripped the underlying provider's safety policy, the source image for i2v was rejected, the job ran past OpenRouter's max lifetime, or a provider incident marked in-flight jobs as failed.","solutions":["Read the embedded reason — provider error messages are usually specific (content filter, invalid input, quota).","If content/safety related, reword ctx.prompt or change the source image and resubmit.","If 'expired', resubmit and consider a faster model or higher OD_OPENROUTER_VIDEO_MAX_POLL_MS so the daemon does not give up before the provider.","If 'cancelled' was unexpected, check whether another session or the OpenRouter dashboard cancelled the job.","On provider incidents, retry after the incident resolves."],"exampleFix":"// before\nconst reasonRaw = pollData?.error?.message || pollData?.error || lastStatus;\nconst reason = typeof reasonRaw === 'string' ? reasonRaw : JSON.stringify(reasonRaw);\nthrow new Error(`openrouter job ${lastStatus}: ${reason}`);\n\n// after — preserve structured error for telemetry, still throw readable text\nconst reasonRaw = pollData?.error?.message || pollData?.error || lastStatus;\nconst reason = typeof reasonRaw === 'string' ? reasonRaw : JSON.stringify(reasonRaw);\nconst err = new Error(`openrouter job ${lastStatus}: ${reason}`);\n(err as any).providerError = pollData?.error;\n(err as any).jobStatus = lastStatus;\nthrow err;","handlingStrategy":"try-catch","validationCode":"// Distinguish failure classes so the user gets actionable guidance\nfunction classifyOpenRouterVideoFailure(status: string, reason: string): { kind: string; message: string } {\n  if (/block|policy|safety|nsfw|content/i.test(reason)) {\n    return { kind: 'content-filter', message: `Video rejected by provider safety filter: ${reason}` };\n  }\n  if (status === 'expired') {\n    return { kind: 'expired', message: `Video job expired before completing: ${reason}` };\n  }\n  if (/quota|credit|billing|payment/i.test(reason)) {\n    return { kind: 'billing', message: `Video failed for billing reasons: ${reason}` };\n  }\n  return { kind: 'failed', message: `openrouter job ${status}: ${reason}` };\n}","typeGuard":"type OpenRouterTerminalStatus = 'failed' | 'expired' | 'cancelled';\nfunction isTerminalFailure(status: string): status is OpenRouterTerminalStatus {\n  return status === 'failed' || status === 'expired' || status === 'cancelled';\n}","tryCatchPattern":"if (isTerminalFailure(lastStatus)) {\n  const cls = classifyOpenRouterVideoFailure(lastStatus, reason);\n  const err = new Error(cls.message);\n  (err as any).failureKind = cls.kind;\n  (err as any).providerError = pollData?.error;\n  throw err;\n}","preventionTips":["Classify the failure reason (content filter / expired / billing) so the user gets targeted guidance.","Reword prompts or change the i2v source image on content-filter failures rather than retrying verbatim.","On 'expired', resubmit and consider a faster model or longer poll ceiling.","Preserve pollData.error on the thrown error for telemetry/support."],"tags":["openrouter","video-generation","async-polling","content-moderation","provider-api"],"backgroundTag":null,"analyzedSha":"5be4028344c2eb4c667c5a97bda8f750c5597ef7","analyzedAt":"2026-08-12T12:03:58.812Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}