Skip to content
Open
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
1 change: 1 addition & 0 deletions .kilo/skills/kilo-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ exist as an org-scoped mirror under `organizations`, for example
- `codeIndexing`
- `codeReviews`
- `codingPlans`
- `computeBilling`
- `credits`
- `debug`
- `discord`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from '@jest/globals';
import { after, NextRequest } from 'next/server';
import { INTERNAL_API_SECRET } from '@kilocode/web-shared/lib/config.server';
import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper';
import { createTestOrganization } from '@kilocode/web-shared/tests/helpers/organization.helper';
import { sendBalanceAlertEmail } from '@kilocode/web-shared/lib/email';
import { dispatchLowBalancePush } from '@kilocode/web-shared/lib/notifications-worker-client';
import { POST } from './route';

jest.mock('next/server', () => ({ ...jest.requireActual('next/server'), after: jest.fn() }));

jest.mock('@kilocode/web-shared/lib/email', () => ({
...jest.requireActual('@kilocode/web-shared/lib/email'),
sendBalanceAlertEmail: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('@kilocode/web-shared/lib/notifications-worker-client', () => ({
dispatchLowBalancePush: jest.fn().mockResolvedValue(undefined),
}));

function request(body: unknown, secret = INTERNAL_API_SECRET) {
return new NextRequest('http://localhost/api/internal/organizations/compute-balance-alert', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Internal-Secret': secret },
body: JSON.stringify(body),
});
}

describe('internal organization compute balance alert', () => {
it('requires service authentication and a valid organization ID', async () => {
expect((await POST(request({ organizationId: crypto.randomUUID() }, 'wrong'))).status).toBe(
401
);
expect((await POST(request({ organizationId: 'invalid' }))).status).toBe(400);
expect(sendBalanceAlertEmail).not.toHaveBeenCalled();
});

it('uses the existing email and push delivery with configured recipients', async () => {
const user = await insertTestUser();
const org = await createTestOrganization('Compute alert recipients', user.id, 1_000_000, {
minimum_balance: 18,
minimum_balance_alert_email: [user.google_user_email],
});
expect((await POST(request({ organizationId: org.id }))).status).toBe(200);
expect(sendBalanceAlertEmail).not.toHaveBeenCalled();
const task = jest.mocked(after).mock.calls[0]?.[0];
expect(typeof task).toBe('function');
if (typeof task !== 'function') throw new Error('Missing deferred alert');
await task();
expect(sendBalanceAlertEmail).toHaveBeenCalledWith({
organizationId: org.id,
minimum_balance: 18,
to: [user.google_user_email],
});
expect(dispatchLowBalancePush).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: org.id,
recipientUserIds: [user.id],
minimumBalanceUsd: 18,
})
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { after, NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { organizations } from '@kilocode/db/schema';
import { db } from '@kilocode/web-shared/lib/drizzle';
import { sendOrganizationLowBalanceAlert } from '@kilocode/web-shared/lib/organizations/organization-usage';
import { toMicrodollars } from '@kilocode/web-shared/lib/microdollars';
import { authorizeInternalApiRequest } from '@/lib/internal-api-auth';

const bodySchema = z.object({ organizationId: z.uuid() }).strict();

export async function POST(request: NextRequest) {
const unauthorized = authorizeInternalApiRequest(request);
if (unauthorized) return unauthorized;
const body = bodySchema.safeParse(await request.json().catch(() => null));
if (!body.success) return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
const [organization] = await db
.select({ settings: organizations.settings })
.from(organizations)
.where(eq(organizations.id, body.data.organizationId))
.limit(1);
const minimum = organization?.settings?.minimum_balance;
if (minimum) {
after(() =>
sendOrganizationLowBalanceAlert(
body.data.organizationId,
toMicrodollars(minimum),
organization.settings?.minimum_balance_alert_email ?? []
)
);
}
return NextResponse.json({ ok: true });
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ describe('CloudAgentBillingError', () => {
presentation: personal,
});
expect(html).toContain('role="alert"');
expect(html).toContain('Your account needs more credits to start Cloud Agent compute.');
expect(html).toContain('Available: $1.25 · Need more than $2.00');
expect(html).toContain('Your prompt did not start.');
expect(html).toContain(
'Your account needs more credits for Cloud Agent compute. Add credits, then send a new message.'
);
expect(html).toContain('Available: $1.25 · Need at least $2.00');
expect(html).not.toContain('Your prompt did not start.');
expect(html).toContain('Add credits');
});

it.each([
['remainingMicrodollars', 'Available: $1.00'],
['minimumRequiredMicrodollars', 'Need more than $2.00'],
['minimumRequiredMicrodollars', 'Need at least $2.00'],
] as const)('renders %s independently', (key, text) => {
const html = renderBillingError({
failure: {
Expand All @@ -71,7 +73,7 @@ describe('CloudAgentBillingError', () => {
],
[
'BILLING_UNAVAILABLE',
'Cloud Agent cannot verify compute billing right now. Your prompt has not started and you have not been charged.',
'Cloud Agent cannot verify compute billing right now. Try again shortly.',
],
] as const)('renders exact %s core copy without an action', (code, copy) => {
const html = renderBillingError({
Expand Down Expand Up @@ -100,6 +102,6 @@ describe('CloudAgentBillingError', () => {
remainingMicrodollars: 5_000_000,
minimumRequiredMicrodollars: 5_000_000,
})
).toBe('Available: $5.00 · Need more than $5.00');
).toBe('Available: $5.00 · Need at least $5.00');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function billingBalanceCopy(failure: CustomerBillingFailure): string | nu
const required =
failure.minimumRequiredMicrodollars === undefined
? undefined
: `Need more than ${formatBillingMoney(failure.minimumRequiredMicrodollars)}`;
: `Need at least ${formatBillingMoney(failure.minimumRequiredMicrodollars)}`;
return [available, required].filter(Boolean).join(' · ') || null;
}

Expand All @@ -41,21 +41,20 @@ export function CloudAgentBillingError({ failure, presentation }: Props) {
const balanceCopy = billingBalanceCopy(failure);
const content =
failure.code === 'INSUFFICIENT_CREDITS'
? `${payerName} needs more credits to start Cloud Agent compute.`
? failure.payer.type === 'org'
? `${payerName} cannot cover Cloud Agent compute. Add credits or ask an owner or admin to check your daily spending limit, then send a new message.`
: `${payerName} needs more credits for Cloud Agent compute. Add credits, then send a new message.`
: failure.code === 'COMPUTE_STOPPING'
? 'Cloud Agent is saving and stopping compute. Your prompt has not started. Try again after shutdown completes.'
: 'Cloud Agent cannot verify compute billing right now. Your prompt has not started and you have not been charged.';
: 'Cloud Agent cannot verify compute billing right now. Try again shortly.';
return (
<Alert variant="destructive" className="min-w-0">
<AlertCircle className="size-4" aria-hidden="true" />
<AlertTitle>Compute billing</AlertTitle>
<AlertDescription className="space-y-2">
<p>{content}</p>
{failure.code === 'INSUFFICIENT_CREDITS' && (
<>
{balanceCopy && <p className="font-mono text-xs tabular-nums">{balanceCopy}</p>}
<p>Your prompt did not start.</p>
</>
<>{balanceCopy && <p className="font-mono text-xs tabular-nums">{balanceCopy}</p>}</>
)}
{action && (
<div className="flex flex-wrap items-center gap-2">
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/cloud-agent-next/ComputePrice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc/utils';
import type { SandboxDestination } from '@kilocode/worker-utils/sandbox-allocation';
import { formatComputePrice } from '@/lib/cloud-agent/compute-price';

export function ComputePrice({
destination,
label,
}: {
destination: SandboxDestination | undefined;
label?: string;
}) {
const trpc = useTRPC();
const native =
destination?.provider.id === 'cloudflare-containers' && destination.provider.account === 'kilo';
const rates = useQuery(trpc.computeBilling.rates.queryOptions(undefined, { enabled: native }));
if (!native) return null;
const rate = rates.data?.find(rate => rate.instance === destination.instanceType);
return (
<span className="text-muted-foreground whitespace-nowrap">
{label} ·{' '}
{rates.isLoading
? 'Loading price…'
: rate?.available === false
? 'Compute unavailable'
: formatComputePrice(rate?.hourlyUsd)}
</span>
);
}
9 changes: 7 additions & 2 deletions apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Badge } from '@/components/ui/badge';
import { getSandboxAllocationKey } from '@kilocode/worker-utils/sandbox-allocation';
import { resolveSandboxSelectionSubmissionError } from './sandbox-selection';
import { NewSessionSandboxSelect } from './NewSessionSandboxSelect';
import { ComputePrice } from './ComputePrice';
import { useSandboxPicker } from './useSandboxPicker';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MobileSidebarToggle } from './MobileSidebarToggle';
Expand Down Expand Up @@ -1756,15 +1757,19 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) {
</Popover>

<div className="flex w-full shrink-0 flex-wrap items-center gap-2 sm:w-auto">
{sandbox.isVisible && (
{sandbox.isVisible ? (
<NewSessionSandboxSelect
picker={sandbox.picker}
groups={sandbox.groups}
invalid={!!sandboxSelectionError}
describedBy={sandboxDescriptionId}
onChoose={sandbox.choose}
/>
)}
) : sandbox.picker.destination ? (
<ComputePrice destination={sandbox.picker.destination} label="Compute" />
) : sandbox.query.isError ? (
<span className="text-muted-foreground text-xs">Compute price unavailable</span>
) : null}
<ProfilePickerPopover
organizationId={organizationId}
selectedOverrideProfileId={selectedProfileId}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { getSandboxAllocationKey } from '@kilocode/worker-utils/sandbox-allocation';
import { ComputePrice } from './ComputePrice';
import {
Select,
SelectContent,
Expand Down Expand Up @@ -50,8 +51,11 @@ export function NewSessionSandboxSelect({
aria-invalid={invalid}
>
<SelectValue className="min-w-0">
<span className="truncate">
{formatSandboxDestinationWithoutTier(picker.destination)}
<span className="flex min-w-0 items-baseline">
<span className="truncate">
{formatSandboxDestinationWithoutTier(picker.destination)}
</span>
<ComputePrice destination={picker.destination} />
</span>
</SelectValue>
</SelectTrigger>
Expand All @@ -65,6 +69,7 @@ export function NewSessionSandboxSelect({
>
<span className="whitespace-normal tabular-nums">
{formatSandboxDestination(defaultDestination)}
<ComputePrice destination={defaultDestination} />
<DefaultMark />
</span>
</SelectItem>
Expand All @@ -86,6 +91,7 @@ export function NewSessionSandboxSelect({
>
<span className="whitespace-normal tabular-nums">
{formatSandboxInstance(option.allocation)}
<ComputePrice destination={option.allocation} />
{key === defaultKey && <DefaultMark />}
</span>
</SelectItem>
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/cloud-agent-next/SessionComputeRate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/lib/trpc/utils';
import { formatComputePrice } from '@/lib/cloud-agent/compute-price';

export function SessionComputeRate() {
const trpc = useTRPC();
const rates = useQuery(trpc.computeBilling.rates.queryOptions());
return (
<section className="space-y-2 rounded-lg border p-4 text-sm">
<h3 className="font-medium">Kilo compute rates</h3>
<dl className="grid grid-cols-2 gap-2 tabular-nums">
{(['standard-3', 'standard-4'] as const).map(instance => (
<div key={instance} className="contents">
<dt>{instance === 'standard-3' ? 'Medium' : 'Large'}</dt>
<dd className="text-right">
{formatComputePrice(rates.data?.find(rate => rate.instance === instance)?.hourlyUsd)}
</dd>
</div>
))}
</dl>
<p className="text-muted-foreground text-xs">
For Kilo Cloudflare Containers. Your own provider bills BYOC compute.
</p>
<p className="text-muted-foreground text-xs">
Compute includes idle time until the sandbox stops. Model usage is separate.
</p>
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type SessionCostBreakdown,
} from './session-cost-breakdown';
import { formatShortModelName } from '@/lib/format-model-name';
import { SessionComputeRate } from './SessionComputeRate';

type SessionInfoDialogProps = {
open: boolean;
Expand Down Expand Up @@ -86,6 +87,7 @@ export function SessionInfoDialog({
<DialogDescription>Usage and session details.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{open && <SessionComputeRate />}
{showTotalCost && (
<dl className="bg-muted/30 grid gap-2 rounded-lg border p-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
Expand Down
Loading