{"record":{"id":"34713b5b634a6c8a","repo":"HeyPuter/puter","slug":"insufficient-funds-34713b","errorCode":"insufficient_funds","errorMessage":"Insufficient funds","messagePattern":"Insufficient funds","errorType":"http","errorClass":"HttpError","httpStatus":402,"severity":"error","filePath":"src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts","lineNumber":220,"sourceCode":"        const estimatedInputTokens = Math.max(1, Math.ceil(text.length / 4));\n        const wordCount = text.split(/\\s+/).length;\n        const estimatedDurationSec = Math.max(1, (wordCount / 150) * 60);\n        const estimatedOutputTokens = Math.ceil(estimatedDurationSec * 25);\n\n        const estimatedInputCostCents =\n            (estimatedInputTokens / 1_000_000) * costs.input;\n        const estimatedOutputCostCents =\n            (estimatedOutputTokens / 1_000_000) * costs.output_audio;\n        const estimatedTotalMicroCents = this.#toMicroCents(\n            estimatedInputCostCents + estimatedOutputCostCents,\n        );\n\n        const usageAllowed = await this.meteringService.hasEnoughCredits(\n            actor,\n            estimatedTotalMicroCents,\n        );\n        if (!usageAllowed) {\n            throw new HttpError(402, 'Insufficient funds', {\n                legacyCode: 'insufficient_funds',\n            });\n        }\n\n        // The TTS models require the text to be framed as a transcript\n        // to read aloud. Prefixing with \"Say:\" prevents the model from\n        // trying to generate conversational text instead of audio.\n        const inputText = instructions\n            ? `${instructions}\\n\\nSay the following text aloud:\\n${text}`\n            : `Say the following text aloud:\\n${text}`;\n\n        // Let Google GenAI `ApiError`s bubble — they carry `.status` and\n        // are mapped to `upstream_*` HttpErrors by the driver-boundary\n        // translator. Catching here and wrapping as 502 hid the upstream\n        // status and caused 4xx validation errors to page.\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const response: any = await this.#client.models.generateContent({\n            model,","sourceCodeStart":202,"sourceCodeEnd":238,"githubUrl":"https://github.com/HeyPuter/puter/blob/908ec23eda38526170322c3edf71ba45ecb1ca95/src/backend/drivers/ai-tts/providers/gemini/GeminiTTSProvider.ts#L202-L238","documentation":"Credit gate in GeminiTTSProvider.synthesize. It estimates input tokens (~chars/4) and output audio tokens (~150 wpm, 25 tokens/sec), converts to microcents via the model's input/output_audio rates, and rejects with HTTP 402 (legacyCode insufficient_funds) when meteringService.hasEnoughCredits(actor, estimate) is false. Because the estimate is heuristic, actual metered usage may differ.","triggerScenarios":"An actor whose balance is below the estimated combined input+output cost for the text on the chosen Gemini TTS model. Long text drives both input and output estimates up.","commonSituations":"Long narration on a Pro model exhausting a small balance; free-tier users; the estimate being conservative so a request is rejected even though actual cost would have fit.","solutions":["Top up the actor/workspace balance.","Shorten the text or split into smaller requests.","Use the Flash model (cheaper) instead of Pro.","Note the estimate is conservative; retry after a small top-up may succeed even for the same text."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Rough client-side estimate mirroring the server heuristic.\nfunction estimateGeminiUcents(text, modelCosts) {\n  const inputTokens = Math.max(1, Math.ceil(text.length / 4));\n  const words = text.split(/\\s+/).length;\n  const durSec = Math.max(1, (words / 150) * 60);\n  const outputTokens = Math.ceil(durSec * 25);\n  const centsIn = (inputTokens / 1_000_000) * modelCosts.input;\n  const centsOut = (outputTokens / 1_000_000) * modelCosts.output_audio;\n  return Math.ceil((centsIn + centsOut) * 1_000_000); // microcents\n}\n// Compare to balance; note the server estimate is conservative.","typeGuard":null,"tryCatchPattern":"try {\n  await driver.synthesize({ text, provider: 'gemini', model });\n} catch (e) {\n  if (e?.status === 402 || e?.fields?.legacyCode === 'insufficient_funds') {\n    // prompt top-up, shorten text, or switch Flash->same text after balance refresh\n  } else throw e;\n}","preventionTips":["Use the Flash model for long text; it's cheaper than Pro.","The estimate is conservative — a small top-up may unblock the same text.","Show an estimated cost in the UI before submission."],"tags":["gemini","tts","billing","credits","pre-flight"],"backgroundTag":null,"analyzedSha":"908ec23eda38526170322c3edf71ba45ecb1ca95","analyzedAt":"2026-08-12T20:53:15.911Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}