All files / web/src/lib/session-song classify-failure.ts

97.74% Statements 130/133
89.28% Branches 50/56
100% Functions 1/1
97.74% Lines 130/133

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 1341x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 13x 13x 5x 8x 8x   13x 13x 13x 13x 13x 12x 12x 12x 13x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x   1x 1x 12x 12x 12x 12x 10x 9x 9x 9x 9x 13x 3x 3x 3x 3x 3x 1x 2x 3x 1x 2x 3x 3x 9x 9x 9x 9x 9x 8x 6x 6x 6x 5x 5x 5x 5x 5x 13x 4x 4x 3x 3x 3x 4x 4x 4x 4x 2x 2x 4x 2x 2x 4x 4x 5x 5x 13x 2x 2x 2x 2x 2x 2x 2x 3x 3x 3x 3x 2x 2x 2x 2x 2x 2x 13x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x  
import type { SessionSongFailureKind } from '@/db/schema/session-songs'
 
export interface ClassifiedSongFailure {
  kind: SessionSongFailureKind
  /** Kid-safe one-liner — shown to all viewers regardless of role. */
  userMessage: string
  /** Owner/admin-actionable description — shown only to account owners and admins. */
  ownerMessage: string
  /** Where the owner/admin should go to remediate, if applicable. */
  remediation: { label: string; href: string } | null
}
 
const KID_SAFE_MESSAGE = "Couldn't make a song this time. A grown-up will help."
 
export function classifySongFailure(rawError: unknown): ClassifiedSongFailure {
  const message =
    rawError instanceof Error
      ? rawError.message
      : typeof rawError === 'string'
        ? rawError
        : String(rawError ?? '')
  const lower = message.toLowerCase()
 
  // Missing server configuration — usually an env var omitted from app-env.
  if (
    lower.includes('environment variable is not set') ||
    lower.includes('missing env') ||
    lower.includes('missing environment variable') ||
    lower.includes('elevenlabs_music_api_key')
  ) {
    const isElevenLabs =
      lower.includes('elevenlabs') || lower.includes('eleven_labs') || lower.includes('music_api')
    const envName = isElevenLabs ? 'ELEVENLABS_MUSIC_API_KEY' : 'LLM_OPENAI_API_KEY'
    return {
      kind: 'missing_config',
      userMessage: KID_SAFE_MESSAGE,
      ownerMessage: isElevenLabs
        ? `Missing ElevenLabs Music API key. Set ${envName} in the app-env Kubernetes secret.`
        : `Missing OpenAI API key. Set ${envName} in the app-env Kubernetes secret.`,
      remediation: isElevenLabs
        ? { label: 'ElevenLabs API keys', href: 'https://elevenlabs.io/app/settings/api-keys' }
        : { label: 'OpenAI API keys', href: 'https://platform.openai.com/account/api-keys' },
    }
  }
 
  // Auth failures — OpenAI / ElevenLabs return 401 with consistent strings.
  if (
    lower.includes('401') ||
    lower.includes('incorrect api key') ||
    lower.includes('invalid api key') ||
    lower.includes('invalid_api_key') ||
    lower.includes('unauthorized') ||
    lower.includes('authentication failed')
  ) {
    const isElevenLabs = lower.includes('elevenlabs') || lower.includes('eleven_labs')
    return {
      kind: 'auth_invalid',
      userMessage: KID_SAFE_MESSAGE,
      ownerMessage: isElevenLabs
        ? 'The ElevenLabs Music API key is invalid. Update ELEVENLABS_MUSIC_API_KEY in the app-env Kubernetes secret.'
        : 'The OpenAI API key is invalid. Update LLM_OPENAI_API_KEY in the app-env Kubernetes secret.',
      remediation: isElevenLabs
        ? { label: 'ElevenLabs API keys', href: 'https://elevenlabs.io/app/settings/api-keys' }
        : { label: 'OpenAI API keys', href: 'https://platform.openai.com/account/api-keys' },
    }
  }
 
  // Quota / credits exhausted — 402 / explicit credit messages.
  if (
    lower.includes('402') ||
    lower.includes('insufficient_quota') ||
    lower.includes('insufficient credits') ||
    lower.includes('insufficient credit') ||
    lower.includes('quota exceeded') ||
    lower.includes('exceeded your current quota') ||
    lower.includes('exceeded your quota') ||
    lower.includes('out of credits') ||
    lower.includes('character limit') ||
    lower.includes('not enough credits') ||
    lower.includes('payment required')
  ) {
    const isElevenLabs =
      lower.includes('elevenlabs') ||
      lower.includes('eleven_labs') ||
      lower.includes('character limit') ||
      lower.includes('credits')
    return {
      kind: 'quota_exceeded',
      userMessage: KID_SAFE_MESSAGE,
      ownerMessage: isElevenLabs
        ? 'ElevenLabs credits are exhausted. Top up your ElevenLabs account.'
        : 'OpenAI quota is exhausted. Top up your OpenAI account.',
      remediation: isElevenLabs
        ? { label: 'ElevenLabs billing', href: 'https://elevenlabs.io/app/subscription' }
        : { label: 'OpenAI billing', href: 'https://platform.openai.com/account/billing' },
    }
  }
 
  // Rate limiting — 429 with retry-after.
  if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
    return {
      kind: 'rate_limited',
      userMessage: "Couldn't make a song right now. Try again in a few minutes.",
      ownerMessage: 'Hit a provider rate limit. Should clear on its own; retry in a few minutes.',
      remediation: null,
    }
  }
 
  // Transient — network errors, 5xx, timeouts.
  if (
    lower.includes('etimedout') ||
    lower.includes('econnreset') ||
    lower.includes('econnrefused') ||
    lower.includes('enotfound') ||
    lower.includes('socket hang up') ||
    lower.includes('timeout') ||
    /\b5\d\d\b/.test(lower) // any 5xx status
  ) {
    return {
      kind: 'transient',
      userMessage: "Couldn't make a song this time. Try again later.",
      ownerMessage: 'Transient network or upstream error. Usually clears up on its own.',
      remediation: null,
    }
  }
 
  return {
    kind: 'unknown',
    userMessage: KID_SAFE_MESSAGE,
    ownerMessage: `Unclassified error: ${message.slice(0, 200)}`,
    remediation: null,
  }
}