diff --git a/apps/web/src/app/(app)/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx b/apps/web/src/app/(app)/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx index e2689192ec..76755e46bb 100644 --- a/apps/web/src/app/(app)/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx +++ b/apps/web/src/app/(app)/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx @@ -3,12 +3,12 @@ import { Suspense } from 'react'; import { CloudChatPage } from '@/components/cloud-agent-next/CloudChatPage'; -export function CloudChatPageWrapperNext() { +export function CloudChatPageWrapperNext({ currentUserId }: { currentUserId: string }) { return ( Loading...} > - + ); } diff --git a/apps/web/src/app/(app)/cloud/(agent)/chat/page.tsx b/apps/web/src/app/(app)/cloud/(agent)/chat/page.tsx index 694a926584..5fe8f46eb3 100644 --- a/apps/web/src/app/(app)/cloud/(agent)/chat/page.tsx +++ b/apps/web/src/app/(app)/cloud/(agent)/chat/page.tsx @@ -8,11 +8,11 @@ type PageProps = { }; export default async function PersonalCloudChatPage({ searchParams }: PageProps) { - await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/cloud/chat'); + const user = await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/cloud/chat'); const { sessionId } = await searchParams; if (!sessionId || isNewSession(sessionId)) { - return ; + return ; } return ; diff --git a/apps/web/src/app/(app)/cloud/(agent)/page.tsx b/apps/web/src/app/(app)/cloud/(agent)/page.tsx index df3f2ecb39..2d7b72ba62 100644 --- a/apps/web/src/app/(app)/cloud/(agent)/page.tsx +++ b/apps/web/src/app/(app)/cloud/(agent)/page.tsx @@ -9,5 +9,7 @@ export default async function PersonalCloudPage() { user.id ); - return ; + return ( + + ); } diff --git a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx index ab0cce4d7b..9b0ac7f316 100644 --- a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/CloudChatPageWrapperNext.tsx @@ -2,17 +2,31 @@ import { Suspense } from 'react'; import { CloudChatPage } from '@/components/cloud-agent-next/CloudChatPage'; +import type { OrganizationRole } from '@/lib/organizations/organization-types'; type CloudChatPageWrapperNextProps = { organizationId: string; + organizationName?: string; + organizationRole: OrganizationRole; + currentUserId: string; }; -export function CloudChatPageWrapperNext({ organizationId }: CloudChatPageWrapperNextProps) { +export function CloudChatPageWrapperNext({ + organizationId, + organizationName, + organizationRole, + currentUserId, +}: CloudChatPageWrapperNextProps) { return ( Loading...} > - + ); } diff --git a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/page.tsx b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/page.tsx index 1c55cc22d4..6caa92be87 100644 --- a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/chat/page.tsx @@ -25,7 +25,14 @@ export default async function OrganizationCloudChatPage({ params, searchParams } const { sessionId } = await searchParams; if (!sessionId || isNewSession(sessionId)) { - return ; + return ( + + ); } return ; diff --git a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/page.tsx b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/page.tsx index a370830a4b..eb08b5f714 100644 --- a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/page.tsx @@ -10,7 +10,7 @@ export default async function OrganizationCloudPage({ }) { const { id } = await params; const organizationId = decodeURIComponent(id); - await getUserFromAuthOrRedirect( + const user = await getUserFromAuthOrRedirect( `/users/sign_in?callbackPath=${encodeURIComponent(`/organizations/${organizationId}/cloud`)}` ); const isDevcontainerAvailable = await isFeatureFlagEnabledOrDevelopment( @@ -21,9 +21,12 @@ export default async function OrganizationCloudPage({ return ( ( + render={({ organization, role }) => ( )} diff --git a/apps/web/src/components/cloud-agent-next/ChatHeader.test.ts b/apps/web/src/components/cloud-agent-next/ChatHeader.test.ts new file mode 100644 index 0000000000..d84f3b0dce --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/ChatHeader.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from '@jest/globals'; +import { computeBillingLabel, computeBillingRefetchInterval } from './ChatHeader'; + +const active = { + billingMode: 'paid' as const, + phase: 'active' as const, + estimatedHourlyRateMicrodollars: null, + estimatedIntervalAmountMicrodollars: null, +}; + +describe('computeBillingLabel', () => { + it.each([ + ['payer_shared', 'Shared compute so far · pricing unavailable'], + ['session', 'Compute so far · pricing unavailable'], + ] as const)('does not show $0.00 when active %s pricing is unavailable', (attribution, label) => { + expect(computeBillingLabel({ ...active, attribution })).toBe(label); + }); +}); + +describe('computeBillingRefetchInterval', () => { + it('stops polling while idle and resumes when the live session becomes active', () => { + expect(computeBillingRefetchInterval(false, 'idle')).toBe(false); + expect(computeBillingRefetchInterval(true, 'idle')).toBe(5_000); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/ChatHeader.tsx b/apps/web/src/components/cloud-agent-next/ChatHeader.tsx index 7e93115c57..16c7548a1c 100644 --- a/apps/web/src/components/cloud-agent-next/ChatHeader.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatHeader.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -15,6 +16,56 @@ import { SessionActionsDialog } from './SessionActionsDialog'; import { SoundToggleButton } from '@/components/shared/SoundToggleButton'; import { FeedbackDialog } from './FeedbackDialog'; import { buildRepoBrowseUrl, detectGitPlatform } from './utils/git-utils'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +function formatRate(microdollars: number | null): string { + return microdollars === null + ? 'pricing unavailable' + : `$${(microdollars / 1_000_000).toFixed(2)}/hour`; +} + +export function computeBillingLabel( + computeStatus: + | { + billingMode: 'shadow' | 'paid' | null; + phase: 'idle' | 'active' | 'stopping' | 'settling' | 'unavailable'; + attribution: 'payer_shared' | 'session'; + estimatedHourlyRateMicrodollars: number | null; + estimatedIntervalAmountMicrodollars: number | null; + } + | null + | undefined +): string | null { + if (computeStatus?.billingMode === 'shadow') { + return `Compute est. ${formatRate(computeStatus.estimatedHourlyRateMicrodollars)} · Not currently charged`; + } + if (computeStatus?.phase === 'active') { + const prefix = + computeStatus.attribution === 'payer_shared' ? 'Shared compute so far' : 'Compute so far'; + return computeStatus.estimatedIntervalAmountMicrodollars === null + ? `${prefix} · pricing unavailable` + : `${prefix} · est. $${(computeStatus.estimatedIntervalAmountMicrodollars / 1_000_000).toFixed(2)}`; + } + if (computeStatus?.phase === 'stopping' || computeStatus?.phase === 'settling') { + return 'Saving and stopping compute'; + } + if (computeStatus?.phase === 'idle') { + return computeStatus.estimatedHourlyRateMicrodollars === null + ? 'Compute pricing unavailable' + : `Compute est. ${formatRate(computeStatus.estimatedHourlyRateMicrodollars)}`; + } + return null; +} + +export function computeBillingRefetchInterval( + sessionActive: boolean, + phase: 'idle' | 'active' | 'stopping' | 'settling' | 'unavailable' | undefined +): number | false { + return sessionActive || phase === 'active' || phase === 'stopping' || phase === 'settling' + ? 5_000 + : false; +} type ChatHeaderProps = { cloudAgentSessionId: string; @@ -25,10 +76,11 @@ type ChatHeaderProps = { gitUrl?: string | null; model?: string; modelDisplayName?: string; - totalCost?: number; + tokenUsage?: number; soundEnabled?: boolean; onToggleSound?: () => void; sessionTitle?: string; + sessionActive: boolean; }; export function ChatHeader({ @@ -38,15 +90,36 @@ export function ChatHeader({ gitUrl, model = 'Unknown', modelDisplayName, - totalCost = 0, + tokenUsage = 0, soundEnabled = true, onToggleSound, kiloSessionId, organizationId, sessionTitle, + sessionActive, }: ChatHeaderProps) { const [showInfoDialog, setShowInfoDialog] = useState(false); const [showActionsDialog, setShowActionsDialog] = useState(false); + const trpc = useTRPC(); + const computeQuery = useQuery({ + ...(organizationId + ? trpc.organizations.cloudAgentNext.getComputeBillingStatus.queryOptions({ + organizationId, + cloudAgentSessionId, + }) + : trpc.cloudAgentNext.getComputeBillingStatus.queryOptions({ cloudAgentSessionId })), + enabled: cloudAgentSessionId.startsWith('agent_'), + refetchInterval: query => { + return computeBillingRefetchInterval(sessionActive, query.state.data?.phase); + }, + }); + const wasSessionActive = useRef(sessionActive); + useEffect(() => { + if (sessionActive && !wasSessionActive.current) void computeQuery.refetch(); + wasSessionActive.current = sessionActive; + }, [computeQuery.refetch, sessionActive]); + const computeStatus = computeQuery.data; + const computeLabel = computeBillingLabel(computeStatus); const browseUrl = buildRepoBrowseUrl(gitUrl); const repoUrl = @@ -63,7 +136,8 @@ export function ChatHeader({ kiloSessionId={kiloSessionId} model={model} modelDisplayName={modelDisplayName} - cost={totalCost * 1_000_000} + tokenUsageMicrodollars={tokenUsage * 1_000_000} + computeStatus={computeStatus} /> -
+
+ {computeLabel && ( + + + + {computeLabel} + + + +

{computeLabel}

+

+ {computeStatus?.attribution === 'payer_shared' + ? 'Based on how long the shared sandbox has run. It may include other sessions. Final after it stops.' + : 'Based on how long this sandbox has run. Final after it stops.'} +

+
+
+ )} {onToggleSound && ( )} diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts new file mode 100644 index 0000000000..f2116788ab --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from '@jest/globals'; +import { createElement, type ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) })); + +import { + billingBalanceCopy, + CloudAgentBillingError, + currentPaymentReturnPath, +} from './CloudAgentBillingError'; + +type BillingErrorProps = ComponentProps; + +const personal = { payerName: 'Your account', action: { href: '/credits', label: 'Add credits' } }; +const orgMember = { + payerName: 'Acme Engineering', + action: { + href: '/organizations/org-1/payment-details', + label: 'View organization billing', + memberGuidance: true, + }, +}; + +function renderBillingError(props: BillingErrorProps): string { + return renderToStaticMarkup(createElement(CloudAgentBillingError, props)); +} + +describe('CloudAgentBillingError', () => { + it('renders an accessible personal 402 with both balances and action', () => { + const html = renderBillingError({ + failure: { + code: 'INSUFFICIENT_CREDITS', + payer: { type: 'user', id: 'u1' }, + retryable: false, + remainingMicrodollars: 1_250_000, + minimumRequiredMicrodollars: 2_000_000, + }, + 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('Add credits'); + }); + + it.each([ + ['remainingMicrodollars', 'Available: $1.00'], + ['minimumRequiredMicrodollars', 'Need more than $2.00'], + ] as const)('renders %s independently', (key, text) => { + const html = renderBillingError({ + failure: { + code: 'INSUFFICIENT_CREDITS', + payer: { type: 'org', id: 'org-1' }, + retryable: false, + [key]: key === 'remainingMicrodollars' ? 1_000_000 : 2_000_000, + }, + presentation: orgMember, + }); + expect(html).toContain(text); + expect(html).toContain('Acme Engineering'); + expect(html).toContain('View organization billing'); + expect(html).toContain('An organization owner, admin, or billing manager can add credits.'); + }); + + it.each([ + [ + 'COMPUTE_STOPPING', + 'Cloud Agent is saving and stopping compute. Your prompt has not started. Try again after shutdown completes.', + ], + [ + 'BILLING_UNAVAILABLE', + 'Cloud Agent cannot verify compute billing right now. Your prompt has not started and you have not been charged.', + ], + ] as const)('renders exact %s core copy without an action', (code, copy) => { + const html = renderBillingError({ + failure: { code, payer: { type: 'user', id: 'u1' }, retryable: true }, + presentation: { payerName: 'Your account' }, + }); + expect(html).toContain(copy); + expect(html).not.toContain('href='); + }); + + it('keeps the complete query string for a payment return', () => { + expect( + currentPaymentReturnPath({ + pathname: '/cloud/chat', + search: '?sessionId=ses-1&tab=chat', + } as Location) + ).toBe('/cloud/chat?sessionId=ses-1&tab=chat'); + }); + + it('makes an equal balance threshold unambiguous', () => { + expect( + billingBalanceCopy({ + code: 'INSUFFICIENT_CREDITS', + payer: { type: 'user', id: 'u1' }, + retryable: false, + remainingMicrodollars: 5_000_000, + minimumRequiredMicrodollars: 5_000_000, + }) + ).toBe('Available: $5.00 · Need more than $5.00'); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx new file mode 100644 index 0000000000..d441294c1f --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import type { CustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { AlertCircle } from 'lucide-react'; +import type { BillingPayerPresentation } from './billing-payer-presentation'; +import { setReturnUrlAndRedirect } from '@/components/shared/InsufficientBalanceBanner.actions'; + +type Props = { + failure: CustomerBillingFailure; + presentation: BillingPayerPresentation; +}; + +export function formatBillingMoney(value: number): string { + return `$${(value / 1_000_000).toFixed(2)}`; +} + +export function currentPaymentReturnPath(location: Pick): string { + return `${location.pathname}${location.search}`; +} + +export function billingBalanceCopy(failure: CustomerBillingFailure): string | null { + const available = + failure.remainingMicrodollars === undefined + ? undefined + : `Available: ${formatBillingMoney(failure.remainingMicrodollars)}`; + const required = + failure.minimumRequiredMicrodollars === undefined + ? undefined + : `Need more than ${formatBillingMoney(failure.minimumRequiredMicrodollars)}`; + return [available, required].filter(Boolean).join(' · ') || null; +} + +export function CloudAgentBillingError({ failure, presentation }: Props) { + const router = useRouter(); + const [redirecting, setRedirecting] = useState(false); + const { payerName, action } = presentation; + const balanceCopy = billingBalanceCopy(failure); + const content = + failure.code === 'INSUFFICIENT_CREDITS' + ? `${payerName} needs more credits to start Cloud Agent compute.` + : 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.'; + return ( + + + ); +} diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index df7f8a8456..692e18da63 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -29,6 +29,9 @@ import { PermissionCard, PermissionContextProvider } from './PermissionCard'; import { SuggestionContextProvider } from './SuggestionCard'; import { SessionContinuationPanel } from './SessionContinuationPanel'; import { CloudAgentTerminalPane } from './CloudAgentTerminalDock'; +import { CloudAgentBillingError } from './CloudAgentBillingError'; +import { billingPayerPresentation } from './billing-payer-presentation'; +import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs'; import { CHAT_TAB_ID, @@ -177,7 +180,12 @@ DynamicMessages.displayName = 'DynamicMessages'; // --------------------------------------------------------------------------- const emptyQuestionRequestIds = new Map(); -type CloudChatPageProps = { organizationId?: string }; +type CloudChatPageProps = { + currentUserId?: string; + organizationId?: string; + organizationName?: string; + organizationRole?: OrganizationRole; +}; type TerminalStatusSummary = { status: TerminalStatus; statusText: string }; @@ -213,7 +221,12 @@ function TerminalPaneSlot({ ); } -export default function CloudChatPage({ organizationId }: CloudChatPageProps) { +export default function CloudChatPage({ + currentUserId, + organizationId, + organizationName, + organizationRole, +}: CloudChatPageProps) { const manager = useManager(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); @@ -250,6 +263,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const supportsAttachments = useAtomValue(manager.atoms.supportsAttachments); const canSend = useAtomValue(manager.atoms.canSend); const statusIndicator = useAtomValue(manager.atoms.statusIndicator); + const billingFailure = useAtomValue(manager.atoms.billingFailure); const sessionConfig = useAtomValue(manager.atoms.sessionConfig); const sessionId = useAtomValue(manager.atoms.sessionId); const activity = useAtomValue(manager.atoms.activity); @@ -799,9 +813,10 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { gitUrl={fetchedSessionData?.gitUrl} model={sessionConfig?.model} modelDisplayName={modelDisplayName} - totalCost={totalCost} + tokenUsage={totalCost} soundEnabled={soundEnabled} onToggleSound={handleToggleSound} + sessionActive={isStreaming || activity.type === 'busy' || activity.type === 'retrying'} /> ); @@ -828,7 +843,10 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { title={fetchedSessionData?.title || sessionConfig?.repository || 'Cloud Agent'} > {totalCost > 0 && ( - ${totalCost.toFixed(4)} + + Token Usage{' '} + ${totalCost.toFixed(4)} + )} {showChatInterface ? ( @@ -895,9 +913,11 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { isStreaming={isStreaming} /> )} - {visibleStatusIndicator && ( - - )} + {!billingFailure && + visibleStatusIndicator && + visibleStatusIndicator.type !== 'error' && ( + + )}
@@ -956,6 +976,24 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
)}
+ {billingFailure && ( +
+ +
+ )} + {!billingFailure && statusIndicator?.type === 'error' && ( +
+ +
+ )} {(sessionConfig?.repository || (contextUsage !== undefined && contextWindow !== undefined)) && (
diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index 7b59cbec0a..170f4bfb23 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -70,6 +70,11 @@ import { } from '@/components/cloud-agent-next/utils/git-utils'; import type { AgentMode } from './types'; import { formatSessionError } from '@kilocode/cloud-agent-sdk'; +import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; +import type { CustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; +import { CloudAgentBillingError } from './CloudAgentBillingError'; +import { billingPayerPresentation } from './billing-payer-presentation'; +import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; import { useCloudAgentAttachmentUpload } from '@/hooks/useCloudAgentAttachmentUpload'; import { AttachmentPreviewStrip } from './AttachmentPreviewStrip'; @@ -114,7 +119,10 @@ type Repository = { }; type NewSessionPanelProps = { + currentUserId: string; organizationId?: string; + organizationName?: string; + organizationRole?: OrganizationRole; isDevcontainerAvailable: boolean; }; @@ -125,7 +133,13 @@ type ContextualTipProps = { onDismiss: () => void; }; -export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: NewSessionPanelProps) { +export function NewSessionPanel({ + currentUserId, + organizationId, + organizationName, + organizationRole, + isDevcontainerAvailable, +}: NewSessionPanelProps) { const router = useRouter(); const trpc = useTRPC(); const trpcClient = useRawTRPCClient(); @@ -202,6 +216,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New const [isRepoUserSelected, setIsRepoUserSelected] = useState(false); const [showRepositoryRequiredMessage, setShowRepositoryRequiredMessage] = useState(false); const [isPreparing, setIsPreparing] = useState(false); + const [billingFailure, setBillingFailure] = useState(null); const [attachmentMessageUuid, setAttachmentMessageUuid] = useState(() => uuidv4()); // Repo profile bindings are only keyed by GitHub/GitLab today. const profileBindingPlatform: Exclude | undefined = @@ -1087,11 +1102,14 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; router.push(`${basePath}/chat?sessionId=${result.kiloSessionId}`); + setBillingFailure(null); } catch (error) { + const failure = parseCustomerBillingFailure(error); + setBillingFailure(failure); console.error('Failed to prepare session:', error); - toast.error('Failed to create session', { - description: formatSessionError(error), - }); + if (!failure) { + toast.error('Failed to create session', { description: formatSessionError(error) }); + } } finally { setIsPreparing(false); } @@ -1218,7 +1236,19 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
- {/* Insufficient balance banner */} + {billingFailure && ( + + )} + {/* Organization members intentionally see the shared balance: the existing eligibility API returns it to members. */} {hasInsufficientBalance && eligibilityData && !hasLimitedAccess && ( @@ -81,10 +84,43 @@ export function SessionInfoDialog({
- ${costInDollars.toFixed(4)} + ${tokenUsageDollars.toFixed(4)} +
+
+
+ +
+ {computeStatus?.phase === 'unavailable' || !computeStatus ? ( + Compute status unavailable + ) : computeStatus.estimatedHourlyRateMicrodollars === null ? ( + Compute pricing unavailable + ) : ( + <> +
+ Est. ${(computeStatus.estimatedHourlyRateMicrodollars / 1_000_000).toFixed(2)}{' '} + / hour +
+

+ Billed only while the sandbox runs. +

+ {computeStatus.billingMode === 'shadow' &&

Not currently charged

} +

+ {computeStatus.attribution === 'payer_shared' + ? 'The estimate may include other sessions using this shared sandbox.' + : 'The estimate is based on this sandbox’s runtime.'} +

+ {computeStatus.phase === 'stopping' || computeStatus.phase === 'settling' ? ( +

+ Saving and stopping. Final cost is confirmed after it stops. +

+ ) : null} + + )}
diff --git a/apps/web/src/components/cloud-agent-next/billing-payer-presentation.test.ts b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.test.ts new file mode 100644 index 0000000000..533e037239 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from '@jest/globals'; +import { billingPayerPresentation } from './billing-payer-presentation'; + +const failure = { + code: 'INSUFFICIENT_CREDITS' as const, + payer: { type: 'org' as const, id: 'org-1' }, + retryable: false, +}; + +describe('billingPayerPresentation', () => { + it('uses the actual organization name and billing-manager action', () => { + expect( + billingPayerPresentation(failure, { + organization: { id: 'org-1', name: 'Long Organization Name', role: 'billing_manager' }, + }) + ).toEqual({ + payerName: 'Long Organization Name', + action: { href: '/organizations/org-1', label: 'Add organization credits' }, + }); + }); + + it('gives ordinary members the billing-details action and guidance', () => { + expect( + billingPayerPresentation(failure, { + organization: { id: 'org-1', name: 'Acme', role: 'member' }, + }) + ).toEqual({ + payerName: 'Acme', + action: { + href: '/organizations/org-1/payment-details', + label: 'View organization billing', + memberGuidance: true, + }, + }); + }); + + it('does not expose an action when the payer differs from the authorized surface', () => { + expect( + billingPayerPresentation(failure, { + organization: { id: 'org-2', name: 'Other', role: 'owner' }, + }) + ).toEqual({ payerName: 'This organization' }); + }); + + it('uses the personal credits action only on the personal surface', () => { + expect( + billingPayerPresentation( + { ...failure, payer: { type: 'user', id: 'user-1' } }, + { currentUserId: 'user-1' } + ) + ).toEqual({ payerName: 'Your account', action: { href: '/credits', label: 'Add credits' } }); + }); + + it('does not expose a personal payment action for another payer', () => { + expect( + billingPayerPresentation( + { ...failure, payer: { type: 'user', id: 'other-user' } }, + { currentUserId: 'user-1' } + ) + ).toEqual({ payerName: 'Your account' }); + }); + + it.each(['COMPUTE_STOPPING', 'BILLING_UNAVAILABLE'] as const)( + 'does not offer credit recovery for %s', + code => { + expect( + billingPayerPresentation( + { ...failure, code }, + { organization: { id: 'org-1', name: 'Acme', role: 'billing_manager' } } + ) + ).toEqual({ payerName: 'Acme' }); + expect( + billingPayerPresentation( + { ...failure, code, payer: { type: 'user', id: 'user-1' } }, + { currentUserId: 'user-1' } + ) + ).toEqual({ payerName: 'Your account' }); + } + ); +}); diff --git a/apps/web/src/components/cloud-agent-next/billing-payer-presentation.ts b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.ts new file mode 100644 index 0000000000..76efde4928 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/billing-payer-presentation.ts @@ -0,0 +1,46 @@ +import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations'; +import type { OrganizationRole } from '@/lib/organizations/organization-types'; +import type { CustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; + +export type BillingPayerPresentation = { + payerName: string; + action?: { href: string; label: string; memberGuidance?: boolean }; +}; + +export function billingPayerPresentation( + failure: CustomerBillingFailure, + surface: { + currentUserId?: string; + organization?: { id: string; name: string; role: OrganizationRole }; + } +): BillingPayerPresentation { + const canRecoverWithCredits = failure.code === 'INSUFFICIENT_CREDITS'; + if ( + failure.payer.type === 'user' && + !surface.organization && + failure.payer.id === surface.currentUserId + ) { + return { + payerName: 'Your account', + ...(canRecoverWithCredits ? { action: { href: '/credits', label: 'Add credits' } } : {}), + }; + } + const organization = surface.organization; + if (!organization || failure.payer.type !== 'org' || failure.payer.id !== organization.id) { + return { payerName: failure.payer.type === 'org' ? 'This organization' : 'Your account' }; + } + return { + payerName: organization.name, + ...(canRecoverWithCredits + ? { + action: canManageOrganizationBilling(organization.role) + ? { href: `/organizations/${organization.id}`, label: 'Add organization credits' } + : { + href: `/organizations/${organization.id}/payment-details`, + label: 'View organization billing', + memberGuidance: true, + }, + } + : {}), + }; +} diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts index 6475870789..a869ca5f45 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts @@ -8,6 +8,7 @@ import type { Images } from '@/lib/images-schema'; import { getEnvVariable } from '@/lib/dotenvx'; import { captureException } from '@sentry/nextjs'; import { INTERNAL_API_SECRET } from '@/lib/config.server'; +import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; import type { SendMessagePayload } from './types.js'; export type { SendMessagePayload } from './types.js'; @@ -374,6 +375,23 @@ export type HealthOutput = { version: string; }; +export type ComputeBillingStatus = { + payer: { type: 'user' | 'org'; id: string }; + attribution: 'payer_shared' | 'session'; + phase: 'idle' | 'active' | 'stopping' | 'settling' | 'unavailable'; + estimatedHourlyRateMicrodollars: number | null; + estimatedIntervalAmountMicrodollars: number | null; + billingMode: 'shadow' | 'paid' | null; + interval: { + id: string; + startedAt: string; + lastSeenAt: string; + stoppedAt: string | null; + confirmedSeconds: number; + settledBillableSeconds: number; + } | null; +}; + /** * Custom error class for payment-related errors from cloud-agent. */ @@ -387,10 +405,32 @@ export class InsufficientCreditsError extends Error { } } +export class CloudAgentBillingError extends Error { + constructor( + readonly billingFailure: NonNullable>, + readonly httpStatus: 402 | 409 | 503 + ) { + super('Cloud Agent compute billing request was not accepted'); + this.name = 'CloudAgentBillingError'; + } +} + /** * Helper to re-throw InsufficientCreditsError as TRPCError with PAYMENT_REQUIRED code. */ export function rethrowAsPaymentRequired(error: unknown): never { + if (error instanceof CloudAgentBillingError) { + throw new TRPCError({ + code: + error.httpStatus === 402 + ? 'PAYMENT_REQUIRED' + : error.httpStatus === 409 + ? 'CONFLICT' + : 'SERVICE_UNAVAILABLE', + message: error.message, + cause: { billingFailure: error.billingFailure }, + }); + } if (error instanceof InsufficientCreditsError) { throw new TRPCError({ code: 'PAYMENT_REQUIRED', @@ -404,6 +444,7 @@ export function rethrowAsPaymentRequired(error: unknown): never { * Check if an error indicates insufficient credits (402 Payment Required). */ function isInsufficientCreditsError(err: unknown): boolean { + if (parseCustomerBillingFailure(err)) return false; if (err instanceof TRPCClientError) { const httpStatus = err.data?.httpStatus || err.shape?.data?.httpStatus; if (httpStatus === 402) { @@ -420,6 +461,19 @@ function isInsufficientCreditsError(err: unknown): boolean { return false; } +function preserveBillingFailure(error: unknown): void { + const billingFailure = parseCustomerBillingFailure(error); + if (!billingFailure) return; + const status = + error instanceof TRPCClientError + ? (error.data?.httpStatus ?? error.shape?.data?.httpStatus) + : undefined; + throw new CloudAgentBillingError( + billingFailure, + status === 402 || status === 409 || status === 503 ? status : 503 + ); +} + function normalizeCloudAgentProtocolError(error: unknown): unknown { if (!(error instanceof Error)) { return error; @@ -454,6 +508,9 @@ type CloudAgentNextTRPCClient = { getSession: { query: (input: GetSessionInput) => Promise; }; + getComputeBillingStatus: { + query: (input: GetSessionInput) => Promise; + }; prepareSession: { mutate: (input: PrepareSessionInput) => Promise; }; @@ -625,6 +682,10 @@ export class CloudAgentNextClient { } } + async getComputeBillingStatus(cloudAgentSessionId: string): Promise { + return await this.client.getComputeBillingStatus.query({ cloudAgentSessionId }); + } + /** * Prepare a new cloud agent session. */ @@ -647,6 +708,7 @@ export class CloudAgentNextClient { return result; } catch (error) { const normalizedError = normalizeCloudAgentProtocolError(error); + preserveBillingFailure(normalizedError); console.log('[CloudAgentNextClient.prepareSession] Request failed', { elapsed: Date.now() - startTime, @@ -699,6 +761,7 @@ export class CloudAgentNextClient { return await this.client.initiateFromKilocodeSessionV2.mutate(input); } catch (error) { const normalizedError = normalizeCloudAgentProtocolError(error); + preserveBillingFailure(normalizedError); // Check for insufficient credits error if (isInsufficientCreditsError(normalizedError)) { @@ -727,6 +790,7 @@ export class CloudAgentNextClient { return await this.client.sendMessageV2.mutate(input); } catch (error) { const normalizedError = normalizeCloudAgentProtocolError(error); + preserveBillingFailure(normalizedError); // Check for insufficient credits error if (isInsufficientCreditsError(normalizedError)) { diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts index fd7b09d8cf..ff2b732e1d 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.test.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -24,6 +24,29 @@ const verificationRouter = t.router({ cause: new UpstreamApiError('etag_mismatch'), }); }), + computeBillingFailure: t.procedure + .input(z.enum(['PAYMENT_REQUIRED', 'CONFLICT', 'SERVICE_UNAVAILABLE'])) + .query(({ input }) => { + const billingFailureByCode = { + PAYMENT_REQUIRED: 'INSUFFICIENT_CREDITS', + CONFLICT: 'COMPUTE_STOPPING', + SERVICE_UNAVAILABLE: 'BILLING_UNAVAILABLE', + } as const; + throw new TRPCError({ + code: input, + message: 'Compute is stopping', + cause: { + billingFailure: { + code: billingFailureByCode[input], + payer: { type: 'org', id: 'org-1' }, + retryable: true, + }, + }, + }); + }), + legacyPaymentFailure: t.procedure.query(() => { + throw new TRPCError({ code: 'PAYMENT_REQUIRED', message: 'Insufficient credits' }); + }), }); async function callVerificationProcedure(path: string, input?: unknown): Promise { @@ -251,3 +274,29 @@ describe('generateTrpcOpenApiDocument', () => { expect(upstreamFailure.error.data.upstreamCode).toBe('etag_mismatch'); }); }); + +describe('browser tRPC billing error boundary', () => { + it.each([ + ['PAYMENT_REQUIRED', 'INSUFFICIENT_CREDITS', 402], + ['CONFLICT', 'COMPUTE_STOPPING', 409], + ['SERVICE_UNAVAILABLE', 'BILLING_UNAVAILABLE', 503], + ] as const)('projects explicit %s billing failures', async (code, billingCode, httpStatus) => { + const response = TrpcErrorResponseSchema.parse( + await callVerificationProcedure('computeBillingFailure', code) + ); + expect(response.error.data.httpStatus).toBe(httpStatus); + expect(response.error.data.billingFailure).toEqual({ + code: billingCode, + payer: { type: 'org', id: 'org-1' }, + retryable: true, + }); + }); + + it('does not mark a legacy generic 402 as structured billing failure', async () => { + const response = TrpcErrorResponseSchema.parse( + await callVerificationProcedure('legacyPaymentFailure') + ); + expect(response.error.data.httpStatus).toBe(402); + expect(response.error.data.billingFailure).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/trpc/transport.ts b/apps/web/src/lib/trpc/transport.ts index 6ab7e37076..02b3dd01d4 100644 --- a/apps/web/src/lib/trpc/transport.ts +++ b/apps/web/src/lib/trpc/transport.ts @@ -1,4 +1,5 @@ import type { TRPCDefaultErrorShape, TRPCErrorFormatter } from '@trpc/server'; +import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; import * as z from 'zod'; type JsonSchema = Record; @@ -86,15 +87,22 @@ export type KiloTrpcErrorShape = Omit & { data: KiloTrpcErrorData; }; -export const trpcErrorFormatter = (({ shape, error }) => ({ - ...shape, - data: { - ...shape.data, - zodError: - error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError - ? z.flattenError(error.cause) - : null, - upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined, - authRequired: error.cause instanceof AuthContextError ? true : undefined, - }, -})) satisfies TRPCErrorFormatter; +export const trpcErrorFormatter = (({ shape, error }) => { + // Cloud Agent's worker tRPC error is rethrown with a structured cause. Parse + // it through the SDK contract before exposing it at the browser boundary. + const billingFailure = parseCustomerBillingFailure({ data: error.cause }); + + return { + ...shape, + data: { + ...shape.data, + zodError: + error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError + ? z.flattenError(error.cause) + : null, + upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined, + authRequired: error.cause instanceof AuthContextError ? true : undefined, + ...(billingFailure ? { billingFailure } : {}), + }, + }; +}) satisfies TRPCErrorFormatter; diff --git a/apps/web/src/routers/cloud-agent-next-router.ts b/apps/web/src/routers/cloud-agent-next-router.ts index 9ebb2839b9..edf5dc6089 100644 --- a/apps/web/src/routers/cloud-agent-next-router.ts +++ b/apps/web/src/routers/cloud-agent-next-router.ts @@ -444,6 +444,15 @@ export const cloudAgentNextRouter = createTRPCRouter({ return await client.getSession(input.cloudAgentSessionId); }), + getComputeBillingStatus: baseProcedure + .input(baseGetSessionNextSchema) + .query(async ({ ctx, input }) => { + await assertUserOwnsSession(ctx.user.id, input.cloudAgentSessionId); + return await createCloudAgentNextClient( + generateCloudAgentToken(ctx.user) + ).getComputeBillingStatus(input.cloudAgentSessionId); + }), + checkEligibility: baseProcedure.query(async ({ ctx }) => { const { balance } = await getBalanceForUser(ctx.user); return buildCloudAgentNextEligibility(balance); diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts index 326292d205..7bb525d792 100644 --- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts @@ -605,6 +605,19 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ return await client.getSession(input.cloudAgentSessionId); }), + getComputeBillingStatus: organizationMemberProcedure + .input(GetSessionInput) + .query(async ({ ctx, input }) => { + await assertOrganizationOwnsSession({ + organizationId: input.organizationId, + userId: ctx.user.id, + cloudAgentSessionId: input.cloudAgentSessionId, + }); + return await createCloudAgentNextClient( + generateCloudAgentToken(ctx.user) + ).getComputeBillingStatus(input.cloudAgentSessionId); + }), + checkEligibility: organizationMemberProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }) => { diff --git a/packages/cloud-agent-sdk/src/index.ts b/packages/cloud-agent-sdk/src/index.ts index 5859c8676f..b7b7b7ee39 100644 --- a/packages/cloud-agent-sdk/src/index.ts +++ b/packages/cloud-agent-sdk/src/index.ts @@ -3,6 +3,8 @@ export type { CloudAgentEvent, StreamError, StreamErrorCode } from './event-type export { formatError as formatSessionError } from './session-manager'; export { createSessionManager } from './session-manager'; +export { customerBillingFailureSchema, parseCustomerBillingFailure } from './schemas'; +export type { CustomerBillingFailure } from './schemas'; export { CLI_MODEL_ID, cliModelLabel } from './cli-model'; export type { ActiveSessionType, diff --git a/packages/cloud-agent-sdk/src/schemas.test.ts b/packages/cloud-agent-sdk/src/schemas.test.ts index c9a662d06f..56a35ab0c4 100644 --- a/packages/cloud-agent-sdk/src/schemas.test.ts +++ b/packages/cloud-agent-sdk/src/schemas.test.ts @@ -1,4 +1,39 @@ -import { activeSessionSchema } from './schemas'; +import { activeSessionSchema, parseCustomerBillingFailure } from './schemas'; + +describe('parseCustomerBillingFailure', () => { + const failure = { + code: 'COMPUTE_STOPPING', + payer: { type: 'org', id: 'org-1' }, + retryable: true, + } as const; + it.each([ + { data: { billingFailure: failure } }, + { shape: { data: { billingFailure: failure } } }, + ])('parses an explicit billing failure from either tRPC location', error => + expect(parseCustomerBillingFailure(error)).toEqual(failure) + ); + it.each([ + { data: { billingFailure: { ...failure, payer: { type: 'org' } } } }, + { data: { billingFailure: { ...failure, remainingMicrodollars: -1 } } }, + { data: { code: 'PAYMENT_REQUIRED', httpStatus: 402 } }, + ])('omits malformed or legacy generic errors', error => + expect(parseCustomerBillingFailure(error)).toBeNull() + ); + + it('preserves zero-valued customer billing balances from the Worker', () => { + expect( + parseCustomerBillingFailure({ + data: { + billingFailure: { + ...failure, + remainingMicrodollars: 0, + minimumRequiredMicrodollars: 0, + }, + }, + }) + ).toMatchObject({ remainingMicrodollars: 0, minimumRequiredMicrodollars: 0 }); + }); +}); describe('activeSessionSchema capabilities', () => { it('parses a session whose `capabilities` is absent', () => { diff --git a/packages/cloud-agent-sdk/src/schemas.ts b/packages/cloud-agent-sdk/src/schemas.ts index 56b4e41b31..72e09e35d5 100644 --- a/packages/cloud-agent-sdk/src/schemas.ts +++ b/packages/cloud-agent-sdk/src/schemas.ts @@ -969,3 +969,25 @@ export const errorShapeSchema = z }) .passthrough(); export type ErrorShape = z.infer; + +// Keep aligned with the customer-safe projection in services/cloud-agent-next/src/trpc-error.ts. +export const customerBillingFailureSchema = z + .object({ + code: z.enum(['INSUFFICIENT_CREDITS', 'COMPUTE_STOPPING', 'BILLING_UNAVAILABLE']), + payer: z.object({ type: z.enum(['user', 'org']), id: z.string().min(1) }).strict(), + retryable: z.boolean(), + remainingMicrodollars: z.number().int().nonnegative().optional(), + minimumRequiredMicrodollars: z.number().int().nonnegative().optional(), + }) + .strict(); +export type CustomerBillingFailure = z.infer; + +/** Only accepts the explicit tRPC cause projection; generic 402s stay legacy. */ +export function parseCustomerBillingFailure(error: unknown): CustomerBillingFailure | null { + const parsed = errorShapeSchema.safeParse(error); + if (!parsed.success) return null; + const source = + parsed.data.data?.['billingFailure'] ?? parsed.data.shape?.data?.['billingFailure']; + const billingFailure = customerBillingFailureSchema.safeParse(source); + return billingFailure.success ? billingFailure.data : null; +} diff --git a/packages/cloud-agent-sdk/src/session-manager.test.ts b/packages/cloud-agent-sdk/src/session-manager.test.ts index 8bd811bee6..6f5df97a9b 100644 --- a/packages/cloud-agent-sdk/src/session-manager.test.ts +++ b/packages/cloud-agent-sdk/src/session-manager.test.ts @@ -1836,6 +1836,124 @@ describe('createSessionManager', () => { ); }); + it('sets structured billing state for prompt and command failures, then clears it with the restored prompt', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + const billingFailure = { + code: 'COMPUTE_STOPPING', + payer: { type: 'org', id: 'org-1' }, + retryable: true, + }; + mockSession.send.mockRejectedValueOnce({ data: { billingFailure } }); + + await expect( + mgr.send({ + payload: { + type: 'prompt', + prompt: 'Retry this', + mode: 'code', + model: 'claude-3-5-sonnet', + }, + }) + ).resolves.toBe(false); + expect(atomValue(config.store, mgr.atoms.billingFailure)).toEqual(billingFailure); + expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('Retry this'); + + mockSession.send.mockResolvedValueOnce(undefined); + await expect( + mgr.send({ + payload: { + type: 'prompt', + prompt: 'Retry this', + mode: 'code', + model: 'claude-3-5-sonnet', + }, + }) + ).resolves.toBe(true); + expect(atomValue(config.store, mgr.atoms.billingFailure)).toBeNull(); + expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBeNull(); + + mockSession.send.mockRejectedValueOnce({ data: { billingFailure } }); + await mgr.send({ payload: { type: 'command', command: 'help', arguments: '' } }); + expect(atomValue(config.store, mgr.atoms.billingFailure)).toEqual(billingFailure); + expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('/help'); + }); + + it('clears structured billing state for a normal failure and on reset', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSession.send.mockRejectedValue({ + data: { + billingFailure: { + code: 'BILLING_UNAVAILABLE', + payer: { type: 'user', id: 'user-1' }, + retryable: true, + }, + }, + }); + await mgr.send({ + payload: { type: 'prompt', prompt: 'First', mode: 'code', model: 'claude-3-5-sonnet' }, + }); + mockSession.send.mockRejectedValueOnce(new Error('ordinary failure')); + await mgr.send({ + payload: { type: 'prompt', prompt: 'Second', mode: 'code', model: 'claude-3-5-sonnet' }, + }); + expect(atomValue(config.store, mgr.atoms.billingFailure)).toBeNull(); + expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('Second'); + mgr.destroy(); + expect(atomValue(config.store, mgr.atoms.billingFailure)).toBeNull(); + expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBeNull(); + }); + + it.each(['success', 'billing failure'] as const)( + 'ignores stale %s state after switching sessions mid-send', + async outcome => { + let settleSend: ((value?: unknown) => void) | undefined; + const pendingSend = new Promise((resolve, reject) => { + settleSend = outcome === 'success' ? resolve : reject; + }); + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSession.send.mockReturnValueOnce(pendingSend); + + const send = mgr.send({ + payload: { type: 'prompt', prompt: 'Session A', mode: 'code' }, + }); + await mgr.switchSession(kiloId('ses-2')); + config.store.set(mgr.atoms.failedPrompt, 'Session B'); + config.store.set(mgr.atoms.billingFailure, { + code: 'COMPUTE_STOPPING', + payer: { type: 'user', id: 'user-b' }, + retryable: true, + }); + + settleSend?.( + outcome === 'success' + ? undefined + : { + data: { + billingFailure: { + code: 'BILLING_UNAVAILABLE', + payer: { type: 'user', id: 'user-a' }, + retryable: true, + }, + }, + } + ); + await send; + + expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBe('Session B'); + expect(atomValue(config.store, mgr.atoms.billingFailure)).toEqual({ + code: 'COMPUTE_STOPPING', + payer: { type: 'user', id: 'user-b' }, + retryable: true, + }); + } + ); + it('restores the prompt and explains how to recover from unavailable-model rejection', async () => { const config = createMockConfig(); const mgr = createSessionManager(config); diff --git a/packages/cloud-agent-sdk/src/session-manager.ts b/packages/cloud-agent-sdk/src/session-manager.ts index d329cb78f8..8f3e588a36 100644 --- a/packages/cloud-agent-sdk/src/session-manager.ts +++ b/packages/cloud-agent-sdk/src/session-manager.ts @@ -1,6 +1,10 @@ import type { CloudAgentAttachments } from '@kilocode/app-shared/cloud-agent'; import type { Images } from '@kilocode/app-shared/images-schema'; -import { errorShapeSchema } from './schemas'; +import { + errorShapeSchema, + parseCustomerBillingFailure, + type CustomerBillingFailure, +} from './schemas'; import type { CreateRemoteSessionInput, RemoteAttachmentPart, @@ -357,6 +361,7 @@ type SessionManagerAtoms = { suggestion: W; pendingMessages: W>; failedPrompt: W; + billingFailure: W; fetchedSessionData: W; /** Slash command catalog reported by the wrapper for the current session. */ availableCommands: W; @@ -635,6 +640,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { const activeSuggestionAtom = atom(null); const pendingMessagesAtom = atom>(new Map()); const failedPromptAtom = atom(null); + const billingFailureAtom = atom(null); const fetchedSessionDataAtom = atom(null); /** * Catalog of kilo slash commands the wrapper has reported. Populated by @@ -857,6 +863,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { store.set(activeSuggestionAtom, null); store.set(pendingMessagesAtom, new Map()); store.set(failedPromptAtom, null); + store.set(billingFailureAtom, null); store.set(fetchedSessionDataAtom, null); store.set(childSessionHydrationStatesAtom, new Map()); store.set(childSessionErrorsAtom, new Map()); @@ -1821,6 +1828,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { // were current when the user pressed send, not the post-switch ones. const kiloSessionId = activeSessionId; const sessionType = activeSessionType; + const sessionAtSend = currentSession; // Client-side `/clear` for remote sessions: clear the local transcript view // only; never hit the transport (Decision 3/4). @@ -1879,7 +1887,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { } try { - if (!currentSession) throw new Error('No active session'); + if (!sessionAtSend) throw new Error('No active session'); if (input.attachments && sessionType !== 'cloud-agent') { // The cloud-only `attachments` field is exclusive to cloud-agent // sessions. Remote CLI sessions (capable or not) go through the @@ -1897,7 +1905,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { throw new Error('Only capable remote CLI sessions support attachments'); } } - await currentSession.send({ + await sessionAtSend.send({ payload: transportPayload, messageId, ...(input.attachments ? { attachments: input.attachments } : {}), @@ -1907,6 +1915,9 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { ? { attachmentParts: input.attachmentParts } : {}), }); + if (currentSession !== sessionAtSend || activeSessionId !== kiloSessionId) return true; + store.set(billingFailureAtom, null); + store.set(failedPromptAtom, null); // User continued after `/clear`: drop the marker so a later reconnect // replays full history (pre-clear may reappear — accepted tradeoff). @@ -1921,7 +1932,9 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { } return true; } catch (err) { + if (currentSession !== sessionAtSend || activeSessionId !== kiloSessionId) return false; store.set(failedPromptAtom, messageText); + store.set(billingFailureAtom, parseCustomerBillingFailure(err)); const message = formatError(err); config.onSendFailed?.(messageText, message, err); if (store.get(agentStatusAtom).type !== 'disconnected') { @@ -2191,6 +2204,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { activeSuggestion: activeSuggestionAtom, pendingMessages: pendingMessagesAtom, failedPrompt: failedPromptAtom, + billingFailure: billingFailureAtom, fetchedSessionData: fetchedSessionDataAtom, availableCommands: availableCommandsAtom, messagesList: messagesListAtom, diff --git a/packages/container-usage/src/contracts.ts b/packages/container-usage/src/contracts.ts index 67ea5082ee..b645dbd31b 100644 --- a/packages/container-usage/src/contracts.ts +++ b/packages/container-usage/src/contracts.ts @@ -132,13 +132,14 @@ export const recordStartFailureCodeSchema = z.enum([ ]); export type RecordStartFailureCode = z.infer; +/** Customer-safe billing admission failure carried across Worker and client boundaries. */ const recordStartFailureSchema = z.discriminatedUnion('code', [ z .object({ code: z.literal('insufficient_credits'), message: z.string().min(1), - remainingMicrodollars: z.number().int().optional(), - minimumRequiredMicrodollars: z.number().int().positive().optional(), + remainingMicrodollars: z.number().int().nonnegative().optional(), + minimumRequiredMicrodollars: z.number().int().nonnegative().optional(), }) .strict(), z @@ -160,8 +161,8 @@ export const budgetVerdictSchema = z.discriminatedUnion('verdict', [ z .object({ verdict: z.literal('warn'), - remainingMicrodollars: z.number().int().optional(), - minimumRequiredMicrodollars: z.number().int().positive().optional(), + remainingMicrodollars: z.number().int().nonnegative().optional(), + minimumRequiredMicrodollars: z.number().int().nonnegative().optional(), // Retained while already-deployed producers complete their protocol rollout. remaining: z.number().int().optional(), }) @@ -169,8 +170,8 @@ export const budgetVerdictSchema = z.discriminatedUnion('verdict', [ z .object({ verdict: z.literal('stop'), - remainingMicrodollars: z.number().int().optional(), - minimumRequiredMicrodollars: z.number().int().positive().optional(), + remainingMicrodollars: z.number().int().nonnegative().optional(), + minimumRequiredMicrodollars: z.number().int().nonnegative().optional(), remaining: z.number().int().optional(), }) .strict(), diff --git a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts index 4aca8edeff..cd815cc994 100644 --- a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts +++ b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts @@ -79,6 +79,7 @@ import { ensureSandboxBillingAdmissionInput, isSandboxBillingBlocked, isSandboxContainerRunning, + getSandboxBillingRuntimeStatus, type SandboxBillingInput, } from '../../container-usage-context.js'; import { isCloudAgentContainerBillingEnabled } from '../../container-billing-rollout.js'; @@ -285,6 +286,12 @@ export class CloudflareAgentSandbox implements AgentSandbox { return this.sandboxBillingBlocked(this.resolveSandbox(sandboxId), enforcementRequested); } + async getBillingRuntimeStatus() { + const sandboxId = await this.resolveSandboxId(); + const status = await getSandboxBillingRuntimeStatus(this.resolveSandbox(sandboxId)); + return status ? { ...status, sandboxId } : undefined; + } + private async getSandbox(options?: { sleepAfter?: number; bypassBilling?: boolean; diff --git a/services/cloud-agent-next/src/agent-sandbox/protocol.ts b/services/cloud-agent-next/src/agent-sandbox/protocol.ts index 979e311066..b4be31f3ee 100644 --- a/services/cloud-agent-next/src/agent-sandbox/protocol.ts +++ b/services/cloud-agent-next/src/agent-sandbox/protocol.ts @@ -8,6 +8,9 @@ import type { } from '../execution/types.js'; import type { SessionMetadata } from '../persistence/session-metadata.js'; import type { SandboxBillingAdmissionResult } from '../container-usage-context.js'; +import type { SandboxClassName } from '../container-usage-context.js'; +import type { BillingContext } from '@kilocode/container-usage'; +import type { SandboxId } from '../types.js'; export type SandboxDeleteReason = 'explicit' | 'retention-expired' | 'recovery'; @@ -133,6 +136,16 @@ export type EnsuredWrapper = export type AgentSandbox = { ensureBillingAdmission(): Promise; isBillingBlocked(enforcementRequested?: boolean): Promise; + getBillingRuntimeStatus(): Promise< + | { + sandboxId: SandboxId; + sandboxClassName: SandboxClassName; + running: boolean; + blocked: boolean; + context?: BillingContext; + } + | undefined + >; ensureWrapper(request: EnsureWrapperRequest): Promise; discoverSessionWrappers(): Promise; /** diff --git a/services/cloud-agent-next/src/container-usage-context.test.ts b/services/cloud-agent-next/src/container-usage-context.test.ts index 1d20d70601..948fa87c05 100644 --- a/services/cloud-agent-next/src/container-usage-context.test.ts +++ b/services/cloud-agent-next/src/container-usage-context.test.ts @@ -5,6 +5,7 @@ import { assertSandboxBillingAllocation, buildSandboxBillingInput, configureSandboxBillingInput, + getSandboxBillingRuntimeStatus, SANDBOX_CAPACITIES, SANDBOX_USAGE_SKUS, } from './container-usage-context.js'; @@ -20,6 +21,28 @@ function metadata(identity: SessionMetadata['identity']): SessionMetadata { } describe('container usage context', () => { + it('invokes billing status as a binding method for RPC proxies', async () => { + const status = { + sandboxClassName: 'SandboxSmall' as const, + running: true, + blocked: false, + }; + const getBillingRuntimeStatus = new Proxy( + vi.fn(async () => status), + { + get: (target, property, receiver) => { + if (property === 'call') + throw new Error('RPC method proxies do not support Function.call'); + return Reflect.get(target, property, receiver); + }, + } + ); + + await expect( + getSandboxBillingRuntimeStatus({ getBillingRuntimeStatus } as unknown as SandboxInstance) + ).resolves.toEqual(status); + }); + it('maps every concrete sandbox class to its immutable SKU', () => { expect(SANDBOX_USAGE_SKUS).toEqual({ Sandbox: 'cloud-agent-standard-2026-07', diff --git a/services/cloud-agent-next/src/container-usage-context.ts b/services/cloud-agent-next/src/container-usage-context.ts index 9edacdc2ce..43425127fb 100644 --- a/services/cloud-agent-next/src/container-usage-context.ts +++ b/services/cloud-agent-next/src/container-usage-context.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { logger } from './logger.js'; import type { SessionMetadata } from './persistence/session-metadata.js'; import type { SandboxId, SandboxInstance } from './types.js'; +import type { BillingContext } from '@kilocode/container-usage'; export const SANDBOX_USAGE_SKUS = { Sandbox: 'cloud-agent-standard-2026-07', @@ -55,6 +56,17 @@ export type MeteredSandboxInstance = SandboxInstance & { ensureBillingAdmission(input: unknown): Promise; isBillingBlocked(): Promise; isContainerRunning(): Promise; + getBillingRuntimeStatus(): Promise<{ + sandboxClassName: SandboxClassName; + running: boolean; + blocked: boolean; + context?: BillingContext; + }>; +}; + +/** Optional while Worker and sandbox runtime deployments roll forward independently. */ +type BillingRuntimeStatusCapability = { + getBillingRuntimeStatus?: MeteredSandboxInstance['getBillingRuntimeStatus']; }; const sandboxBillingInputEnvelopeSchema = z @@ -291,6 +303,20 @@ export async function isSandboxContainerRunning( } } +export async function getSandboxBillingRuntimeStatus(sandbox: SandboxInstance): Promise< + | { + sandboxClassName: SandboxClassName; + running: boolean; + blocked: boolean; + context?: BillingContext; + } + | undefined +> { + const capability = sandbox as BillingRuntimeStatusCapability; + if (typeof capability.getBillingRuntimeStatus !== 'function') return undefined; + return await capability.getBillingRuntimeStatus(); +} + export async function configureSandboxBillingInput( sandbox: SandboxInstance, input: SandboxBillingInput diff --git a/services/cloud-agent-next/src/container-usage.test.ts b/services/cloud-agent-next/src/container-usage.test.ts index cdc7e6f9cf..91486355b9 100644 --- a/services/cloud-agent-next/src/container-usage.test.ts +++ b/services/cloud-agent-next/src/container-usage.test.ts @@ -74,7 +74,7 @@ const sdk = vi.hoisted(() => { vi.mock('@cloudflare/sandbox', () => ({ Sandbox: sdk.StockSandbox })); -import { MeteredSandbox } from './container-usage.js'; +import { billingHeartbeatSeconds, MeteredSandbox } from './container-usage.js'; class MemoryStorage { private readonly values = new Map(); @@ -99,6 +99,10 @@ class MemoryStorage { clear(): void { this.values.clear(); } + + size(): number { + return this.values.size; + } } function ack(intervalId = 'interval-1') { @@ -139,7 +143,8 @@ function createSandbox( | 'Sandbox' | 'SandboxContainment' | 'SandboxSmallContainment' - | 'SandboxDIND' = 'SandboxSmallContainment' + | 'SandboxDIND' = 'SandboxSmallContainment', + heartbeatSeconds?: string ) { const storage = new MemoryStorage(); const shadowTasks: Promise[] = []; @@ -169,6 +174,7 @@ function createSandbox( flushShadowTasks: () => Promise.all(shadowTasks), sandbox: new TestSandbox(ctx, { CONTAINER_USAGE_METER: rpc, + CONTAINER_BILLING_HEARTBEAT_SECONDS: heartbeatSeconds, } as never) as unknown as TestRuntime, }; } @@ -182,14 +188,43 @@ const billingInput = { }; describe('MeteredSandbox', () => { + it('uses a configurable positive heartbeat interval with the production default as fallback', () => { + expect(billingHeartbeatSeconds('60')).toBe(60); + expect(billingHeartbeatSeconds(undefined)).toBe(300); + expect(billingHeartbeatSeconds('')).toBe(300); + expect(billingHeartbeatSeconds('0')).toBe(300); + expect(billingHeartbeatSeconds('-1')).toBe(300); + expect(billingHeartbeatSeconds('not-a-number')).toBe(300); + }); + beforeEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + it('reads billing runtime status without creating a billing generation or waking the container', async () => { + const { sandbox, storage, rpc } = createSandbox(createRpc(), false, 'SandboxSmallContainment'); + + await expect(sandbox.getBillingRuntimeStatus()).resolves.toEqual({ + sandboxClassName: 'SandboxSmallContainment', + running: false, + blocked: false, + context: undefined, + }); + + expect(storage.size()).toBe(0); + expect(rpc.recordStart).not.toHaveBeenCalled(); + expect(rpc.recordHeartbeat).not.toHaveBeenCalled(); + }); + it('accepts any successful meter admission before a selected cold start', async () => { const rpc = createRpc(); - const { sandbox, flushShadowTasks } = createSandbox(rpc); + const { sandbox, flushShadowTasks } = createSandbox( + rpc, + false, + 'SandboxSmallContainment', + '60' + ); await expect( sandbox.ensureBillingAdmission({ ...billingInput, enforcementRequested: true }) @@ -202,7 +237,7 @@ describe('MeteredSandbox', () => { await sandbox.onStart(); await flushShadowTasks(); expect(sandbox.schedules).toEqual([ - expect.objectContaining({ callback: 'billingHeartbeatTick' }), + expect.objectContaining({ when: 60, callback: 'billingHeartbeatTick' }), ]); }); diff --git a/services/cloud-agent-next/src/container-usage.ts b/services/cloud-agent-next/src/container-usage.ts index 8dab646145..95333d2803 100644 --- a/services/cloud-agent-next/src/container-usage.ts +++ b/services/cloud-agent-next/src/container-usage.ts @@ -1,6 +1,7 @@ import { clearBillingContext, createContainerUsageClient, + DEFAULT_BILLING_HEARTBEAT_SECONDS, getBillingContext, installBillingHeartbeat, setBillingContext, @@ -37,6 +38,12 @@ const DESTROY_RECOVERY_MARKER_STORAGE_KEY_PREFIX = 'container-usage:destroy-reco const BILLING_FORCE_STOP_SECONDS = 120; const BILLING_FORCE_STOP_RETRY_SECONDS = 5; +export function billingHeartbeatSeconds(value: string | undefined): number { + if (value === undefined || value.trim() === '') return DEFAULT_BILLING_HEARTBEAT_SECONDS; + const seconds = Number(value); + return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : DEFAULT_BILLING_HEARTBEAT_SECONDS; +} + // oxlint-disable-next-line no-empty-object-type -- Matches the Sandbox 0.12.1 constructor. type SandboxDurableObjectState = DurableObjectState<{}>; type ContainerStopParams = { reason: 'exit' | 'runtime_signal'; exitCode?: number }; @@ -102,6 +109,7 @@ export abstract class MeteredSandbox extends StockSandbox { this.billingHeartbeat = installBillingHeartbeat(this, { client: this.usageClient, storage: this.ctx.storage, + heartbeatSeconds: billingHeartbeatSeconds(env.CONTAINER_BILLING_HEARTBEAT_SECONDS), stopOnStoppedState: false, deferBudgetStopFinalSettlement: true, beforeHeartbeatDelivery: context => this.ensureStartAcknowledged(context), @@ -133,6 +141,21 @@ export abstract class MeteredSandbox extends StockSandbox { return (await this.getBillingBlock()) !== undefined; } + /** Read-only status: storage and container state only; this never wakes or admits. */ + async getBillingRuntimeStatus(): Promise<{ + sandboxClassName: SandboxClassName; + running: boolean; + blocked: boolean; + context?: BillingContext; + }> { + return { + sandboxClassName: this.sandboxClassName, + running: this.ctx.container?.running === true, + blocked: (await this.getBillingBlock()) !== undefined, + context: await getBillingContext(this.ctx.storage), + }; + } + async ensureBillingAdmission(input: unknown): Promise { const parsed = parseSandboxBillingInput(input); assertSandboxBillingAllocation(this.sandboxClassName, parsed); diff --git a/services/cloud-agent-next/src/execution/types.ts b/services/cloud-agent-next/src/execution/types.ts index 721b2ad737..9ed2014627 100644 --- a/services/cloud-agent-next/src/execution/types.ts +++ b/services/cloud-agent-next/src/execution/types.ts @@ -230,6 +230,15 @@ export type RetryableResultCode = export type PermanentDeliveryResultCode = 'SANDBOX_CAPABILITY_UNAVAILABLE'; +// Keep aligned with the customer-safe schemas in trpc-error.ts and cloud-agent-sdk. +export type CustomerBillingFailure = { + code: 'INSUFFICIENT_CREDITS' | 'COMPUTE_STOPPING' | 'BILLING_UNAVAILABLE'; + payer: { type: 'user' | 'org'; id: string }; + retryable: boolean; + remainingMicrodollars?: number; + minimumRequiredMicrodollars?: number; +}; + export type AdmissionFailure = { success: false; code: @@ -237,9 +246,12 @@ export type AdmissionFailure = { | 'BAD_REQUEST' | 'INTERNAL' | 'PAYMENT_REQUIRED' + | 'COMPUTE_STOPPING' + | 'BILLING_UNAVAILABLE' | 'PENDING_QUEUE_FULL' | RetryableResultCode; error: string; + billingFailure?: CustomerBillingFailure; failureBoundary?: 'registration' | 'admission'; }; diff --git a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts index dcd4a2f641..9aab3e8e40 100644 --- a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts +++ b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts @@ -676,6 +676,14 @@ function promptAdmissionError( return facadeError(429, 'KILO_PROMPT_QUEUE_FULL', result.error); case 'PAYMENT_REQUIRED': return facadeError(402, 'KILO_PROMPT_PAYMENT_REQUIRED', result.error); + case 'COMPUTE_STOPPING': + return facadeError(409, 'KILO_BILLING_BLOCKED', 'Cloud Agent is saving and stopping compute'); + case 'BILLING_UNAVAILABLE': + return facadeError( + 503, + 'KILO_BILLING_UNAVAILABLE', + 'Cloud Agent cannot verify compute billing right now' + ); case 'SANDBOX_CONNECT_FAILED': case 'WORKSPACE_SETUP_FAILED': case 'KILO_SERVER_FAILED': diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index c9e67dc8b0..fdcc058dc4 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -756,14 +756,40 @@ export class CloudAgentSession extends DurableObject { if (!isCloudAgentContainerBillingEnabled(this.env, metadata.identity)) return null; const admission = await createAgentSandbox(this.env, metadata).ensureBillingAdmission(); if (admission.success) return null; - const paymentRequired = - admission.code === 'insufficient_credits' || admission.code === 'stopping'; + const payer = metadata.identity.orgId + ? { type: 'org' as const, id: metadata.identity.orgId } + : { type: 'user' as const, id: metadata.identity.userId }; + const billingFailure = + admission.code === 'insufficient_credits' + ? { + code: 'INSUFFICIENT_CREDITS' as const, + payer, + retryable: false, + ...(admission.remainingMicrodollars === undefined + ? {} + : { remainingMicrodollars: admission.remainingMicrodollars }), + ...(admission.minimumRequiredMicrodollars === undefined + ? {} + : { minimumRequiredMicrodollars: admission.minimumRequiredMicrodollars }), + } + : admission.code === 'stopping' + ? { code: 'COMPUTE_STOPPING' as const, payer, retryable: true } + : { code: 'BILLING_UNAVAILABLE' as const, payer, retryable: true }; return { success: false, - code: paymentRequired ? 'PAYMENT_REQUIRED' : 'INTERNAL', - error: paymentRequired - ? 'Container billing requires additional credits' - : 'Container billing admission is temporarily unavailable', + code: + admission.code === 'insufficient_credits' + ? 'PAYMENT_REQUIRED' + : admission.code === 'stopping' + ? 'COMPUTE_STOPPING' + : 'BILLING_UNAVAILABLE', + error: + admission.code === 'insufficient_credits' + ? 'Insufficient credits to start compute' + : admission.code === 'stopping' + ? 'Cloud Agent is saving and stopping compute' + : 'Cloud Agent cannot verify compute billing right now', + billingFailure, failureBoundary: 'admission', }; } diff --git a/services/cloud-agent-next/src/router/handlers/session-management.ts b/services/cloud-agent-next/src/router/handlers/session-management.ts index 4f90a80458..d6afe6bba1 100644 --- a/services/cloud-agent-next/src/router/handlers/session-management.ts +++ b/services/cloud-agent-next/src/router/handlers/session-management.ts @@ -3,7 +3,7 @@ import * as z from 'zod'; import { AgentSandboxUnavailableError } from '../../agent-sandbox/protocol.js'; import { createAgentSandbox } from '../../agent-sandbox/factory.js'; import { logger, withLogTags } from '../../logger.js'; -import { generateSandboxId } from '../../sandbox-id.js'; +import { generateSandboxId, isGeneratedSharedSandboxId } from '../../sandbox-id.js'; import type { SessionId, InterruptResult, TRPCContext } from '../../types.js'; import type { SandboxId } from '../../types.js'; import { @@ -23,12 +23,17 @@ import { GetMessageResultOutput, GetLatestAssistantMessageInput, GetLatestAssistantMessageOutput, + GetComputeBillingStatusOutput, } from '../schemas.js'; import { readProfileBundle } from '../../session-profile.js'; import type { CloudAgentSession } from '../../persistence/CloudAgentSession.js'; import type { CloudAgentSessionState } from '../../persistence/types.js'; import type { MessageResultRPCResponse } from '../../session/message-result.js'; import { requireCurrentSessionAccess } from '../../session-access.js'; +import { getPgDb } from '../../db/pg.js'; +import { cloud_billing_sku, container_usage_interval } from '@kilocode/db/schema'; +import { and, desc, eq, like } from 'drizzle-orm'; +import { SANDBOX_USAGE_SKUS } from '../../container-usage-context.js'; function publicRepositoryFields(metadata: CloudAgentSessionState): { githubRepo?: string; @@ -49,6 +54,17 @@ function publicRepositoryFields(metadata: CloudAgentSessionState): { } } +function toIso(value: string): string { + return new Date(value).toISOString(); +} + +function microdollarsForSeconds(seconds: number, rateCentsPerSecond: string): number { + const [whole, fraction = ''] = rateCentsPerSecond.split('.'); + const scale = 10n ** BigInt(fraction.length); + const cents = BigInt(`${whole}${fraction}` || '0'); + return Number((BigInt(seconds) * cents * 10_000n) / scale); +} + async function deleteSessionResources( sessionId: SessionId, userId: string, @@ -374,6 +390,144 @@ export function createSessionManagementHandlers() { }); }), + getComputeBillingStatus: protectedProcedure + .input(GetSessionInput) + .output(GetComputeBillingStatusOutput) + .query(async ({ input, ctx }) => { + const sessionId = input.cloudAgentSessionId as SessionId; + await requireCurrentSessionAccess({ + env: ctx.env, + kiloUserId: ctx.userId, + cloudAgentSessionId: sessionId, + }); + const stub = ctx.env.CLOUD_AGENT_SESSION.get( + ctx.env.CLOUD_AGENT_SESSION.idFromName(`${ctx.userId}:${sessionId}`) + ); + const metadata = await withDORetry< + DurableObjectStub, + CloudAgentSessionState | null + >( + () => stub, + value => value.getMetadata(), + 'getMetadata' + ); + if (!metadata) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' }); + + const sandbox = createAgentSandbox(ctx.env, metadata); + // This is a no-wake status RPC. Older/rejecting runtime deployments are + // unavailable rather than a reason to create, wake, or admit compute. + const runtime = await sandbox.getBillingRuntimeStatus().catch(() => undefined); + const payer = metadata.identity.orgId + ? { type: 'org' as const, id: metadata.identity.orgId } + : { type: 'user' as const, id: metadata.identity.userId }; + // The metered runtime has the canonical resolved ID (including a + // persisted failover ID). When it is not available, only trust stored + // metadata; never regenerate an ID for a read-only billing status. + const sandboxId = runtime?.sandboxId ?? metadata.workspace?.sandboxId; + const db = getPgDb(ctx.env); + const catalogSkuId = runtime ? SANDBOX_USAGE_SKUS[runtime.sandboxClassName] : undefined; + const [catalog] = catalogSkuId + ? await db + .select({ rate: cloud_billing_sku.rate_cents_per_unit, unit: cloud_billing_sku.unit }) + .from(cloud_billing_sku) + .where(eq(cloud_billing_sku.id, catalogSkuId)) + .limit(1) + : []; + const latest = sandboxId + ? await db + .select({ + id: container_usage_interval.id, + billingMode: container_usage_interval.billing_mode, + rate: container_usage_interval.rate_cents_per_unit, + skuId: container_usage_interval.cloud_billing_sku_id, + startedAt: container_usage_interval.started_at, + lastSeenAt: container_usage_interval.last_seen_at, + stoppedAt: container_usage_interval.stopped_at, + confirmedSeconds: container_usage_interval.confirmed_seconds, + settledBillableSeconds: container_usage_interval.settled_billable_seconds, + status: container_usage_interval.status, + skuRate: cloud_billing_sku.rate_cents_per_unit, + }) + .from(container_usage_interval) + .leftJoin( + cloud_billing_sku, + eq(cloud_billing_sku.id, container_usage_interval.cloud_billing_sku_id) + ) + .where( + and( + eq(container_usage_interval.instance_id, sandboxId), + eq(container_usage_interval.subject_type, payer.type), + eq(container_usage_interval.subject_id, payer.id), + like(container_usage_interval.service, 'cloud-agent-next-%') + ) + ) + .orderBy(desc(container_usage_interval.started_at)) + .limit(1) + : []; + const interval = latest[0]; + // A closed row is historical evidence, not the current running + // interval. Paid intervals retain their admitted snapshot; shadow + // intervals use today's catalog rate for the current runtime class. + const hasCurrentInterval = interval?.status === 'open'; + const catalogRate = catalog?.unit === 'second' ? catalog.rate : null; + // Paid interval rates are admitted snapshots for second-based container usage. + const rate = hasCurrentInterval + ? interval.billingMode === 'paid' + ? interval.rate + : catalogRate + : catalogRate; + const attribution = + sandboxId && isGeneratedSharedSandboxId(sandboxId) + ? ('payer_shared' as const) + : ('session' as const); + const phase = !runtime + ? ('unavailable' as const) + : runtime.context || hasCurrentInterval + ? runtime.blocked + ? ('stopping' as const) + : runtime.running + ? ('active' as const) + : ('settling' as const) + : ('idle' as const); + const confirmedSeconds = hasCurrentInterval ? (interval?.confirmedSeconds ?? 0) : 0; + // This display-only elapsed estimate never drives settlement; the meter is authoritative. + const observedSeconds = + hasCurrentInterval && interval + ? Math.max( + confirmedSeconds, + Math.floor( + ((interval.stoppedAt ? new Date(interval.stoppedAt).getTime() : Date.now()) - + new Date(interval.startedAt).getTime()) / + 1_000 + ) + ) + : 0; + return { + payer, + attribution, + phase, + estimatedHourlyRateMicrodollars: rate ? microdollarsForSeconds(3600, rate) : null, + estimatedIntervalAmountMicrodollars: + phase === 'active' || phase === 'stopping' + ? rate + ? microdollarsForSeconds(observedSeconds, rate) + : null + : null, + billingMode: hasCurrentInterval ? (interval?.billingMode ?? null) : null, + interval: + hasCurrentInterval && interval + ? { + id: interval.id, + startedAt: toIso(interval.startedAt), + lastSeenAt: toIso(interval.lastSeenAt), + stoppedAt: interval.stoppedAt ? toIso(interval.stoppedAt) : null, + confirmedSeconds, + settledBillableSeconds: interval.settledBillableSeconds, + } + : null, + }; + }), + getSessionHealth: protectedProcedure .input(GetSessionHealthInput) .output(GetSessionHealthOutput) diff --git a/services/cloud-agent-next/src/router/schemas.ts b/services/cloud-agent-next/src/router/schemas.ts index 83a7984922..0b9711be4c 100644 --- a/services/cloud-agent-next/src/router/schemas.ts +++ b/services/cloud-agent-next/src/router/schemas.ts @@ -924,6 +924,26 @@ export const GetSessionInput = z.object({ cloudAgentSessionId: sessionIdSchema.describe('Cloud-agent session ID to retrieve'), }); +/** Customer-safe, no-wake compute billing status for an existing session. */ +export const GetComputeBillingStatusOutput = z.object({ + payer: z.object({ type: z.enum(['user', 'org']), id: z.string() }), + attribution: z.enum(['payer_shared', 'session']), + phase: z.enum(['idle', 'active', 'stopping', 'settling', 'unavailable']), + estimatedHourlyRateMicrodollars: z.number().int().nonnegative().nullable(), + estimatedIntervalAmountMicrodollars: z.number().int().nonnegative().nullable(), + billingMode: z.enum(['shadow', 'paid']).nullable(), + interval: z + .object({ + id: z.string(), + startedAt: z.string().datetime(), + lastSeenAt: z.string().datetime(), + stoppedAt: z.string().datetime().nullable(), + confirmedSeconds: z.number().int().nonnegative(), + settledBillableSeconds: z.number().int().nonnegative(), + }) + .nullable(), +}); + export const SandboxStatusSchema = z .enum(['healthy', 'destroyed', 'unreachable', 'unknown']) .describe('Sandbox reachability status for the session container'); diff --git a/services/cloud-agent-next/src/session/pending-messages.ts b/services/cloud-agent-next/src/session/pending-messages.ts index cda48d25d9..1e2d14751f 100644 --- a/services/cloud-agent-next/src/session/pending-messages.ts +++ b/services/cloud-agent-next/src/session/pending-messages.ts @@ -100,6 +100,8 @@ const PendingFlushFailureCodeSchema = z.enum([ 'INTERNAL', 'PENDING_QUEUE_FULL', 'MODEL_MISSING', + 'COMPUTE_STOPPING', + 'BILLING_UNAVAILABLE', 'UNKNOWN', ]); export type PendingFlushFailureCode = z.infer; @@ -540,6 +542,8 @@ export async function recordPendingFlushFailure( | 'INTERNAL' | 'PENDING_QUEUE_FULL' | 'MODEL_MISSING' + | 'COMPUTE_STOPPING' + | 'BILLING_UNAVAILABLE' | 'UNKNOWN'; subtype?: WorkspaceFailureSubtype; safeFailureMessage?: string; @@ -636,6 +640,8 @@ function isRetryableFlushCode( | 'INTERNAL' | 'PENDING_QUEUE_FULL' | 'MODEL_MISSING' + | 'COMPUTE_STOPPING' + | 'BILLING_UNAVAILABLE' | 'UNKNOWN' | undefined ): boolean { @@ -646,6 +652,8 @@ function isRetryableFlushCode( code === 'WORKSPACE_SETUP_FAILED' || code === 'KILO_SERVER_FAILED' || code === 'WRAPPER_START_FAILED' || + code === 'COMPUTE_STOPPING' || + code === 'BILLING_UNAVAILABLE' || code === 'WRAPPER_CLEANUP_EXHAUSTED' ); } diff --git a/services/cloud-agent-next/src/session/queue-message.ts b/services/cloud-agent-next/src/session/queue-message.ts index 0deef65b42..017df20cea 100644 --- a/services/cloud-agent-next/src/session/queue-message.ts +++ b/services/cloud-agent-next/src/session/queue-message.ts @@ -44,12 +44,20 @@ const ADMISSION_CODE_TO_TRPC: Record = NOT_FOUND: 'NOT_FOUND', BAD_REQUEST: 'BAD_REQUEST', PAYMENT_REQUIRED: 'PAYMENT_REQUIRED', + COMPUTE_STOPPING: 'CONFLICT', + BILLING_UNAVAILABLE: 'SERVICE_UNAVAILABLE', PENDING_QUEUE_FULL: 'TOO_MANY_REQUESTS', INTERNAL: 'INTERNAL_SERVER_ERROR', }; function isAdmissionFailureRetryable(code: AdmissionFailureCode): boolean { - return isRetryableCode(code) || code === 'PENDING_QUEUE_FULL' || code === 'INTERNAL'; + return ( + isRetryableCode(code) || + code === 'PENDING_QUEUE_FULL' || + code === 'INTERNAL' || + code === 'COMPUTE_STOPPING' || + code === 'BILLING_UNAVAILABLE' + ); } export function throwAdmissionError( @@ -66,6 +74,7 @@ export function throwAdmissionError( error: result.code, message: result.error, retryable: explicitlyRetryable, + ...(result.billingFailure ? { billingFailure: result.billingFailure } : {}), }, }); } diff --git a/services/cloud-agent-next/src/session/session-message-queue.test.ts b/services/cloud-agent-next/src/session/session-message-queue.test.ts index 282884801e..0a8bc493d4 100644 --- a/services/cloud-agent-next/src/session/session-message-queue.test.ts +++ b/services/cloud-agent-next/src/session/session-message-queue.test.ts @@ -418,6 +418,116 @@ describe('flushNextPendingSessionMessage', () => { expect((await storage.list({ prefix: 'pending_message:' })).size).toBe(0); }); + it.each(['COMPUTE_STOPPING', 'BILLING_UNAVAILABLE'] as const)( + 'retries %s billing admission and preserves the durable message identity', + async code => { + const storage = createMemoryStorage(); + await storePendingSessionMessage( + storage, + createPendingSessionMessage({ + messageId: FIRST_MESSAGE_ID, + role: 'user', + content: 'retry after compute state settles', + createdAt: 1, + }) + ); + const deliver = vi + .fn<(_plan: MessageDeliveryRequest) => Promise>() + .mockResolvedValueOnce({ success: false, code, error: 'temporary billing state' }) + .mockResolvedValueOnce({ + success: true, + outcome: 'accepted', + messageId: FIRST_MESSAGE_ID, + wrapperRunId: 'wr_test', + }); + + const first = await flushNextPendingSessionMessage({ + storage, + now: 10, + getDeliveryContext: async () => createContext(), + validateModeAgainstRuntimeAgents: () => null, + deliver, + }); + expect(first).toMatchObject({ type: 'failure', exhausted: false, attempts: 1 }); + if (first.type !== 'failure') return; + expect(first.message.lastFlushFailureCode).toBe(code); + + await expect( + flushNextPendingSessionMessage({ + storage, + now: first.nextFlushAttemptAt ?? 20, + getDeliveryContext: async () => createContext(), + validateModeAgainstRuntimeAgents: () => null, + deliver, + }) + ).resolves.toEqual({ type: 'delivered', remainingCount: 0 }); + expect(deliver.mock.calls.map(([plan]) => plan.turn.messageId)).toEqual([ + FIRST_MESSAGE_ID, + FIRST_MESSAGE_ID, + ]); + } + ); + + it('fails closed after the retry budget for unavailable billing', async () => { + const storage = createMemoryStorage(); + await storePendingSessionMessage( + storage, + createPendingSessionMessage({ + messageId: FIRST_MESSAGE_ID, + role: 'user', + content: 'eventually terminal', + createdAt: 1, + }) + ); + const deliver = async (): Promise => ({ + success: false, + code: 'BILLING_UNAVAILABLE', + error: 'temporary billing state', + }); + const first = await flushNextPendingSessionMessage({ + storage, + now: 10, + getDeliveryContext: async () => createContext(), + validateModeAgainstRuntimeAgents: () => null, + deliver, + }); + if (first.type !== 'failure') throw new Error('Expected failure'); + const exhausted = await flushNextPendingSessionMessage({ + storage, + now: first.nextFlushAttemptAt ?? 20, + getDeliveryContext: async () => createContext(), + validateModeAgainstRuntimeAgents: () => null, + deliver, + }); + expect(exhausted).toMatchObject({ type: 'failure', exhausted: true, attempts: 2 }); + }); + + it('terminalizes insufficient credits immediately without a retry', async () => { + const storage = createMemoryStorage(); + await storePendingSessionMessage( + storage, + createPendingSessionMessage({ + messageId: FIRST_MESSAGE_ID, + role: 'user', + content: 'needs funds', + createdAt: 1, + }) + ); + await expect( + flushNextPendingSessionMessage({ + storage, + now: 10, + getDeliveryContext: async () => createContext(), + validateModeAgainstRuntimeAgents: () => null, + deliver: async () => ({ + success: false, + code: 'PAYMENT_REQUIRED', + error: 'Insufficient credits', + }), + }) + ).resolves.toMatchObject({ type: 'failure', exhausted: true, attempts: 1 }); + }); + it('schedules a retry from the time a delivery failure is observed', async () => { const storage = createMemoryStorage(); await storePendingSessionMessage( diff --git a/services/cloud-agent-next/src/session/session-message-queue.ts b/services/cloud-agent-next/src/session/session-message-queue.ts index 060674b390..b532ee9071 100644 --- a/services/cloud-agent-next/src/session/session-message-queue.ts +++ b/services/cloud-agent-next/src/session/session-message-queue.ts @@ -262,6 +262,8 @@ function classifyDeliveryFailure(code: PendingFlushFailureCode | undefined): { case 'MODEL_MISSING': return { failureStage: 'pre_dispatch', failureCode: 'model_missing' }; case 'WRAPPER_CLEANUP_EXHAUSTED': + case 'COMPUTE_STOPPING': + case 'BILLING_UNAVAILABLE': case 'SANDBOX_CAPABILITY_UNAVAILABLE': case 'WRAPPER_FINALIZING': case 'INTERNAL': diff --git a/services/cloud-agent-next/src/terminal/access.test.ts b/services/cloud-agent-next/src/terminal/access.test.ts index e01fd6de22..b55c3d91ee 100644 --- a/services/cloud-agent-next/src/terminal/access.test.ts +++ b/services/cloud-agent-next/src/terminal/access.test.ts @@ -101,6 +101,7 @@ function sandboxWithTerminalResult( return { ensureBillingAdmission: vi.fn().mockResolvedValue({ success: true }), isBillingBlocked: vi.fn().mockResolvedValue(false), + getBillingRuntimeStatus: vi.fn().mockResolvedValue(undefined), ensureWrapper: vi.fn(), discoverSessionWrappers: vi.fn(), observeWrappersWithoutWaking: vi.fn(), diff --git a/services/cloud-agent-next/src/trpc-error.test.ts b/services/cloud-agent-next/src/trpc-error.test.ts index 174f803e6d..f1c6b1183b 100644 --- a/services/cloud-agent-next/src/trpc-error.test.ts +++ b/services/cloud-agent-next/src/trpc-error.test.ts @@ -4,6 +4,28 @@ import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc'; import { buildTrpcErrorResponse, createClientError, projectTrpcErrorData } from './trpc-error.js'; describe('createClientError', () => { + it.each([ + ['PAYMENT_REQUIRED', 402, 'INSUFFICIENT_CREDITS'], + ['CONFLICT', 409, 'COMPUTE_STOPPING'], + ['SERVICE_UNAVAILABLE', 503, 'BILLING_UNAVAILABLE'], + ])('projects validated %s billing failure at HTTP %i', (code, httpStatus, billingCode) => { + const billingFailure = { + code: billingCode, + payer: { type: 'user', id: 'user-1' }, + retryable: code !== 'PAYMENT_REQUIRED', + }; + expect( + projectTrpcErrorData({ code, httpStatus }, 'Safe message', { billingFailure }) + ).toMatchObject({ billingFailure }); + }); + + it('omits malformed billing failures', () => { + expect( + projectTrpcErrorData({ code: 'PAYMENT_REQUIRED', httpStatus: 402 }, 'Safe', { + billingFailure: { code: 'INSUFFICIENT_CREDITS' }, + }) + ).not.toHaveProperty('billingFailure'); + }); it.each([ 'PARSE_ERROR', 'BAD_REQUEST', diff --git a/services/cloud-agent-next/src/trpc-error.ts b/services/cloud-agent-next/src/trpc-error.ts index 10fa69f42c..7f9ebcc4fa 100644 --- a/services/cloud-agent-next/src/trpc-error.ts +++ b/services/cloud-agent-next/src/trpc-error.ts @@ -1,5 +1,17 @@ import { PublicErrorCodeSchema, type ClientError } from '@kilocode/worker-utils/client-error'; import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc'; +import { z } from 'zod'; + +// Keep aligned with packages/cloud-agent-sdk/src/schemas.ts. +const customerBillingFailureSchema = z + .object({ + code: z.enum(['INSUFFICIENT_CREDITS', 'COMPUTE_STOPPING', 'BILLING_UNAVAILABLE']), + payer: z.object({ type: z.enum(['user', 'org']), id: z.string().min(1) }).strict(), + retryable: z.boolean(), + remainingMicrodollars: z.number().int().nonnegative().optional(), + minimumRequiredMicrodollars: z.number().int().nonnegative().optional(), + }) + .strict(); const NON_RETRYABLE_CODES = new Set([ 'PARSE_ERROR', @@ -61,6 +73,12 @@ function parseExplicitLegacyCause(cause: unknown): ExplicitLegacyCause | undefin return { error: parsedError.data, retryable: cause.retryable }; } +function parseBillingFailure(cause: unknown) { + if (!cause || typeof cause !== 'object' || !('billingFailure' in cause)) return undefined; + const parsed = customerBillingFailureSchema.safeParse(cause.billingFailure); + return parsed.success ? parsed.data : undefined; +} + export function createClientError(code: string, message: string, retryable?: boolean): ClientError { return { code, @@ -75,6 +93,7 @@ export function projectTrpcErrorData( cause?: unknown ): TrpcErrorData & { clientError: ClientError } { const explicitCause = parseExplicitLegacyCause(cause); + const billingFailure = parseBillingFailure(cause); if (explicitCause) { const clientError = createClientError(explicitCause.error, message, explicitCause.retryable); return { @@ -82,11 +101,13 @@ export function projectTrpcErrorData( error: explicitCause.error, retryable: clientError.retryable, clientError, + ...(billingFailure ? { billingFailure } : {}), }; } return { ...data, clientError: createClientError(data.code, message), + ...(billingFailure ? { billingFailure } : {}), }; } diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index f6833a3cd2..95827d6c23 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -500,6 +500,7 @@ export type Env = { CLOUD_AGENT_CONTAINER_BILLING_ENABLED?: string; CLOUD_AGENT_CONTAINER_BILLING_USER_IDS?: string; CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS?: string; + CONTAINER_BILLING_HEARTBEAT_SECONDS?: string; Sandbox: DurableObjectNamespace; /** Durable Object namespace for shared sandbox containers with SCM credential containment */ SandboxContainment: DurableObjectNamespace; diff --git a/services/cloud-agent-next/worker-configuration.d.ts b/services/cloud-agent-next/worker-configuration.d.ts index 1fbb28e6fa..7326dc3258 100644 --- a/services/cloud-agent-next/worker-configuration.d.ts +++ b/services/cloud-agent-next/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 49b6a02c34a9c5aafad101018d68a38e) +// Generated by Wrangler by running `wrangler types` (hash: 0f61912537b1887473372e4dd99f2737) // Runtime types generated with workerd@1.20260714.1 2026-06-03 nodejs_compat interface __BaseEnv_Env { SHARED_SANDBOX_OVERRIDES: KVNamespace; @@ -26,6 +26,7 @@ interface __BaseEnv_Env { KILOCODE_TOKEN_CONTAINMENT_ORG_IDS?: ""; REPO_SNAPSHOT_ORG_IDS?: ""; TOOL_CGROUP_ORG_IDS: "" | "*"; + CONTAINER_BILLING_HEARTBEAT_SECONDS: "60" | "300"; NEXTAUTH_SECRET: string; INTERNAL_API_SECRET: string; KILOCODE_BACKEND_BASE_URL: string; @@ -91,6 +92,7 @@ declare namespace Cloudflare { KILOCODE_TOKEN_CONTAINMENT_ORG_IDS: ""; REPO_SNAPSHOT_ORG_IDS: ""; TOOL_CGROUP_ORG_IDS: ""; + CONTAINER_BILLING_HEARTBEAT_SECONDS: "60"; NEXTAUTH_SECRET: string; INTERNAL_API_SECRET: string; KILOCODE_BACKEND_BASE_URL: string; @@ -130,7 +132,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.sql" { const value: string; diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index cc949a42c0..c26004add7 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -64,6 +64,7 @@ "CLOUD_AGENT_CONTAINER_BILLING_ENABLED": "true", "CLOUD_AGENT_CONTAINER_BILLING_USER_IDS": "f1a848ca-bade-48d8-a5ad-1042d08651e6", "CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS": "", + "CONTAINER_BILLING_HEARTBEAT_SECONDS": "300", }, "placement": { "mode": "smart" }, /** @@ -432,6 +433,7 @@ "KILOCODE_TOKEN_CONTAINMENT_ORG_IDS": "", "REPO_SNAPSHOT_ORG_IDS": "", "TOOL_CGROUP_ORG_IDS": "", + "CONTAINER_BILLING_HEARTBEAT_SECONDS": "60", }, "services": [ {