Skip to content

Commit 45e1063

Browse files
feat(web): allow free-model sessions on zero balance and unify access-level eligibility (#4367)
1 parent 52200ac commit 45e1063

13 files changed

Lines changed: 640 additions & 72 deletions

apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
163163
: personalEligibilityQuery.isPending;
164164
const hasInsufficientBalance =
165165
!isEligibilityLoading && eligibilityData && !eligibilityData.isEligible;
166+
const hasLimitedAccess = !isEligibilityLoading && eligibilityData?.accessLevel === 'limited';
166167

167168
// ---------------------------------------------------------------------------
168169
// Models
@@ -174,20 +175,19 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
174175

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

177-
const modelOptions = useMemo<ModelOption[]>(
178-
() =>
179-
appendCloudAgentNextLocalTestModel(
180-
allModels.map(model => ({
181-
id: model.id,
182-
name: model.name,
183-
isFree: model.isFree,
184-
mayTrainOnYourPrompts: model.mayTrainOnYourPrompts,
185-
hasUserByokAvailable: model.hasUserByokAvailable,
186-
variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined,
187-
}))
188-
),
189-
[allModels]
190-
);
178+
const modelOptions = useMemo<ModelOption[]>(() => {
179+
const options = allModels.map(model => ({
180+
id: model.id,
181+
name: model.name,
182+
isFree: model.isFree,
183+
mayTrainOnYourPrompts: model.mayTrainOnYourPrompts,
184+
hasUserByokAvailable: model.hasUserByokAvailable,
185+
variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined,
186+
}));
187+
const withLocalTest = appendCloudAgentNextLocalTestModel(options);
188+
if (!hasLimitedAccess) return withLocalTest;
189+
return withLocalTest.filter(option => option.isFree || option.hasUserByokAvailable);
190+
}, [allModels, hasLimitedAccess]);
191191

192192
// ---------------------------------------------------------------------------
193193
// Form state
@@ -888,12 +888,21 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
888888
// ---------------------------------------------------------------------------
889889
const isPromptTooLong = prompt.length > CLOUD_AGENT_PROMPT_MAX_LENGTH;
890890

891+
const selectedModelOption = modelOptions.find(m => m.id === model);
892+
// Limited-access users can submit when they've picked a free or BYOK-capable
893+
// model from the filtered picker; the server still gates paid models behind
894+
// the minimum balance, but the submit button shouldn't pretend otherwise.
895+
const limitedAccessModelIsAllowed =
896+
hasLimitedAccess &&
897+
!!selectedModelOption &&
898+
(selectedModelOption.isFree || selectedModelOption.hasUserByokAvailable);
899+
891900
const isFormValid =
892901
prompt.trim().length > 0 &&
893902
!isPromptTooLong &&
894903
model.length > 0 &&
895904
!isPreparing &&
896-
!hasInsufficientBalance &&
905+
(!hasInsufficientBalance || limitedAccessModelIsAllowed) &&
897906
!attachmentUpload.hasUploadingAttachments;
898907

899908
const handleStartSession = useCallback(async () => {
@@ -1174,14 +1183,30 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New
11741183
<MobileSidebarToggle />
11751184
<div className="w-full max-w-2xl space-y-4">
11761185
{/* Insufficient balance banner */}
1177-
{hasInsufficientBalance && eligibilityData && (
1186+
{hasInsufficientBalance && eligibilityData && !hasLimitedAccess && (
11781187
<InsufficientBalanceBanner
11791188
balance={eligibilityData.balance}
11801189
organizationId={organizationId}
11811190
content={{ type: 'productName', productName: 'Cloud Agent' }}
11821191
/>
11831192
)}
11841193

1194+
{/* Free-models-available banner when balance is low but free models are usable */}
1195+
{hasLimitedAccess && eligibilityData && (
1196+
<InsufficientBalanceBanner
1197+
balance={eligibilityData.balance}
1198+
organizationId={organizationId}
1199+
colorScheme="info"
1200+
content={{
1201+
type: 'custom',
1202+
title: 'Free Models Available',
1203+
description:
1204+
'You can use free models in Cloud Agent. Add credits to unlock all models.',
1205+
compactActionText: 'Add credits to unlock all models',
1206+
}}
1207+
/>
1208+
)}
1209+
11851210
{/* Textarea + model toolbar container */}
11861211
<div
11871212
className={cn(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export type AccessLevel = 'full' | 'limited' | 'blocked';
2+
3+
export type AccessLevelEligibility = {
4+
balance: number;
5+
minBalance: number;
6+
accessLevel: AccessLevel;
7+
isEligible: boolean;
8+
};
9+
10+
export function buildAccessLevelEligibility(
11+
balance: number,
12+
minBalance: number
13+
): AccessLevelEligibility {
14+
const accessLevel: AccessLevel = balance >= minBalance ? 'full' : 'limited';
15+
return {
16+
balance,
17+
minBalance,
18+
accessLevel,
19+
isEligible: accessLevel === 'full',
20+
};
21+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
const mockIsFreeModel = jest.fn();
2+
const mockGetModelUserByokProviders = jest.fn();
3+
const mockGetUserByokProviderIds = jest.fn();
4+
const mockGetOrganizationByokProviderIds = jest.fn();
5+
6+
jest.mock('@/lib/ai-gateway/is-free-model', () => ({
7+
isFreeModel: (...args: unknown[]) => mockIsFreeModel(...args),
8+
}));
9+
10+
jest.mock('@/lib/ai-gateway/byok', () => ({
11+
getModelUserByokProviders: (...args: unknown[]) => mockGetModelUserByokProviders(...args),
12+
getUserByokProviderIds: (...args: unknown[]) => mockGetUserByokProviderIds(...args),
13+
getOrganizationByokProviderIds: (...args: unknown[]) =>
14+
mockGetOrganizationByokProviderIds(...args),
15+
}));
16+
17+
import { computeCloudAgentNextBalanceCheckEligibility } from './balance-check-eligibility';
18+
19+
const KILO_EXCLUSIVE_MODEL = 'deepseek/deepseek-v4-pro:discounted';
20+
const NON_EXCLUSIVE_MODEL = 'anthropic/claude-sonnet-4';
21+
22+
const fakeDb = {} as never;
23+
const fakeUser = { id: 'user-1' };
24+
25+
beforeEach(() => {
26+
jest.resetAllMocks();
27+
mockIsFreeModel.mockResolvedValue(false);
28+
mockGetModelUserByokProviders.mockResolvedValue([]);
29+
mockGetUserByokProviderIds.mockResolvedValue([]);
30+
mockGetOrganizationByokProviderIds.mockResolvedValue([]);
31+
});
32+
33+
describe('computeCloudAgentNextBalanceCheckEligibility', () => {
34+
it('returns isFree and skips BYOK when the model is free', async () => {
35+
mockIsFreeModel.mockResolvedValueOnce(true);
36+
37+
const result = await computeCloudAgentNextBalanceCheckEligibility({
38+
fromDb: fakeDb,
39+
user: fakeUser,
40+
modelId: 'kilo/free-model',
41+
});
42+
43+
expect(result).toEqual({ isFree: true, hasUserByokAvailable: false });
44+
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
45+
});
46+
47+
it('returns hasUserByokAvailable: false for a Kilo-exclusive model even when BYOK providers can serve it', async () => {
48+
const result = await computeCloudAgentNextBalanceCheckEligibility({
49+
fromDb: fakeDb,
50+
user: fakeUser,
51+
modelId: KILO_EXCLUSIVE_MODEL,
52+
});
53+
54+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
55+
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
56+
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
57+
expect(mockGetOrganizationByokProviderIds).not.toHaveBeenCalled();
58+
});
59+
60+
it('returns hasUserByokAvailable: false for a Kilo-exclusive model even when the user has an enabled matching BYOK provider', async () => {
61+
mockGetUserByokProviderIds.mockResolvedValueOnce(['openrouter']);
62+
63+
const result = await computeCloudAgentNextBalanceCheckEligibility({
64+
fromDb: fakeDb,
65+
user: fakeUser,
66+
modelId: KILO_EXCLUSIVE_MODEL,
67+
});
68+
69+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
70+
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
71+
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
72+
});
73+
74+
it('returns hasUserByokAvailable: false for a Kilo-exclusive model in an organization context', async () => {
75+
mockGetOrganizationByokProviderIds.mockResolvedValueOnce(['openrouter']);
76+
77+
const result = await computeCloudAgentNextBalanceCheckEligibility({
78+
fromDb: fakeDb,
79+
user: fakeUser,
80+
modelId: KILO_EXCLUSIVE_MODEL,
81+
organizationId: 'org-1',
82+
});
83+
84+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
85+
expect(mockGetModelUserByokProviders).not.toHaveBeenCalled();
86+
expect(mockGetOrganizationByokProviderIds).not.toHaveBeenCalled();
87+
});
88+
89+
it('returns hasUserByokAvailable: true for a non-Kilo-exclusive paid model with a matching enabled user BYOK provider', async () => {
90+
mockGetModelUserByokProviders.mockResolvedValueOnce(['openrouter']);
91+
mockGetUserByokProviderIds.mockResolvedValueOnce(['openrouter']);
92+
93+
const result = await computeCloudAgentNextBalanceCheckEligibility({
94+
fromDb: fakeDb,
95+
user: fakeUser,
96+
modelId: NON_EXCLUSIVE_MODEL,
97+
});
98+
99+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: true });
100+
});
101+
102+
it('returns hasUserByokAvailable: false for a non-Kilo-exclusive paid model with no matching BYOK provider', async () => {
103+
mockGetModelUserByokProviders.mockResolvedValueOnce(['openrouter']);
104+
mockGetUserByokProviderIds.mockResolvedValueOnce(['anthropic']);
105+
106+
const result = await computeCloudAgentNextBalanceCheckEligibility({
107+
fromDb: fakeDb,
108+
user: fakeUser,
109+
modelId: NON_EXCLUSIVE_MODEL,
110+
});
111+
112+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
113+
});
114+
115+
it('returns hasUserByokAvailable: false for a non-Kilo-exclusive paid model with no resolvable providers', async () => {
116+
mockGetModelUserByokProviders.mockResolvedValueOnce([]);
117+
118+
const result = await computeCloudAgentNextBalanceCheckEligibility({
119+
fromDb: fakeDb,
120+
user: fakeUser,
121+
modelId: NON_EXCLUSIVE_MODEL,
122+
});
123+
124+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: false });
125+
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
126+
});
127+
128+
it('uses organization BYOK providers for a non-Kilo-exclusive paid model when organizationId is provided', async () => {
129+
mockGetModelUserByokProviders.mockResolvedValueOnce(['openrouter']);
130+
mockGetOrganizationByokProviderIds.mockResolvedValueOnce(['openrouter']);
131+
132+
const result = await computeCloudAgentNextBalanceCheckEligibility({
133+
fromDb: fakeDb,
134+
user: fakeUser,
135+
modelId: NON_EXCLUSIVE_MODEL,
136+
organizationId: 'org-1',
137+
});
138+
139+
expect(result).toEqual({ isFree: false, hasUserByokAvailable: true });
140+
expect(mockGetOrganizationByokProviderIds).toHaveBeenCalledWith(fakeDb, 'org-1');
141+
expect(mockGetUserByokProviderIds).not.toHaveBeenCalled();
142+
});
143+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import 'server-only';
2+
import { type db } from '@/lib/drizzle';
3+
import { isFreeModel } from '@/lib/ai-gateway/is-free-model';
4+
import { isKiloExclusiveModel } from '@/lib/ai-gateway/models';
5+
import {
6+
getModelUserByokProviders,
7+
getOrganizationByokProviderIds,
8+
getUserByokProviderIds,
9+
} from '@/lib/ai-gateway/byok';
10+
import type { User } from '@kilocode/db/schema';
11+
12+
export type BalanceCheckModelEligibility = {
13+
isFree: boolean;
14+
hasUserByokAvailable: boolean;
15+
};
16+
17+
/**
18+
* Decide whether `prepareSession` should skip the worker-side $1 balance
19+
* minimum for the chosen model.
20+
*
21+
* Skips the check when either:
22+
* - the model is Kilo-funded (free for the user), or
23+
* - the model is not Kilo-exclusive AND the user has a BYOK provider
24+
* configured that can serve it, so the session is billed against the
25+
* user's own key rather than their balance.
26+
*
27+
* Kilo-exclusive models (e.g. `deepseek/deepseek-v4-pro:discounted`) are
28+
* always excluded from the BYOK bypass: they are Kilo-funded and platform
29+
* billed, so even when `getModelUserByokProviders` reports a provider that
30+
* can route the model, they must still go through the worker-side balance
31+
* check and cannot be legitimately served via a user's own BYOK key.
32+
*
33+
* The resulting predicate is a strict subset of the
34+
* `isFree || hasUserByokAvailable` predicate used by the NewSessionPanel
35+
* model picker to filter `hasLimitedAccess` users: this router additionally
36+
* forces Kilo-exclusive models through the balance check, so the picker
37+
* may offer a model as free while the router still requires a balance.
38+
*/
39+
export async function computeCloudAgentNextBalanceCheckEligibility(params: {
40+
fromDb: typeof db;
41+
user: Pick<User, 'id'>;
42+
modelId: string;
43+
organizationId?: string;
44+
}): Promise<BalanceCheckModelEligibility> {
45+
const isFree = await isFreeModel(params.modelId);
46+
if (isFree) {
47+
return { isFree: true, hasUserByokAvailable: false };
48+
}
49+
50+
if (isKiloExclusiveModel(params.modelId)) {
51+
return { isFree: false, hasUserByokAvailable: false };
52+
}
53+
54+
const modelProviders = await getModelUserByokProviders(params.modelId);
55+
if (modelProviders.length === 0) {
56+
return { isFree: false, hasUserByokAvailable: false };
57+
}
58+
59+
const enabledProviderIds = params.organizationId
60+
? await getOrganizationByokProviderIds(params.fromDb, params.organizationId)
61+
: await getUserByokProviderIds(params.fromDb, params.user.id);
62+
63+
const enabled = new Set(enabledProviderIds);
64+
const hasUserByokAvailable = modelProviders.some(provider => enabled.has(provider));
65+
return { isFree: false, hasUserByokAvailable };
66+
}

0 commit comments

Comments
 (0)