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
57 changes: 41 additions & 16 deletions apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
: personalEligibilityQuery.isPending;
const hasInsufficientBalance =
!isEligibilityLoading && eligibilityData && !eligibilityData.isEligible;
const hasLimitedAccess = !isEligibilityLoading && eligibilityData?.accessLevel === 'limited';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: isFormValid still blocks submission for limited-access users, defeating this PR's goal

Further down in this component, isFormValid requires !hasInsufficientBalance, and hasInsufficientBalance is true whenever eligibilityData.isEligible is false — which is exactly the case whenever accessLevel is 'limited' (i.e. whenever hasLimitedAccess here is true). So for every zero/low-balance user, the submit button (disabled={!isFormValid || ...}) and the Cmd/Ctrl+Enter shortcut stay disabled even after they pick a free or BYOK-capable model from the filtered modelOptions. The stated purpose of this PR — letting zero-balance users start Cloud Agent sessions on free models — can't actually be exercised from this UI as written. isFormValid needs to permit submission when hasLimitedAccess is true and the selected model is free/BYOK-capable, mirroring the filter already applied to modelOptions.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


// ---------------------------------------------------------------------------
// Models
Expand All @@ -171,20 +172,19 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New

const allModels = modelsData?.data || [];

const modelOptions = useMemo<ModelOption[]>(
() =>
appendCloudAgentNextLocalTestModel(
allModels.map(model => ({
id: model.id,
name: model.name,
isFree: model.isFree,
mayTrainOnYourPrompts: model.mayTrainOnYourPrompts,
hasUserByokAvailable: model.hasUserByokAvailable,
variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined,
}))
),
[allModels]
);
const modelOptions = useMemo<ModelOption[]>(() => {
const options = allModels.map(model => ({
id: model.id,
name: model.name,
isFree: model.isFree,
mayTrainOnYourPrompts: model.mayTrainOnYourPrompts,
hasUserByokAvailable: model.hasUserByokAvailable,
variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined,
}));
const withLocalTest = appendCloudAgentNextLocalTestModel(options);
if (!hasLimitedAccess) return withLocalTest;
return withLocalTest.filter(option => option.isFree || option.hasUserByokAvailable);
}, [allModels, hasLimitedAccess]);

// ---------------------------------------------------------------------------
// Form state
Expand Down Expand Up @@ -872,12 +872,21 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
// ---------------------------------------------------------------------------
const isPromptTooLong = prompt.length > CLOUD_AGENT_PROMPT_MAX_LENGTH;

const selectedModelOption = modelOptions.find(m => m.id === model);
// Limited-access users can submit when they've picked a free or BYOK-capable
// model from the filtered picker; the server still gates paid models behind
// the minimum balance, but the submit button shouldn't pretend otherwise.
const limitedAccessModelIsAllowed =
hasLimitedAccess &&
!!selectedModelOption &&
(selectedModelOption.isFree || selectedModelOption.hasUserByokAvailable);

const isFormValid =
prompt.trim().length > 0 &&
!isPromptTooLong &&
model.length > 0 &&
!isPreparing &&
!hasInsufficientBalance &&
(!hasInsufficientBalance || limitedAccessModelIsAllowed) &&
!attachmentUpload.hasUploadingAttachments;

