Skip to content

Commit aca1e0f

Browse files
authored
Disclose free model data collection (#3606)
* feat: disclose free model data collection * fix: align free model data indicators * fix: restore free badges for data indicators * fix: limit data labels to kilo gateway models * fix(model-picker): warn on all free models * fix(model-picker): tighten warning icon spacing
1 parent f9e4936 commit aca1e0f

25 files changed

Lines changed: 251 additions & 22 deletions

apps/mobile/src/app/(app)/agent-chat/model-picker.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import * as Haptics from 'expo-haptics';
22
import { useFocusEffect, useRouter } from 'expo-router';
3-
import { Check, Search } from 'lucide-react-native';
3+
import { AlertTriangle, Check, Search } from 'lucide-react-native';
44
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
55
import { Pressable, ScrollView, TextInput, View } from 'react-native';
66

77
import { Text } from '@/components/ui/text';
8+
import {
9+
FREE_MODEL_DATA_LABEL,
10+
FREE_MODEL_FREE_LABEL,
11+
getFreeModelDataAccessibilityLabel,
12+
isFreeModelOption,
13+
} from '@/lib/free-model-data-disclosure';
814
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
915
import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models';
1016
import { clearModelPickerBridge, getModelPickerBridge } from '@/lib/picker-bridge';
@@ -217,6 +223,8 @@ export default function ModelPickerScreen() {
217223

218224
const modelOption = item.model;
219225
const selected = modelOption.id === selectedModel;
226+
const free = isFreeModelOption(modelOption);
227+
const collectsData = isFreeModelOption(modelOption);
220228
const hasVariants = modelOption.variants.length > 1;
221229

222230
return (
@@ -227,11 +235,30 @@ export default function ModelPickerScreen() {
227235
handleSelectModel(modelOption.id);
228236
}}
229237
accessibilityRole="button"
230-
accessibilityLabel={`${modelOption.name}${selected ? ', selected' : ''}`}
238+
accessibilityLabel={`${collectsData ? getFreeModelDataAccessibilityLabel(modelOption.name) : modelOption.name}${selected ? ', selected' : ''}`}
231239
>
232240
<View className="flex-1">
233241
<Text className="text-base text-foreground">{modelOption.name}</Text>
234242
<Text className="text-xs text-muted-foreground">{modelOption.id}</Text>
243+
{free ? (
244+
<View className="mt-1 flex-row items-center gap-1 self-start">
245+
<View
246+
className="rounded-full px-2 py-0.5"
247+
style={{ backgroundColor: colors.good }}
248+
>
249+
<Text className="text-[11px] font-medium text-white" numberOfLines={1}>
250+
{FREE_MODEL_FREE_LABEL}
251+
</Text>
252+
</View>
253+
{collectsData ? (
254+
<AlertTriangle
255+
accessibilityLabel={FREE_MODEL_DATA_LABEL}
256+
size={13}
257+
color={colors.warn}
258+
/>
259+
) : null}
260+
</View>
261+
) : null}
235262
</View>
236263
{selected && <Check size={18} color={colors.primary} />}
237264
</Pressable>

apps/mobile/src/components/agents/model-selector.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { Pressable, View } from 'react-native';
22
import { type Href, useRouter } from 'expo-router';
3-
import { Brain, ChevronDown } from 'lucide-react-native';
3+
import { AlertTriangle, Brain, ChevronDown } from 'lucide-react-native';
44

55
import { Text } from '@/components/ui/text';
6+
import {
7+
getFreeModelDataAccessibilityLabel,
8+
isFreeModelOption,
9+
} from '@/lib/free-model-data-disclosure';
610
import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models';
711
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
812
import { setModelPickerBridge } from '@/lib/picker-bridge';
@@ -39,9 +43,13 @@ export function ModelSelector({
3943

4044
const selectedModel = options.find(m => m.id === value);
4145
const label = selectedModel?.name ?? (value || 'Model');
46+
const collectsData = isFreeModelOption(selectedModel);
4247
const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false;
4348
const variantLabel = variant ? thinkingEffortLabel(variant) : '';
4449
const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : '';
50+
const dataLabel = collectsData ? getFreeModelDataAccessibilityLabel(label) : label;
51+
const accessibilityLabel =
52+
hasVariants && variantLabel ? `${dataLabel}, ${variantLabel} thinking effort` : dataLabel;
4553

4654
function handlePress() {
4755
if (effectivelyDisabled) {
@@ -61,9 +69,7 @@ export function ModelSelector({
6169
onPress={handlePress}
6270
disabled={effectivelyDisabled}
6371
accessibilityRole="button"
64-
accessibilityLabel={
65-
hasVariants && variantLabel ? `${label}, ${variantLabel} thinking effort` : label
66-
}
72+
accessibilityLabel={accessibilityLabel}
6773
className={cn(
6874
'max-w-[240px] shrink flex-row items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 active:opacity-70',
6975
effectivelyDisabled && 'opacity-50'
@@ -76,6 +82,7 @@ export function ModelSelector({
7682
>
7783
{label}
7884
</Text>
85+
{collectsData ? <AlertTriangle size={12} color={colors.warn} /> : null}
7986
{hasVariants && compactVariantLabel ? (
8087
<View className="flex-row items-center gap-1 rounded-full bg-neutral-200 px-1.5 py-0.5 dark:bg-neutral-800">
8188
<Brain size={12} color={colors.mutedForeground} />
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
FREE_MODEL_DATA_LABEL,
4+
FREE_MODEL_FREE_LABEL,
5+
getFreeModelDataAccessibilityLabel,
6+
isFreeModelOption,
7+
} from './free-model-data-disclosure';
8+
9+
describe('free model data disclosure', () => {
10+
it('uses the disclosure label expected in model pickers', () => {
11+
expect(FREE_MODEL_DATA_LABEL).toBe('Data collected');
12+
expect(FREE_MODEL_FREE_LABEL).toBe('Free');
13+
});
14+
15+
it('detects explicit and known free model options', () => {
16+
expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true);
17+
expect(isFreeModelOption({ id: 'openrouter/free', isFree: true })).toBe(true);
18+
expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(false);
19+
expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(false);
20+
expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false);
21+
});
22+
23+
it('adds a data collection phrase to accessibility labels', () => {
24+
expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected');
25+
});
26+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const FREE_MODEL_DATA_LABEL = 'Data collected';
2+
export const FREE_MODEL_FREE_LABEL = 'Free';
3+
4+
export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) {
5+
return model?.isFree === true;
6+
}
7+
8+
export function getFreeModelDataAccessibilityLabel(label: string) {
9+
return `${label}, ${FREE_MODEL_DATA_LABEL}`;
10+
}

apps/mobile/src/lib/hooks/use-available-models.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ export type ModelOption = {
1212
name: string;
1313
variants: string[];
1414
isPreferred: boolean;
15+
isFree?: boolean;
1516
};
1617

1718
type ModelResponse = {
1819
data: {
1920
id: string;
2021
name: string;
22+
isFree?: boolean;
2123
preferredIndex?: number;
2224
opencode?: {
2325
variants?: Record<string, unknown>;
@@ -98,6 +100,7 @@ export function useAvailableModels(organizationId: string | undefined) {
98100
const items = data.data.map(model => ({
99101
id: model.id,
100102
name: formatShortModelName(model.name),
103+
isFree: model.isFree,
101104
variants: Object.keys(model.opencode?.variants ?? {}),
102105
preferredIndex: model.preferredIndex,
103106
}));
@@ -123,6 +126,7 @@ export function useAvailableModels(organizationId: string | undefined) {
123126
name: item.name,
124127
variants: item.variants,
125128
isPreferred: item.preferredIndex !== undefined,
129+
isFree: item.isFree,
126130
}));
127131
}, [data]);
128132

apps/web/src/app/(app)/claw/components/SettingsTab.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1956,7 +1956,11 @@ export function SettingsTab({
19561956
const modelOptions = useMemo<ModelOption[]>(
19571957
() =>
19581958
getSettingsModelOptions({
1959-
models: (modelsData?.data || []).map(model => ({ id: model.id, name: model.name })),
1959+
models: (modelsData?.data || []).map(model => ({
1960+
id: model.id,
1961+
name: model.name,
1962+
isFree: model.isFree,
1963+
})),
19601964
trackedOpenClawVersion: trackedVersion,
19611965
runningOpenClawVersion: runningVersion,
19621966
isRunning,

apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@ export function EditWebhookTriggerContent({
8686

8787
// Transform models to ModelOption format
8888
const modelOptions = useMemo<ModelOption[]>(
89-
() => (modelsData?.data || []).map(model => ({ id: model.id, name: model.name })),
89+
() =>
90+
(modelsData?.data || []).map(model => ({
91+
id: model.id,
92+
name: model.name,
93+
isFree: model.isFree,
94+
})),
9095
[modelsData?.data]
9196
);
9297

apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,12 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri
7272

7373
// Transform models to ModelOption format
7474
const modelOptions = useMemo<ModelOption[]>(
75-
() => (modelsData?.data || []).map(model => ({ id: model.id, name: model.name })),
75+
() =>
76+
(modelsData?.data || []).map(model => ({
77+
id: model.id,
78+
name: model.name,
79+
isFree: model.isFree,
80+
})),
7681
[modelsData?.data]
7782
);
7883

apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props)
9696
} = useModelSelectorList(organizationId);
9797

9898
const modelOptions = useMemo<ModelOption[]>(
99-
() => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [],
99+
() =>
100+
modelsData?.data.map(model => ({
101+
id: model.id,
102+
name: model.name,
103+
isFree: model.isFree,
104+
})) ?? [],
100105
[modelsData]
101106
);
102107

apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
141141
} = useModelSelectorList(organizationId);
142142

143143
const modelOptions = useMemo<ModelOption[]>(
144-
() => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [],
144+
() =>
145+
modelsData?.data.map(model => ({
146+
id: model.id,
147+
name: model.name,
148+
isFree: model.isFree,
149+
})) ?? [],
145150
[modelsData]
146151
);
147152

0 commit comments

Comments
 (0)