Skip to content

Commit b032abe

Browse files
authored
fix(web): allow unlimited plan models at zero balance (#7187)
* fix(web): skip zero-balance gate for unlimited models * test(web): cover balance gate model argument * fix(web): suppress low-balance warning for unlimited models
1 parent 3df6db4 commit b032abe

10 files changed

Lines changed: 326 additions & 18 deletions

apps/web/src/components/EntryShell.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,11 @@ import type { OnboardingEntry } from '../onboarding/onboarding-entry';
134134
import type { PluginUseAction } from './plugins-home/useActions';
135135
import { Icon } from './Icon';
136136
import { Button } from '@open-design/components';
137-
import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection';
137+
import {
138+
defaultAgentModelId,
139+
effectiveAgentModelChoice,
140+
effectiveAgentModelId,
141+
} from './agentModelSelection';
138142
import { AgentIcon } from './AgentIcon';
139143
import { CommunityView } from './CommunityView';
140144
import { TeamSlotPlaceholder } from './TeamSlotPlaceholder';
@@ -1348,6 +1352,10 @@ export function EntryShell({
13481352
let amrGatePrecheckWitness: AmrBalanceGateScope | undefined;
13491353
let amrGatePrecheckPassed = false;
13501354
if (config.mode === 'daemon' && config.agentId === 'amr') {
1355+
const amrModelId = effectiveAgentModelId(
1356+
agents.find((agent) => agent.id === 'amr'),
1357+
config.agentModels?.amr,
1358+
);
13511359
// PRODUCT INVARIANT: Send never starts Workspace identity discovery.
13521360
// Billing consumes the shell's current in-memory snapshot; if it has not
13531361
// arrived yet, the existing account-scoped gate is used. The daemon's
@@ -1364,7 +1372,7 @@ export function EntryShell({
13641372
const gateWorkspaceIdentity = workspaceIdentityCacheKey(gateWorkspaceContext);
13651373
const gateScope = amrBalanceGateScopeForWorkspaceContext(gateWorkspaceContext);
13661374
let gate = await retryUnavailableAmrBalanceGate(
1367-
() => checkAmrBalanceGate(gateScope),
1375+
() => checkAmrBalanceGate(gateScope, amrModelId),
13681376
);
13691377
// Hard blocks hold THIS submit open: the dialog resolves 'retry' when
13701378
// its blocking condition clears (sign-in completed, recharge landed)
@@ -1383,7 +1391,7 @@ export function EntryShell({
13831391
setAmrBalanceGateBlock(null);
13841392
if (decision === 'dismiss') return 'blocked' as const;
13851393
gate = await retryUnavailableAmrBalanceGate(
1386-
() => checkAmrBalanceGate(gateScope),
1394+
() => checkAmrBalanceGate(gateScope, amrModelId),
13871395
);
13881396
}
13891397
if (gate.kind === 'unavailable') return false;

apps/web/src/components/ProjectView.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ import { buildContinueInCliToast } from '../lib/build-continue-in-cli-toast';
333333
import { buildClipboardPrompt } from '../lib/build-clipboard-prompt';
334334
import { copyToClipboard } from '../lib/copy-to-clipboard';
335335
import { effectiveMaxTokens } from '../state/maxTokens';
336-
import { effectiveAgentModelChoice } from './agentModelSelection';
336+
import { effectiveAgentModelChoice, effectiveAgentModelId } from './agentModelSelection';
337337
import { mediaExecutionPolicyForProjectMetadata } from '../media/execution-policy';
338338
import { mediaModelProviderId } from '../media/models';
339339
import { byokProviderRequiresApiKey } from '../utils/byokProvider';
@@ -6709,6 +6709,10 @@ export function ProjectView({
67096709
persistedWorkspaceId.length > 0
67106710
|| projectWorkspaceScopeState.scope?.kind === 'unbound'
67116711
);
6712+
const amrModelId = effectiveAgentModelId(
6713+
agentsById.get('amr'),
6714+
config.agentModels?.amr,
6715+
);
67126716
const gate =
67136717
deferAmrPreflightToDaemon
67146718
? { kind: 'allow' as const }
@@ -6721,6 +6725,7 @@ export function ProjectView({
67216725
projectRunPreflightContext.workspaceMemberId,
67226726
}
67236727
: undefined,
6728+
amrModelId,
67246729
);
67256730
// A blocked send parks in the conversation queue with its FULL
67266731
// payload (prompt, attachments, comment context) — the composer

apps/web/src/components/agentModelSelection.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export function effectiveAgentModelChoice(
4848
return normalizeAgentModelChoice(agent, choice) ?? choice;
4949
}
5050

51+
export function effectiveAgentModelId(
52+
agent: AgentModelSource,
53+
choice: AgentModelChoice | undefined,
54+
): string | null {
55+
const configuredModel = effectiveAgentModelChoice(agent, choice)?.model?.trim();
56+
return configuredModel && configuredModel !== 'default'
57+
? configuredModel
58+
: defaultAgentModelId(agent);
59+
}
60+
5161
/**
5262
* Whether `modelId` may be OFFERED to the user as a selectable model.
5363
*

apps/web/src/runtime/amr-balance-gate.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type {
1818
WorkspaceBillingResponse,
1919
} from '@open-design/contracts';
2020
import { fetchAmrWalletSnapshot } from '../providers/daemon';
21+
import { resolveAmrPlan } from './amr-low-balance-plan';
22+
import { isUnlimitedAmrModelForPlan } from './amr-unlimited-models';
2123

2224
/**
2325
* Hard-block line (USD): at or below this the wallet cannot fund any part of
@@ -229,6 +231,7 @@ async function fetchWorkspaceWalletSnapshot(
229231

230232
async function checkWorkspaceBalanceGate(
231233
scope: AmrBalanceGateScope,
234+
modelId?: string | null,
232235
): Promise<AmrBalanceGateResult> {
233236
// The URL carries the selected workspace identity. The daemon authorizes
234237
// that exact directory membership and returns a v2 identity-stamped wallet.
@@ -259,6 +262,10 @@ async function checkWorkspaceBalanceGate(
259262
}
260263
const balance = amrWalletBalanceUsd(workspaceSnapshot);
261264
if (balance == null) return { kind: 'unavailable' };
265+
if (balance <= AMR_LOW_BALANCE_WARN_USD && scope.workspaceType === 'personal') {
266+
const plan = await resolveAmrPlan(workspaceSnapshot!);
267+
if (isUnlimitedAmrModelForPlan(plan, modelId)) return { kind: 'allow' };
268+
}
262269
if (balance <= AMR_HARD_BLOCK_BALANCE_USD) {
263270
return {
264271
kind: 'hard',
@@ -274,10 +281,11 @@ async function checkWorkspaceBalanceGate(
274281

275282
export async function checkAmrBalanceGate(
276283
scope?: AmrBalanceGateScope,
284+
modelId?: string | null,
277285
): Promise<AmrBalanceGateResult> {
278286
try {
279287
if (scope) {
280-
return await checkWorkspaceBalanceGate(scope);
288+
return await checkWorkspaceBalanceGate(scope, modelId);
281289
}
282290
const cached = await fetchAmrWalletSnapshot().catch(() => null);
283291
const cachedBalance = amrWalletBalanceUsd(cached);
@@ -290,6 +298,8 @@ export async function checkAmrBalanceGate(
290298
return { kind: 'allow' };
291299
}
292300
// cached is non-null here: a definitive balance implies a snapshot.
301+
const plan = await resolveAmrPlan(cached!);
302+
if (isUnlimitedAmrModelForPlan(plan, modelId)) return { kind: 'allow' };
293303
return { kind: 'soft', snapshot: cached! };
294304
}
295305
// Hard-block candidate (signed out or empty): confirm against the live
@@ -309,6 +319,10 @@ export async function checkAmrBalanceGate(
309319
if (fresh.stale || fresh.error != null) return { kind: 'allow' };
310320
const freshBalance = amrWalletBalanceUsd(fresh);
311321
if (freshBalance == null) return { kind: 'allow' };
322+
if (freshBalance <= AMR_LOW_BALANCE_WARN_USD) {
323+
const plan = await resolveAmrPlan(fresh);
324+
if (isUnlimitedAmrModelForPlan(plan, modelId)) return { kind: 'allow' };
325+
}
312326
if (freshBalance <= AMR_HARD_BLOCK_BALANCE_USD) {
313327
return { kind: 'hard', reason: 'insufficient', snapshot: fresh };
314328
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const GO_UNLIMITED_MODELS = [
2+
'deepseek-v4-flash',
3+
'deepseek-v4-pro',
4+
'glm-5.2',
5+
] as const;
6+
7+
const PLUS_UNLIMITED_MODELS = [
8+
...GO_UNLIMITED_MODELS,
9+
'kimi-k2.7-code',
10+
] as const;
11+
12+
const PRO_UNLIMITED_MODELS = [
13+
'deepseek-v4-flash',
14+
'deepseek-v4-pro',
15+
'glm-5.2',
16+
'kimi-k2.7-code',
17+
'mimo-v2.5-pro',
18+
] as const;
19+
20+
const MAX_UNLIMITED_MODELS = [
21+
...PRO_UNLIMITED_MODELS,
22+
'minimax-m2.7',
23+
'kimi-k2.6',
24+
'glm-5.1',
25+
] as const;
26+
27+
// This table only decides whether the client-side balance preflight may
28+
// stand down. Vela remains authoritative for plan access and usage limits.
29+
const UNLIMITED_MODELS_BY_PLAN: Readonly<Record<string, ReadonlySet<string>>> = {
30+
go: new Set(GO_UNLIMITED_MODELS),
31+
plus: new Set(PLUS_UNLIMITED_MODELS),
32+
pro: new Set(PRO_UNLIMITED_MODELS),
33+
max: new Set(MAX_UNLIMITED_MODELS),
34+
};
35+
36+
function normalize(value: string | null | undefined): string {
37+
return value?.trim().toLowerCase() ?? '';
38+
}
39+
40+
export function isUnlimitedAmrModelForPlan(
41+
plan: string | null | undefined,
42+
modelId: string | null | undefined,
43+
): boolean {
44+
const models = UNLIMITED_MODELS_BY_PLAN[normalize(plan)];
45+
return models?.has(normalize(modelId)) ?? false;
46+
}

apps/web/tests/components/EntryShell.amr-workspace-race.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,9 @@ describe('EntryShell AMR workspace precheck race', () => {
310310
setHomeHeroPrompt('Create a poster after I sign in.');
311311
fireEvent.click(await screen.findByTestId('home-hero-submit'));
312312

313-
await waitFor(() => expect(mockedCheckAmrBalanceGate).toHaveBeenCalledWith(undefined));
313+
await waitFor(() => {
314+
expect(mockedCheckAmrBalanceGate).toHaveBeenCalledWith(undefined, 'glm-5');
315+
});
314316
const dialog = await screen.findByTestId('amr-balance-dialog');
315317
expect(dialog.getAttribute('data-reason')).toBe('signed_out');
316318
expect(onCreateProject).not.toHaveBeenCalled();
@@ -393,7 +395,9 @@ describe('EntryShell AMR workspace precheck race', () => {
393395
setHomeHeroPrompt('Create through the old daemon compatibility lane.');
394396
fireEvent.click(await screen.findByTestId('home-hero-submit'));
395397

396-
await waitFor(() => expect(mockedCheckAmrBalanceGate).toHaveBeenCalledWith(undefined));
398+
await waitFor(() => {
399+
expect(mockedCheckAmrBalanceGate).toHaveBeenCalledWith(undefined, 'glm-5');
400+
});
397401
await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(1));
398402
});
399403

@@ -890,7 +894,7 @@ describe('EntryShell AMR workspace precheck race', () => {
890894
workspaceType: 'team',
891895
workspaceId: 'workspace-a',
892896
workspaceMemberId: 'member-a',
893-
});
897+
}, 'glm-5');
894898
});
895899

896900
currentContext = workspaceB;
@@ -905,7 +909,7 @@ describe('EntryShell AMR workspace precheck race', () => {
905909
workspaceType: 'team',
906910
workspaceId: 'workspace-b',
907911
workspaceMemberId: 'member-b',
908-
});
912+
}, 'glm-5');
909913
});
910914
await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(1));
911915
expect(onCreateProject).toHaveBeenCalledWith(

apps/web/tests/components/ProjectView.run-workspace-identity.test.tsx

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,16 @@ function projectViewElement(overrides: Partial<ComponentProps<typeof ProjectView
412412
project={project()}
413413
routeFileName={null}
414414
config={config}
415-
agents={[{ id: 'amr', name: 'amr', available: true }] as unknown as AgentInfo[]}
415+
agents={[{
416+
id: 'amr',
417+
name: 'amr',
418+
available: true,
419+
models: [{
420+
id: 'deepseek-v4-flash',
421+
label: 'DeepSeek V4 Flash',
422+
default: true,
423+
}],
424+
}] as unknown as AgentInfo[]}
416425
skills={[] as SkillSummary[]}
417426
designTemplates={[] as SkillSummary[]}
418427
designSystems={[] as DesignSystemSummary[]}
@@ -502,7 +511,7 @@ describe('a Home auto-send identifies its caller before the project scope resolv
502511
workspaceType: 'team',
503512
workspaceId: TEAM_WORKSPACE,
504513
workspaceMemberId: TEAM_MEMBER,
505-
});
514+
}, 'deepseek-v4-flash');
506515
const options = mockedStreamViaDaemon.mock.calls[0]?.[0];
507516
expect(
508517
options?.workspaceContext,
@@ -997,7 +1006,7 @@ describe('a Home auto-send identifies its caller before the project scope resolv
9971006
workspaceType: 'team',
9981007
workspaceId: TEAM_WORKSPACE,
9991008
workspaceMemberId: TEAM_MEMBER,
1000-
});
1009+
}, 'deepseek-v4-flash');
10011010
expect(mockedStreamViaDaemon.mock.calls[0]?.[0].workspaceContext).toEqual(
10021011
CALLER_CONTEXT,
10031012
);
@@ -1018,7 +1027,16 @@ describe('a Home auto-send identifies its caller before the project scope resolv
10181027

10191028
const stableOverrides: Partial<ComponentProps<typeof ProjectView>> = {
10201029
project: project(),
1021-
agents: [{ id: 'amr', name: 'amr', available: true }] as unknown as AgentInfo[],
1030+
agents: [{
1031+
id: 'amr',
1032+
name: 'amr',
1033+
available: true,
1034+
models: [{
1035+
id: 'deepseek-v4-flash',
1036+
label: 'DeepSeek V4 Flash',
1037+
default: true,
1038+
}],
1039+
}] as unknown as AgentInfo[],
10221040
skills: [] as SkillSummary[],
10231041
designTemplates: [] as SkillSummary[],
10241042
designSystems: [] as DesignSystemSummary[],
@@ -1080,7 +1098,7 @@ describe('a Home auto-send identifies its caller before the project scope resolv
10801098
workspaceType: 'personal',
10811099
workspaceId: PERSONAL_CONTEXT.workspaceId,
10821100
workspaceMemberId: PERSONAL_CONTEXT.workspaceMemberId,
1083-
});
1101+
}, 'deepseek-v4-flash');
10841102
});
10851103
await waitFor(() => expect(mockedStreamViaDaemon).toHaveBeenCalled());
10861104
});
@@ -1109,7 +1127,7 @@ describe('a Home auto-send identifies its caller before the project scope resolv
11091127
workspaceType: 'personal',
11101128
workspaceId: PERSONAL_CONTEXT.workspaceId,
11111129
workspaceMemberId: PERSONAL_CONTEXT.workspaceMemberId,
1112-
});
1130+
}, 'deepseek-v4-flash');
11131131
});
11141132
await waitFor(() => expect(mockedStreamViaDaemon).toHaveBeenCalled());
11151133
expect(mockedStreamViaDaemon.mock.calls[0]?.[0].workspaceContext).toEqual(
@@ -1205,7 +1223,16 @@ describe('a Home auto-send observes a project billing scope that settles after m
12051223
// only dependency allowed to change in this regression.
12061224
const stableOverrides: Partial<ComponentProps<typeof ProjectView>> = {
12071225
project: project(),
1208-
agents: [{ id: 'amr', name: 'amr', available: true }] as unknown as AgentInfo[],
1226+
agents: [{
1227+
id: 'amr',
1228+
name: 'amr',
1229+
available: true,
1230+
models: [{
1231+
id: 'deepseek-v4-flash',
1232+
label: 'DeepSeek V4 Flash',
1233+
default: true,
1234+
}],
1235+
}] as unknown as AgentInfo[],
12091236
skills: [] as SkillSummary[],
12101237
designTemplates: [] as SkillSummary[],
12111238
designSystems: [] as DesignSystemSummary[],
@@ -1249,7 +1276,7 @@ describe('a Home auto-send observes a project billing scope that settles after m
12491276
workspaceType: 'team',
12501277
workspaceId: TEAM_WORKSPACE,
12511278
workspaceMemberId: TEAM_MEMBER,
1252-
});
1279+
}, 'deepseek-v4-flash');
12531280
});
12541281
await waitFor(() => expect(mockedStreamViaDaemon).toHaveBeenCalled());
12551282
});

apps/web/tests/components/agentModelSelection.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
agentModelIsSelectable,
44
defaultAgentModelId,
55
effectiveAgentModelChoice,
6+
effectiveAgentModelId,
67
normalizeAgentModelChoice,
78
} from '../../src/components/agentModelSelection';
89
import type { AgentInfo } from '../../src/types';
@@ -61,6 +62,7 @@ describe('agent model selection', () => {
6162

6263
expect(normalizeAgentModelChoice(amrAgent, choice)).toBeNull();
6364
expect(effectiveAgentModelChoice(amrAgent, choice)).toEqual(choice);
65+
expect(effectiveAgentModelId(amrAgent, choice)).toBe('glm-5');
6466
});
6567

6668
it('does not select a disabled model as the AMR default when every catalog row is locked', () => {

0 commit comments

Comments
 (0)