From bfa01c171773ff480aa4b47c08c5cdb62851abb8 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Tue, 11 Aug 2026 10:55:47 +0200 Subject: [PATCH] fix(web): handle bot credit errors at source --- .../[botRequestId]/route.ts | 15 +++++ .../lib/ai-gateway/usage-limit-error.test.ts | 58 +++++++++++++++++++ .../src/lib/ai-gateway/usage-limit-error.ts | 21 +++++++ apps/web/src/lib/bot/agent-runner.ts | 5 +- apps/web/src/lib/bot/run.ts | 5 +- 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/ai-gateway/usage-limit-error.test.ts create mode 100644 apps/web/src/lib/ai-gateway/usage-limit-error.ts diff --git a/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts b/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts index bc544ed1f7..26c9f897c7 100644 --- a/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts +++ b/apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts @@ -24,6 +24,7 @@ import { } from '@/lib/bot/request-logging'; import { parseBotCallbackStep } from '@/lib/bot/step-budget'; import { runBotAgent, type BotAgentMessageLike } from '@/lib/bot/agent-runner'; +import { getKiloUsageLimitErrorMessage } from '@/lib/ai-gateway/usage-limit-error'; import { botPlatforms } from '@/lib/bot/platforms'; import { getPlatformIntegrationById } from '@/lib/bot/platform-helpers'; import { findUserById } from '@/lib/user'; @@ -991,6 +992,20 @@ export async function POST( botRequestId, status: payload.status, }); + } catch (error) { + const usageLimitMessage = getKiloUsageLimitErrorMessage(error); + if (!usageLimitMessage) { + throw error; + } + + workComplete = await failBotRequestForCallbackProcessingError({ + botRequestId, + platformIntegration, + thread, + startedAt, + errorMessage: usageLimitMessage, + logMessage: 'Bot continuation stopped because user credits were exhausted', + }); } finally { await stopIndicator({ handedOff: !workComplete }); } diff --git a/apps/web/src/lib/ai-gateway/usage-limit-error.test.ts b/apps/web/src/lib/ai-gateway/usage-limit-error.test.ts new file mode 100644 index 0000000000..bc174bd627 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/usage-limit-error.test.ts @@ -0,0 +1,58 @@ +import { APICallError } from 'ai'; +import { getKiloUsageLimitErrorMessage } from './usage-limit-error'; + +function apiCallError(statusCode: number, responseBody: string): APICallError { + return new APICallError({ + message: 'API call failed', + url: 'https://app.kilo.ai/api/openrouter/chat/completions', + requestBodyValues: {}, + statusCode, + responseBody, + }); +} + +describe('getKiloUsageLimitErrorMessage', () => { + test('returns the message for a Kilo usage-limit response', () => { + const error = apiCallError( + 402, + JSON.stringify({ + error_type: 'usage_limit_exceeded', + error: { message: 'Add credits to continue, or switch to a free model' }, + }) + ); + + expect(getKiloUsageLimitErrorMessage(error)).toBe( + 'Add credits to continue, or switch to a free model' + ); + }); + + test('does not classify other 402 responses as Kilo usage-limit errors', () => { + const error = apiCallError( + 402, + JSON.stringify({ error: { message: 'Upstream provider balance exhausted' } }) + ); + + expect(getKiloUsageLimitErrorMessage(error)).toBeNull(); + }); + + test('does not classify a usage-limit response with another status', () => { + const error = apiCallError( + 503, + JSON.stringify({ + error_type: 'usage_limit_exceeded', + error: { message: 'Add credits to continue' }, + }) + ); + + expect(getKiloUsageLimitErrorMessage(error)).toBeNull(); + }); + + test.each([ + new Error('network failure'), + apiCallError(402, 'not-json'), + apiCallError(402, JSON.stringify({ error_type: 'usage_limit_exceeded', error: {} })), + undefined, + ])('does not classify malformed or unrelated errors', error => { + expect(getKiloUsageLimitErrorMessage(error)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/ai-gateway/usage-limit-error.ts b/apps/web/src/lib/ai-gateway/usage-limit-error.ts new file mode 100644 index 0000000000..128d788bde --- /dev/null +++ b/apps/web/src/lib/ai-gateway/usage-limit-error.ts @@ -0,0 +1,21 @@ +import { APICallError } from 'ai'; +import * as z from 'zod'; +import { ProxyErrorType } from '@/lib/proxy-error-types'; + +const usageLimitResponseSchema = z.object({ + error_type: z.literal(ProxyErrorType.usage_limit_exceeded), + error: z.object({ message: z.string().min(1) }), +}); + +export function getKiloUsageLimitErrorMessage(error: unknown): string | null { + if (!APICallError.isInstance(error) || error.statusCode !== 402 || !error.responseBody) { + return null; + } + + try { + const result = usageLimitResponseSchema.safeParse(JSON.parse(error.responseBody)); + return result.success ? result.data.error.message : null; + } catch { + return null; + } +} diff --git a/apps/web/src/lib/bot/agent-runner.ts b/apps/web/src/lib/bot/agent-runner.ts index 30a6f3ecd8..7638564038 100644 --- a/apps/web/src/lib/bot/agent-runner.ts +++ b/apps/web/src/lib/bot/agent-runner.ts @@ -25,6 +25,7 @@ import { getGitLabRepositoryContext, } from '@/lib/slack-bot/gitlab-repository-context'; import { isFreeModel } from '@/lib/ai-gateway/is-free-model'; +import { getKiloUsageLimitErrorMessage } from '@/lib/ai-gateway/usage-limit-error'; import { generateApiToken } from '@/lib/tokens'; import { captureException } from '@sentry/nextjs'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; @@ -164,7 +165,9 @@ async function postSessionLinkEphemeral(params: { const summary = await summarizePrompt(params.provider, params.modelSlug, params.prompt); if (summary) description = `Cloud Agent session started: ${summary}`; } catch (error) { - captureException(error, { tags: { component: 'kilo-bot', op: 'summarize-prompt' } }); + if (!getKiloUsageLimitErrorMessage(error)) { + captureException(error, { tags: { component: 'kilo-bot', op: 'summarize-prompt' } }); + } } params.thread diff --git a/apps/web/src/lib/bot/run.ts b/apps/web/src/lib/bot/run.ts index 29cfec3e98..2d7449b1b5 100644 --- a/apps/web/src/lib/bot/run.ts +++ b/apps/web/src/lib/bot/run.ts @@ -2,6 +2,7 @@ import { createBotRequest, updateBotRequest } from '@/lib/bot/request-logging'; import { runBotAgent } from '@/lib/bot/agent-runner'; import { extractAndUploadAttachments } from '@/lib/bot/attachments'; import { botPlatforms } from '@/lib/bot/platforms'; +import { getKiloUsageLimitErrorMessage } from '@/lib/ai-gateway/usage-limit-error'; import type { PlatformIntegration, User } from '@kilocode/db'; import type { Message, Thread } from 'chat'; import { captureException } from '@sentry/nextjs'; @@ -122,7 +123,9 @@ async function processMessage({ return result.startedCloudAgentSession; } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); + const errMsg = + getKiloUsageLimitErrorMessage(error) ?? + (error instanceof Error ? error.message : String(error)); updateBotRequest(botRequestId, { status: 'error',