Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
}
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/lib/ai-gateway/usage-limit-error.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 21 additions & 0 deletions apps/web/src/lib/ai-gateway/usage-limit-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 4 additions & 1 deletion apps/web/src/lib/bot/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/bot/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down