const handleStartSession = useCallback(async () => {
Expand Down Expand Up @@ -1158,14 +1167,30 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
<MobileSidebarToggle />
<div className="w-full max-w-2xl space-y-4">
{/* Insufficient balance banner */}
{hasInsufficientBalance && eligibilityData && (
{hasInsufficientBalance && eligibilityData && !hasLimitedAccess && (
<InsufficientBalanceBanner
balance={eligibilityData.balance}
organizationId={organizationId}
content={{ type: 'productName', productName: 'Cloud Agent' }}
/>
)}

{/* Free-models-available banner when balance is low but free models are usable */}
{hasLimitedAccess && eligibilityData && (
<InsufficientBalanceBanner
balance={eligibilityData.balance}
organizationId={organizationId}
colorScheme="info"
content={{
type: 'custom',
title: 'Free Models Available',
description:
'You can use free models in Cloud Agent. Add credits to unlock all models.',
compactActionText: 'Add credits to unlock all models',
}}
/>
)}

{/* Textarea + model toolbar container */}
<div
className={cn(
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/lib/access-level-eligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type AccessLevel = 'full' | 'limited' | 'blocked';

export type AccessLevelEligibility = {
balance: number;
minBalance: number;
accessLevel: AccessLevel;
isEligible: boolean;
};

export function buildAccessLevelEligibility(
balance: number,
minBalance: number
): AccessLevelEligibility {
const accessLevel: AccessLevel = balance >= minBalance ? 'full' : 'limited';
return {
balance,
minBalance,
accessLevel,
isEligible: accessLevel === 'full',
};
}
143 changes: 143 additions & 0 deletions apps/web/src/lib/cloud-agent-next/balance-check-eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
const mockIsFreeModel = jest.fn();
const mockGetModelUserByokProviders = jest.fn();
const mockGetUserByokProviderIds = jest.fn();
const mockGetOrganizationByokProviderIds = jest.fn();

jest.mock('@/lib/ai-gateway/is-free-model', () => ({
isFreeModel: (...args: unknown[]) => mockIsFreeModel(...args),
}));

jest.mock('@/lib/ai-gateway/byok', () => ({
getModelUserByokProviders: (...args: unknown[]) => mockGetModelUserByokProviders(...args),
getUserByokProviderIds: (...args: unknown[]) => mockGetUserByokProviderIds(...args),
getOrganizationByokProviderIds: (...args: unknown[]) =>
mockGetOrganizationByokProviderIds(...args),
}));

import { computeCloudAgentNextBalanceCheckEligibility } from './balance-check-eligibility';

const KILO_EXCLUSIVE_MODEL = 'deepseek/deepseek-v4-pro:discounted';
const NON_EXCLUSIVE_MODEL = 'anthropic/claude-sonnet-4';

const fakeDb = {} as never;
const fakeUser = { id: 'user-1' };

beforeEach(() => {
jest.clearAllMocks();
mockIsFreeModel.mockResolvedValue(false);
mockGetModelUserByokProviders.mockResolvedValue([]);
mockGetUserByokProviderIds.mockResolvedValue([]);
mockGetOrganizationByokProviderIds.mockResolvedValue([]);
});

describe('computeCloudAgentNextBalanceCheckEligibility', () => {
it('returns isFree and skips BYOK when the model is free', async () => {
mockIsFreeModel.mockResolvedValueOnce(true);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: 'kilo/free-model',
});

expect(result).toEqual({ isFree: true, hasUserByokAvailable: false });
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
});

it('returns hasUserByokAvailable: false for a Kilo-exclusive model even when BYOK providers can serve it', async () => {
const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: KILO_EXCLUSIVE_MODEL,
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
expect(mockGetOrganizationByokProviderIds).not.toHaveBeenCalled();
});

it('returns hasUserByokAvailable: false for a Kilo-exclusive model even when the user has an enabled matching BYOK provider', async () => {
mockGetUserByokProviderIds.mockResolvedValueOnce(['openrouter']);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: KILO_EXCLUSIVE_MODEL,
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
});

it('returns hasUserByokAvailable: false for a Kilo-exclusive model in an organization context', async () => {
mockGetOrganizationByokProviderIds.mockResolvedValueOnce(['openrouter']);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: KILO_EXCLUSIVE_MODEL,
organizationId: 'org-1',
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
expect(mockGetOrganizationByokProviderIds).not.toHaveBeenCalled();
});

it('returns hasUserByokAvailable: true for a non-Kilo-exclusive paid model with a matching enabled user BYOK provider', async () => {
mockGetModelUserByokProviders.mockResolvedValueOnce(['openrouter']);
mockGetUserByokProviderIds.mockResolvedValueOnce(['openrouter']);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: NON_EXCLUSIVE_MODEL,
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: true });
});

it('returns hasUserByokAvailable: false for a non-Kilo-exclusive paid model with no matching BYOK provider', async () => {
mockGetModelUserByokProviders.mockResolvedValueOnce(['openrouter']);
mockGetUserByokProviderIds.mockResolvedValueOnce(['anthropic']);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: NON_EXCLUSIVE_MODEL,
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
});

it('returns hasUserByokAvailable: false for a non-Kilo-exclusive paid model with no resolvable providers', async () => {
mockGetModelUserByokProviders.mockResolvedValueOnce([]);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: NON_EXCLUSIVE_MODEL,
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
});

it('uses organization BYOK providers for a non-Kilo-exclusive paid model when organizationId is provided', async () => {
mockGetModelUserByokProviders.mockResolvedValueOnce(['openrouter']);
mockGetOrganizationByokProviderIds.mockResolvedValueOnce(['openrouter']);

const result = await computeCloudAgentNextBalanceCheckEligibility({
fromDb: fakeDb,
user: fakeUser,
modelId: NON_EXCLUSIVE_MODEL,
organizationId: 'org-1',
});

expect(result).toEqual({ isFree: false, hasUserByokAvailable: true });
expect(mockGetOrganizationByokProviderIds).toHaveBeenCalledWith(fakeDb, 'org-1');
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
});
});
66 changes: 66 additions & 0 deletions apps/web/src/lib/cloud-agent-next/balance-check-eligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'server-only';
import { type db } from '@/lib/drizzle';
import { isFreeModel } from '@/lib/ai-gateway/is-free-model';
import { isKiloExclusiveModel } from '@/lib/ai-gateway/models';
import {
getModelUserByokProviders,
getOrganizationByokProviderIds,
getUserByokProviderIds,
} from '@/lib/ai-gateway/byok';
import type { User } from '@kilocode/db/schema';

export type BalanceCheckModelEligibility = {
isFree: boolean;
hasUserByokAvailable: boolean;
};

/**
* Decide whether `prepareSession` should skip the worker-side $1 balance
* minimum for the chosen model.
*
* Skips the check when either:
* - the model is Kilo-funded (free for the user), or
* - the model is not Kilo-exclusive AND the user has a BYOK provider
* configured that can serve it, so the session is billed against the
* user's own key rather than their balance.
*
* Kilo-exclusive models (e.g. `deepseek/deepseek-v4-pro:discounted`) are
* always excluded from the BYOK bypass: they are Kilo-funded and platform
* billed, so even when `getModelUserByokProviders` reports a provider that
* can route the model, they must still go through the worker-side balance
* check and cannot be legitimately served via a user's own BYOK key.
*
* The resulting predicate is a strict subset of the
* `isFree || hasUserByokAvailable` predicate used by the NewSessionPanel
* model picker to filter `hasLimitedAccess` users: this router additionally
* forces Kilo-exclusive models through the balance check, so the picker
* may offer a model as free while the router still requires a balance.
*/
export async function computeCloudAgentNextBalanceCheckEligibility(params: {
fromDb: typeof db;
user: Pick<User, 'id'>;
modelId: string;
organizationId?: string;
}): Promise<BalanceCheckModelEligibility> {
const isFree = await isFreeModel(params.modelId);
if (isFree) {
return { isFree: true, hasUserByokAvailable: false };
}

if (isKiloExclusiveModel(params.modelId)) {
return { isFree: false, hasUserByokAvailable: false };
}

const modelProviders = await getModelUserByokProviders(params.modelId);
if (modelProviders.length === 0) {
return { isFree: false, hasUserByokAvailable: false };
}

const enabledProviderIds = params.organizationId
? await getOrganizationByokProviderIds(params.fromDb, params.organizationId)
: await getUserByokProviderIds(params.fromDb, params.user.id);

const enabled = new Set(enabledProviderIds);
const hasUserByokAvailable = modelProviders.some(provider => enabled.has(provider));
return { isFree: false, hasUserByokAvailable };
}
Loading