Skip to content

Commit bc3e418

Browse files
ehayes2000claude
andauthored
fix(ai): model selector for free users (2063) (#4341)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 03c75ba commit bc3e418

5 files changed

Lines changed: 117 additions & 22 deletions

File tree

js/app/packages/core/component/AI/component/input/ChatInput.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { useAnalytics } from '@app/component/analytics-context';
2+
import { useHasPaidAccess } from '@core/auth/license';
23
import type { ChatSendInput } from '@core/component/AI/component/input/buildRequest';
34
import { ModelSelector } from '@core/component/AI/component/input/ModelSelector';
5+
import {
6+
defaultModelForPlan,
7+
FREE_MODELS,
8+
PAID_MODELS,
9+
} from '@core/component/AI/constant';
410
import { useChatInputContext } from '@core/component/AI/context';
5-
import { Model, type ToolSet } from '@core/component/AI/types';
11+
import type { Model, ToolSet } from '@core/component/AI/types';
612
import type { EditorConfigBuilder } from '@core/component/LexicalMarkdown/builder/MarkdownConfigBuilder';
713
import { MarkdownShell } from '@core/component/LexicalMarkdown/builder/MarkdownShell';
814
import { toast } from '@core/component/Toast/Toast';
@@ -48,20 +54,26 @@ export function ChatInput(props: ChatInputComponentProps) {
4854
const model = input.model;
4955
const generating = input.isGenerating;
5056
const { showPaywall } = usePaywallState();
57+
const hasPaidAccess = useHasPaidAccess();
5158

52-
// Every model is offered in the picker; the backend enforces plan
53-
// entitlements on send (a free user picking a pro model is rejected there).
59+
// Free and paid users see different selector lists. Free users get only the
60+
// free models (Haiku); premium models aren't shown at all. The 403 -> paywall
61+
// path is the backstop for anything that slips through to the backend.
5462
const modelOptions = createMemo(() =>
55-
Object.values(Model).map((id) => ({ id, available: true }))
63+
(hasPaidAccess() ? PAID_MODELS : FREE_MODELS).map((id) => ({
64+
id,
65+
available: true,
66+
}))
5667
);
5768

58-
// If the selected model isn't a known id (e.g. a stale persisted value),
59-
// fall back to the first one so we never send something unroutable.
69+
// Keep the selected model valid for the current plan: if it isn't a known id
70+
// (e.g. a stale persisted value) or isn't available to this user (e.g. a free
71+
// user defaulted to Opus), fall back to the plan default so we never send
72+
// something unroutable or something the backend rejects.
6073
createEffect(() => {
6174
const options = modelOptions();
62-
if (options.some((o) => o.id === model())) return;
63-
const [first] = options;
64-
if (first) input.setModel(first.id);
75+
if (options.some((o) => o.id === model() && o.available)) return;
76+
input.setModel(defaultModelForPlan(hasPaidAccess()));
6577
});
6678

6779
let containerRef!: HTMLDivElement;

js/app/packages/core/component/AI/constant/model.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,33 @@ export const MODEL_PROVIDER_ICON: ExhaustiveMap = {
4141
'openai/gpt-5-mini': OpenAiIcon,
4242
};
4343

44+
/** Default model for paid users. */
4445
export const DEFAULT_MODEL: TModel = Model.opus48;
46+
47+
/**
48+
* Default model for free users. Free users aren't entitled to the premium
49+
* "smart" models (which the backend rejects with a 403), so they start on the
50+
* fast model instead of Opus.
51+
*/
52+
export const FREE_DEFAULT_MODEL: TModel = Model.haiku45;
53+
54+
/** Models a paid user may select — the full set. */
55+
export const PAID_MODELS: readonly TModel[] = Object.values(Model);
56+
57+
/**
58+
* Models a free user may select. Free users only get the fast model
59+
* (`FREE_DEFAULT_MODEL`); every other model is paid-only and shows locked in
60+
* the selector, where selecting one opens the paywall instead of being sent
61+
* and rejected by the backend.
62+
*/
63+
export const FREE_MODELS: readonly TModel[] = [FREE_DEFAULT_MODEL];
64+
65+
/** The default model for a user given their paid entitlement. */
66+
export function defaultModelForPlan(hasPaidAccess: boolean): TModel {
67+
return hasPaidAccess ? DEFAULT_MODEL : FREE_DEFAULT_MODEL;
68+
}
69+
70+
/** The selectable models for a user given their paid entitlement. */
71+
export function modelsForPlan(hasPaidAccess: boolean): readonly TModel[] {
72+
return hasPaidAccess ? PAID_MODELS : FREE_MODELS;
73+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { err, ok } from 'neverthrow';
2+
import { describe, expect, it } from 'vitest';
3+
import { isPaymentError } from './handlePaymentError';
4+
5+
describe('isPaymentError', () => {
6+
it('returns false for a success result', () => {
7+
expect(isPaymentError(ok({ value: 1 }))).toBe(false);
8+
});
9+
10+
it('detects a FORBIDDEN (403) error', () => {
11+
expect(
12+
isPaymentError(err([{ code: 'FORBIDDEN', message: 'Forbidden' }]))
13+
).toBe(true);
14+
});
15+
16+
it('detects a 403 reported as HTTP_ERROR in the message', () => {
17+
expect(
18+
isPaymentError(
19+
err([{ code: 'HTTP_ERROR', message: 'HTTP error! status: 403' }])
20+
)
21+
).toBe(true);
22+
});
23+
24+
it('detects a 402 / payment_required reported as HTTP_ERROR', () => {
25+
expect(
26+
isPaymentError(
27+
err([{ code: 'HTTP_ERROR', message: 'HTTP error! status: 402' }])
28+
)
29+
).toBe(true);
30+
expect(
31+
isPaymentError(err([{ code: 'HTTP_ERROR', message: 'payment_required' }]))
32+
).toBe(true);
33+
});
34+
35+
it('ignores unrelated errors', () => {
36+
expect(
37+
isPaymentError(
38+
err([{ code: 'NOT_FOUND', message: 'Resource not found' }])
39+
)
40+
).toBe(false);
41+
expect(
42+
isPaymentError(
43+
err([{ code: 'HTTP_ERROR', message: 'HTTP error! status: 500' }])
44+
)
45+
).toBe(false);
46+
});
47+
});

js/app/packages/core/util/handlePaymentError.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,21 @@ export function isPaymentError<T>(
88
return false;
99
}
1010

11-
if (
12-
result.isErr() &&
13-
result.error.some((error) => error.code === 'HTTP_ERROR')
14-
) {
15-
const errorMessage = result.error[0].message;
16-
if (
17-
errorMessage.includes('402') ||
18-
errorMessage.includes('payment_required') ||
19-
errorMessage.includes('403')
20-
) {
11+
// A 403 (entitlement) or 402 (payment required) means the user isn't allowed
12+
// to do this on their plan — always surface the paywall. 403s come through as
13+
// a `FORBIDDEN` code; older paths may still report them as `HTTP_ERROR` with
14+
// the status/reason in the message, so check both.
15+
return result.error.some((error) => {
16+
if (error.code === 'FORBIDDEN') {
2117
return true;
2218
}
23-
}
24-
25-
return false;
19+
if (error.code === 'HTTP_ERROR') {
20+
return (
21+
error.message.includes('402') ||
22+
error.message.includes('payment_required') ||
23+
error.message.includes('403')
24+
);
25+
}
26+
return false;
27+
});
2628
}

js/app/packages/service-clients/service-auth/fetch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ export async function fetchWithAuth<
106106
code: 'UNAUTHORIZED',
107107
message: 'Unauthorized access',
108108
};
109+
case 403:
110+
return {
111+
code: 'FORBIDDEN',
112+
message: 'Forbidden',
113+
};
109114
case 409:
110115
return {
111116
code: 'CONFLICT',

0 commit comments

Comments
 (0)