From df0eced37b169897bb344a17f828a7ec9193fbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 30 May 2026 20:42:20 +0200 Subject: [PATCH 1/6] feat: disclose free model data collection --- .../src/app/(app)/agent-chat/model-picker.tsx | 25 +++++++++- .../src/components/agents/model-selector.tsx | 15 ++++-- .../lib/free-model-data-disclosure.test.ts | 25 ++++++++++ .../src/lib/free-model-data-disclosure.ts | 20 ++++++++ .../src/lib/hooks/use-available-models.ts | 4 ++ .../app/(app)/claw/components/SettingsTab.tsx | 6 ++- .../[triggerId]/EditWebhookTriggerContent.tsx | 7 ++- .../new/CreateWebhookTriggerContent.tsx | 7 ++- .../settings/RigSettingsPageClient.tsx | 7 ++- .../settings/TownSettingsPageClient.tsx | 7 ++- .../onboarding/OnboardingStepModel.tsx | 7 ++- .../components/app-builder/AppBuilderChat.tsx | 2 +- .../app-builder/AppBuilderLanding.tsx | 2 +- .../cloud-agent-next/NewSessionPanel.tsx | 1 + .../hooks/useOrganizationModels.ts | 1 + .../cloud-agent/CloudSessionsPage.tsx | 1 + .../hooks/useOrganizationModels.ts | 8 +++- .../profile-editor/KiloCommandsTab.tsx | 1 + .../profile-editor/ProfileAgentsTab.tsx | 1 + .../DiscordIntegrationDetails.tsx | 8 +++- .../integrations/LinearIntegrationDetails.tsx | 8 +++- .../integrations/SlackIntegrationDetails.tsx | 8 +++- .../src/components/shared/ModelCombobox.tsx | 46 +++++++++++++++++-- .../shared/free-model-data-disclosure.test.ts | 23 ++++++++++ .../shared/free-model-data-disclosure.ts | 20 ++++++++ 25 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/lib/free-model-data-disclosure.test.ts create mode 100644 apps/mobile/src/lib/free-model-data-disclosure.ts create mode 100644 apps/web/src/components/shared/free-model-data-disclosure.test.ts create mode 100644 apps/web/src/components/shared/free-model-data-disclosure.ts diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index 201687d180..375cb69f1c 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -5,6 +5,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, ScrollView, TextInput, View } from 'react-native'; import { Text } from '@/components/ui/text'; +import { + FREE_MODEL_DATA_LABEL, + getFreeModelDataAccessibilityLabel, + isFreeModelOption, +} from '@/lib/free-model-data-disclosure'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { clearModelPickerBridge, getModelPickerBridge } from '@/lib/picker-bridge'; @@ -217,6 +222,7 @@ export default function ModelPickerScreen() { const modelOption = item.model; const selected = modelOption.id === selectedModel; + const collectsData = isFreeModelOption(modelOption); const hasVariants = modelOption.variants.length > 1; return ( @@ -227,11 +233,28 @@ export default function ModelPickerScreen() { handleSelectModel(modelOption.id); }} accessibilityRole="button" - accessibilityLabel={`${modelOption.name}${selected ? ', selected' : ''}`} + accessibilityLabel={`${collectsData ? getFreeModelDataAccessibilityLabel(modelOption.name) : modelOption.name}${selected ? ', selected' : ''}`} > {modelOption.name} {modelOption.id} + {collectsData ? ( + + + {FREE_MODEL_DATA_LABEL} + + + ) : null} {selected && } diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index f465821478..b5cf64c006 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -1,8 +1,12 @@ import { Pressable, View } from 'react-native'; import { type Href, useRouter } from 'expo-router'; -import { Brain, ChevronDown } from 'lucide-react-native'; +import { Brain, ChevronDown, Info } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; +import { + getFreeModelDataAccessibilityLabel, + isFreeModelOption, +} from '@/lib/free-model-data-disclosure'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { setModelPickerBridge } from '@/lib/picker-bridge'; @@ -39,9 +43,13 @@ export function ModelSelector({ const selectedModel = options.find(m => m.id === value); const label = selectedModel?.name ?? (value || 'Model'); + const collectsData = isFreeModelOption(selectedModel); const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false; const variantLabel = variant ? thinkingEffortLabel(variant) : ''; const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : ''; + const dataLabel = collectsData ? getFreeModelDataAccessibilityLabel(label) : label; + const accessibilityLabel = + hasVariants && variantLabel ? `${dataLabel}, ${variantLabel} thinking effort` : dataLabel; function handlePress() { if (effectivelyDisabled) { @@ -61,9 +69,7 @@ export function ModelSelector({ onPress={handlePress} disabled={effectivelyDisabled} accessibilityRole="button" - accessibilityLabel={ - hasVariants && variantLabel ? `${label}, ${variantLabel} thinking effort` : label - } + accessibilityLabel={accessibilityLabel} className={cn( 'max-w-[240px] shrink flex-row items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 active:opacity-70', effectivelyDisabled && 'opacity-50' @@ -76,6 +82,7 @@ export function ModelSelector({ > {label} + {collectsData ? : null} {hasVariants && compactVariantLabel ? ( diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts new file mode 100644 index 0000000000..2d592cffa8 --- /dev/null +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { + FREE_MODEL_DATA_LABEL, + getFreeModelDataAccessibilityLabel, + isFreeModelOption, +} from './free-model-data-disclosure'; + +describe('free model data disclosure', () => { + it('uses the disclosure label expected in model pickers', () => { + expect(FREE_MODEL_DATA_LABEL).toBe('Free - data collected'); + }); + + it('detects explicit and known free model options', () => { + expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(true); + expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); + }); + + it('adds a data collection phrase to accessibility labels', () => { + expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe( + 'Kilo Auto, free model, usage data collected' + ); + }); +}); diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts new file mode 100644 index 0000000000..bda117da52 --- /dev/null +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -0,0 +1,20 @@ +export const FREE_MODEL_DATA_LABEL = 'Free - data collected'; +export const FREE_MODEL_DATA_SHORT_LABEL = 'Data collected'; + +export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { + if (!model) { + return false; + } + return ( + model.isFree === true || + model.id === 'kilo-auto/free' || + model.id.endsWith(':free') || + model.id === 'openrouter/free' || + (model.id.startsWith('openrouter/') && + (model.id.endsWith('-alpha') || model.id.endsWith('-beta'))) + ); +} + +export function getFreeModelDataAccessibilityLabel(label: string) { + return `${label}, free model, usage data collected`; +} diff --git a/apps/mobile/src/lib/hooks/use-available-models.ts b/apps/mobile/src/lib/hooks/use-available-models.ts index 9676c75fdd..f10190e619 100644 --- a/apps/mobile/src/lib/hooks/use-available-models.ts +++ b/apps/mobile/src/lib/hooks/use-available-models.ts @@ -12,12 +12,14 @@ export type ModelOption = { name: string; variants: string[]; isPreferred: boolean; + isFree?: boolean; }; type ModelResponse = { data: { id: string; name: string; + isFree?: boolean; preferredIndex?: number; opencode?: { variants?: Record; @@ -98,6 +100,7 @@ export function useAvailableModels(organizationId: string | undefined) { const items = data.data.map(model => ({ id: model.id, name: formatShortModelName(model.name), + isFree: model.isFree, variants: Object.keys(model.opencode?.variants ?? {}), preferredIndex: model.preferredIndex, })); @@ -123,6 +126,7 @@ export function useAvailableModels(organizationId: string | undefined) { name: item.name, variants: item.variants, isPreferred: item.preferredIndex !== undefined, + isFree: item.isFree, })); }, [data]); diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index c67210ebf5..17ec0c3468 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -1956,7 +1956,11 @@ export function SettingsTab({ const modelOptions = useMemo( () => getSettingsModelOptions({ - models: (modelsData?.data || []).map(model => ({ id: model.id, name: model.name })), + models: (modelsData?.data || []).map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })), trackedOpenClawVersion: trackedVersion, runningOpenClawVersion: runningVersion, isRunning, diff --git a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx index ae6bcb1473..629002f2bf 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx @@ -86,7 +86,12 @@ export function EditWebhookTriggerContent({ // Transform models to ModelOption format const modelOptions = useMemo( - () => (modelsData?.data || []).map(model => ({ id: model.id, name: model.name })), + () => + (modelsData?.data || []).map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })), [modelsData?.data] ); diff --git a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx index 99c861a774..bbc2ea5a4a 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx @@ -72,7 +72,12 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri // Transform models to ModelOption format const modelOptions = useMemo( - () => (modelsData?.data || []).map(model => ({ id: model.id, name: model.name })), + () => + (modelsData?.data || []).map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })), [modelsData?.data] ); diff --git a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx index 61621384ad..b5d92c100f 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx @@ -96,7 +96,12 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) } = useModelSelectorList(organizationId); const modelOptions = useMemo( - () => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [], + () => + modelsData?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [], [modelsData] ); diff --git a/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx index 3cbd3198f3..f6b7ccf5b3 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx @@ -141,7 +141,12 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI } = useModelSelectorList(organizationId); const modelOptions = useMemo( - () => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [], + () => + modelsData?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [], [modelsData] ); diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx index d77d457ab6..cec7dbe157 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx +++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx @@ -327,7 +327,12 @@ export function OnboardingStepModel() { } = useModelSelectorList(undefined); const modelOptions = useMemo( - () => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [], + () => + modelsData?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [], [modelsData] ); diff --git a/apps/web/src/components/app-builder/AppBuilderChat.tsx b/apps/web/src/components/app-builder/AppBuilderChat.tsx index cd6597ceef..7429c83761 100644 --- a/apps/web/src/components/app-builder/AppBuilderChat.tsx +++ b/apps/web/src/components/app-builder/AppBuilderChat.tsx @@ -532,7 +532,7 @@ export function AppBuilderChat({ organizationId }: AppBuilderChatProps) { const inputModalities = m.architecture?.input_modalities || []; const supportsVision = inputModalities.includes('image') || inputModalities.includes('image_url'); - return { id: m.id, name: m.name, supportsVision }; + return { id: m.id, name: m.name, supportsVision, isFree: m.isFree }; }), [availableModels] ); diff --git a/apps/web/src/components/app-builder/AppBuilderLanding.tsx b/apps/web/src/components/app-builder/AppBuilderLanding.tsx index 6e9f4be6a7..f3fb0e54be 100644 --- a/apps/web/src/components/app-builder/AppBuilderLanding.tsx +++ b/apps/web/src/components/app-builder/AppBuilderLanding.tsx @@ -632,7 +632,7 @@ export function AppBuilderLanding({ organizationId, onProjectCreated }: AppBuild const inputModalities = m.architecture?.input_modalities || []; const supportsVision = inputModalities.includes('image') || inputModalities.includes('image_url'); - return { id: m.id, name: m.name, supportsVision }; + return { id: m.id, name: m.name, supportsVision, isFree: m.isFree }; }), [availableModels] ); diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index ad5244747d..86346b7fce 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -151,6 +151,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New allModels.map(model => ({ id: model.id, name: model.name, + isFree: model.isFree, variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined, })) ), diff --git a/apps/web/src/components/cloud-agent-next/hooks/useOrganizationModels.ts b/apps/web/src/components/cloud-agent-next/hooks/useOrganizationModels.ts index 6340d0d55a..f1d355d1cc 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useOrganizationModels.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useOrganizationModels.ts @@ -39,6 +39,7 @@ export function useOrganizationModels(organizationId?: string): UseOrganizationM openRouterModels?.data.map(model => ({ id: model.id, name: model.name, + isFree: model.isFree, variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined, })) ?? [] ); diff --git a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx index d68fb1f961..5396393257 100644 --- a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx +++ b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx @@ -80,6 +80,7 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { allModels.map(model => ({ id: model.id, name: model.name, + isFree: model.isFree, variants: model.opencode?.variants ? Object.keys(model.opencode.variants) : undefined, })), [allModels] diff --git a/apps/web/src/components/cloud-agent/hooks/useOrganizationModels.ts b/apps/web/src/components/cloud-agent/hooks/useOrganizationModels.ts index 7a236d3d15..745716b7a8 100644 --- a/apps/web/src/components/cloud-agent/hooks/useOrganizationModels.ts +++ b/apps/web/src/components/cloud-agent/hooks/useOrganizationModels.ts @@ -34,7 +34,13 @@ export function useOrganizationModels(organizationId?: string): UseOrganizationM // Format models for the combobox const modelOptions = useMemo(() => { - return openRouterModels?.data.map(model => ({ id: model.id, name: model.name })) ?? []; + return ( + openRouterModels?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [] + ); }, [openRouterModels]); return { diff --git a/apps/web/src/components/cloud-agent/profile-editor/KiloCommandsTab.tsx b/apps/web/src/components/cloud-agent/profile-editor/KiloCommandsTab.tsx index 0b78f0c474..0b96c44ed9 100644 --- a/apps/web/src/components/cloud-agent/profile-editor/KiloCommandsTab.tsx +++ b/apps/web/src/components/cloud-agent/profile-editor/KiloCommandsTab.tsx @@ -188,6 +188,7 @@ function KiloCommandForm(props: KiloCommandFormProps) { (modelsData?.data ?? []).map(m => ({ id: m.id, name: m.name, + isFree: m.isFree, variants: m.opencode?.variants ? Object.keys(m.opencode.variants) : undefined, })), [modelsData] diff --git a/apps/web/src/components/cloud-agent/profile-editor/ProfileAgentsTab.tsx b/apps/web/src/components/cloud-agent/profile-editor/ProfileAgentsTab.tsx index 04a795302b..8ba53ad82e 100644 --- a/apps/web/src/components/cloud-agent/profile-editor/ProfileAgentsTab.tsx +++ b/apps/web/src/components/cloud-agent/profile-editor/ProfileAgentsTab.tsx @@ -326,6 +326,7 @@ function AgentForm({ (modelsData?.data ?? []).map(m => ({ id: m.id, name: m.name, + isFree: m.isFree, variants: m.opencode?.variants ? Object.keys(m.opencode.variants) : undefined, })), [modelsData] diff --git a/apps/web/src/components/integrations/DiscordIntegrationDetails.tsx b/apps/web/src/components/integrations/DiscordIntegrationDetails.tsx index 4ecfd496dc..316bd93046 100644 --- a/apps/web/src/components/integrations/DiscordIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/DiscordIntegrationDetails.tsx @@ -40,7 +40,13 @@ export function DiscordIntegrationDetails({ useModelSelectorList(organizationId); const modelOptions = useMemo(() => { - return openRouterModels?.data.map(model => ({ id: model.id, name: model.name })) ?? []; + return ( + openRouterModels?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [] + ); }, [openRouterModels]); // Track selected model diff --git a/apps/web/src/components/integrations/LinearIntegrationDetails.tsx b/apps/web/src/components/integrations/LinearIntegrationDetails.tsx index 2a1c4568c3..12b30c3536 100644 --- a/apps/web/src/components/integrations/LinearIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/LinearIntegrationDetails.tsx @@ -49,7 +49,13 @@ export function LinearIntegrationDetails({ useModelSelectorList(organizationId); const modelOptions = useMemo(() => { - return openRouterModels?.data.map(model => ({ id: model.id, name: model.name })) ?? []; + return ( + openRouterModels?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [] + ); }, [openRouterModels]); const [selectedModel, setSelectedModel] = useState(''); diff --git a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx index b598658a67..7482dac313 100644 --- a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx @@ -68,7 +68,13 @@ export function SlackIntegrationDetails({ useModelSelectorList(organizationId); const modelOptions = useMemo(() => { - return openRouterModels?.data.map(model => ({ id: model.id, name: model.name })) ?? []; + return ( + openRouterModels?.data.map(model => ({ + id: model.id, + name: model.name, + isFree: model.isFree, + })) ?? [] + ); }, [openRouterModels]); // Track selected model diff --git a/apps/web/src/components/shared/ModelCombobox.tsx b/apps/web/src/components/shared/ModelCombobox.tsx index 2c8203149f..18a2bb72df 100644 --- a/apps/web/src/components/shared/ModelCombobox.tsx +++ b/apps/web/src/components/shared/ModelCombobox.tsx @@ -13,16 +13,22 @@ import { CommandItem, CommandList, } from '@/components/ui/command'; -import { ChevronsUpDown, Check, Image } from 'lucide-react'; +import { ChevronsUpDown, Check, Image, Info } from 'lucide-react'; import { cn } from '@/lib/utils'; import { preferredModels } from '@/lib/ai-gateway/models'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { formatShortModelDisplayName } from '@/lib/format-model-name'; +import { + FREE_MODEL_DATA_LABEL, + getFreeModelDataTooltip, + isFreeModelOption, +} from '@/components/shared/free-model-data-disclosure'; export type ModelOption = { id: string; // e.g., "anthropic/claude-sonnet-4.5" name: string; // e.g., "Claude Sonnet 4.5" supportsVision?: boolean; + isFree?: boolean; /** Ordered list of variant key names (e.g., ["none","low","medium","high","max"]) */ variants?: string[]; }; @@ -111,6 +117,7 @@ export function ModelCombobox({ const selectedModel = models.find(model => model.id === value); const isCompact = variant === 'compact'; const showLabel = !isCompact && label; + const selectedIsFree = isFreeModelOption(selectedModel); if (isLoading) { if (isCompact) { @@ -209,9 +216,10 @@ export function ModelCombobox({ className={cn('h-9 justify-between gap-1.5', className)} ref={triggerRef} > - + {selectedModel ? formatShortModelDisplayName(selectedModel.name) : placeholder} + {selectedIsFree && } @@ -244,6 +252,7 @@ export function ModelCombobox({ Supports vision )} + {isFreeModelOption(model) && } {model.id} @@ -281,6 +290,7 @@ export function ModelCombobox({ Supports vision )} + {isFreeModelOption(model) && } {model.id} @@ -324,7 +334,10 @@ export function ModelCombobox({ className={cn('w-full justify-between', className)} ref={triggerRef} > - {selectedModel ? selectedModel.name : placeholder} + + {selectedModel ? selectedModel.name : placeholder} + + {selectedIsFree && } @@ -361,6 +374,7 @@ export function ModelCombobox({ Supports vision )} + {isFreeModelOption(model) && } {model.id} @@ -398,6 +412,7 @@ export function ModelCombobox({ Supports vision )} + {isFreeModelOption(model) && } {model.id} @@ -419,3 +434,28 @@ export function ModelCombobox({ ); } + +function FreeModelDataIcon() { + return ( + + + + + {getFreeModelDataTooltip()} + + ); +} + +function FreeModelDataBadge() { + return ( + + + + + {FREE_MODEL_DATA_LABEL} + + + {getFreeModelDataTooltip()} + + ); +} diff --git a/apps/web/src/components/shared/free-model-data-disclosure.test.ts b/apps/web/src/components/shared/free-model-data-disclosure.test.ts new file mode 100644 index 0000000000..48b856d3ae --- /dev/null +++ b/apps/web/src/components/shared/free-model-data-disclosure.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@jest/globals'; +import { + FREE_MODEL_DATA_LABEL, + getFreeModelDataTooltip, + isFreeModelOption, +} from './free-model-data-disclosure'; + +describe('free model data disclosure', () => { + it('uses the disclosure label expected in model pickers', () => { + expect(FREE_MODEL_DATA_LABEL).toBe('Free - data collected'); + }); + + it('detects explicit and known free model options', () => { + expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(true); + expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); + }); + + it('describes why the free model indicator is shown', () => { + expect(getFreeModelDataTooltip()).toContain('model improvement'); + }); +}); diff --git a/apps/web/src/components/shared/free-model-data-disclosure.ts b/apps/web/src/components/shared/free-model-data-disclosure.ts new file mode 100644 index 0000000000..d7bb5d9b24 --- /dev/null +++ b/apps/web/src/components/shared/free-model-data-disclosure.ts @@ -0,0 +1,20 @@ +export const FREE_MODEL_DATA_LABEL = 'Free - data collected'; +export const FREE_MODEL_DATA_SHORT_LABEL = 'Data collected'; + +export function getFreeModelDataTooltip() { + return 'Usage with free Kilo models is collected for model improvement.'; +} + +export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { + if (!model) { + return false; + } + return ( + model.isFree === true || + model.id === 'kilo-auto/free' || + model.id.endsWith(':free') || + model.id === 'openrouter/free' || + (model.id.startsWith('openrouter/') && + (model.id.endsWith('-alpha') || model.id.endsWith('-beta'))) + ); +} From 86be1a3ad807ec22ed41466de14459c27db9a26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 1 Jun 2026 14:24:14 +0200 Subject: [PATCH 2/6] fix: align free model data indicators --- .../src/components/agents/model-selector.tsx | 4 ++-- .../lib/free-model-data-disclosure.test.ts | 8 +++----- .../src/lib/free-model-data-disclosure.ts | 9 +++------ .../src/components/shared/ModelCombobox.tsx | 20 +++++++++++++++---- .../shared/free-model-data-disclosure.test.ts | 8 ++++---- .../shared/free-model-data-disclosure.ts | 9 +++------ 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index b5cf64c006..adc2625f88 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -1,6 +1,6 @@ import { Pressable, View } from 'react-native'; import { type Href, useRouter } from 'expo-router'; -import { Brain, ChevronDown, Info } from 'lucide-react-native'; +import { AlertTriangle, Brain, ChevronDown } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; import { @@ -82,7 +82,7 @@ export function ModelSelector({ > {label} - {collectsData ? : null} + {collectsData ? : null} {hasVariants && compactVariantLabel ? ( diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts index 2d592cffa8..3bee7429e3 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.test.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -7,19 +7,17 @@ import { describe('free model data disclosure', () => { it('uses the disclosure label expected in model pickers', () => { - expect(FREE_MODEL_DATA_LABEL).toBe('Free - data collected'); + expect(FREE_MODEL_DATA_LABEL).toBe('Data collected'); }); it('detects explicit and known free model options', () => { expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true); expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(true); - expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(false); expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); }); it('adds a data collection phrase to accessibility labels', () => { - expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe( - 'Kilo Auto, free model, usage data collected' - ); + expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected'); }); }); diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index bda117da52..47c23298fb 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -1,5 +1,4 @@ -export const FREE_MODEL_DATA_LABEL = 'Free - data collected'; -export const FREE_MODEL_DATA_SHORT_LABEL = 'Data collected'; +export const FREE_MODEL_DATA_LABEL = 'Data collected'; export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { if (!model) { @@ -9,12 +8,10 @@ export function isFreeModelOption(model: { id: string; isFree?: boolean } | unde model.isFree === true || model.id === 'kilo-auto/free' || model.id.endsWith(':free') || - model.id === 'openrouter/free' || - (model.id.startsWith('openrouter/') && - (model.id.endsWith('-alpha') || model.id.endsWith('-beta'))) + model.id === 'openrouter/free' ); } export function getFreeModelDataAccessibilityLabel(label: string) { - return `${label}, free model, usage data collected`; + return `${label}, ${FREE_MODEL_DATA_LABEL}`; } diff --git a/apps/web/src/components/shared/ModelCombobox.tsx b/apps/web/src/components/shared/ModelCombobox.tsx index 18a2bb72df..e545e66023 100644 --- a/apps/web/src/components/shared/ModelCombobox.tsx +++ b/apps/web/src/components/shared/ModelCombobox.tsx @@ -13,7 +13,7 @@ import { CommandItem, CommandList, } from '@/components/ui/command'; -import { ChevronsUpDown, Check, Image, Info } from 'lucide-react'; +import { AlertTriangle, ChevronsUpDown, Check, Image } from 'lucide-react'; import { cn } from '@/lib/utils'; import { preferredModels } from '@/lib/ai-gateway/models'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -439,7 +439,14 @@ function FreeModelDataIcon() { return ( - + + + {getFreeModelDataTooltip()} @@ -450,8 +457,13 @@ function FreeModelDataBadge() { return ( - - + + {FREE_MODEL_DATA_LABEL} diff --git a/apps/web/src/components/shared/free-model-data-disclosure.test.ts b/apps/web/src/components/shared/free-model-data-disclosure.test.ts index 48b856d3ae..5fdae31414 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.test.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.test.ts @@ -7,17 +7,17 @@ import { describe('free model data disclosure', () => { it('uses the disclosure label expected in model pickers', () => { - expect(FREE_MODEL_DATA_LABEL).toBe('Free - data collected'); + expect(FREE_MODEL_DATA_LABEL).toBe('Data collected'); }); it('detects explicit and known free model options', () => { expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true); expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(true); - expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(false); expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); }); - it('describes why the free model indicator is shown', () => { - expect(getFreeModelDataTooltip()).toContain('model improvement'); + it('uses the short disclosure text as tooltip content', () => { + expect(getFreeModelDataTooltip()).toBe('Data collected'); }); }); diff --git a/apps/web/src/components/shared/free-model-data-disclosure.ts b/apps/web/src/components/shared/free-model-data-disclosure.ts index d7bb5d9b24..0a8d5a6478 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.ts @@ -1,8 +1,7 @@ -export const FREE_MODEL_DATA_LABEL = 'Free - data collected'; -export const FREE_MODEL_DATA_SHORT_LABEL = 'Data collected'; +export const FREE_MODEL_DATA_LABEL = 'Data collected'; export function getFreeModelDataTooltip() { - return 'Usage with free Kilo models is collected for model improvement.'; + return FREE_MODEL_DATA_LABEL; } export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { @@ -13,8 +12,6 @@ export function isFreeModelOption(model: { id: string; isFree?: boolean } | unde model.isFree === true || model.id === 'kilo-auto/free' || model.id.endsWith(':free') || - model.id === 'openrouter/free' || - (model.id.startsWith('openrouter/') && - (model.id.endsWith('-alpha') || model.id.endsWith('-beta'))) + model.id === 'openrouter/free' ); } From 5e90ac79db81ca041bc7b4be84986308905a4cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 1 Jun 2026 15:22:02 +0200 Subject: [PATCH 3/6] fix: restore free badges for data indicators --- .../src/app/(app)/agent-chat/model-picker.tsx | 29 ++++++++-------- .../lib/free-model-data-disclosure.test.ts | 2 ++ .../src/lib/free-model-data-disclosure.ts | 1 + .../src/components/shared/ModelCombobox.tsx | 33 +++++++++++-------- .../shared/free-model-data-disclosure.test.ts | 2 ++ .../shared/free-model-data-disclosure.ts | 1 + 6 files changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index 375cb69f1c..b735c2d4d5 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -1,12 +1,13 @@ import * as Haptics from 'expo-haptics'; import { useFocusEffect, useRouter } from 'expo-router'; -import { Check, Search } from 'lucide-react-native'; +import { AlertTriangle, Check, Search } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Pressable, ScrollView, TextInput, View } from 'react-native'; import { Text } from '@/components/ui/text'; import { FREE_MODEL_DATA_LABEL, + FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, isFreeModelOption, } from '@/lib/free-model-data-disclosure'; @@ -239,20 +240,20 @@ export default function ModelPickerScreen() { {modelOption.name} {modelOption.id} {collectsData ? ( - - + - {FREE_MODEL_DATA_LABEL} - + + {FREE_MODEL_FREE_LABEL} + + + ) : null} diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts index 3bee7429e3..4aa092fb43 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.test.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { FREE_MODEL_DATA_LABEL, + FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, isFreeModelOption, } from './free-model-data-disclosure'; @@ -8,6 +9,7 @@ import { describe('free model data disclosure', () => { it('uses the disclosure label expected in model pickers', () => { expect(FREE_MODEL_DATA_LABEL).toBe('Data collected'); + expect(FREE_MODEL_FREE_LABEL).toBe('Free'); }); it('detects explicit and known free model options', () => { diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 47c23298fb..7b139d4dd6 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -1,4 +1,5 @@ export const FREE_MODEL_DATA_LABEL = 'Data collected'; +export const FREE_MODEL_FREE_LABEL = 'Free'; export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { if (!model) { diff --git a/apps/web/src/components/shared/ModelCombobox.tsx b/apps/web/src/components/shared/ModelCombobox.tsx index e545e66023..fcd05c0b30 100644 --- a/apps/web/src/components/shared/ModelCombobox.tsx +++ b/apps/web/src/components/shared/ModelCombobox.tsx @@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { formatShortModelDisplayName } from '@/lib/format-model-name'; import { FREE_MODEL_DATA_LABEL, + FREE_MODEL_FREE_LABEL, getFreeModelDataTooltip, isFreeModelOption, } from '@/components/shared/free-model-data-disclosure'; @@ -455,19 +456,23 @@ function FreeModelDataIcon() { function FreeModelDataBadge() { return ( - - - - - {FREE_MODEL_DATA_LABEL} - - - {getFreeModelDataTooltip()} - + + + {FREE_MODEL_FREE_LABEL} + + + + + + + + {getFreeModelDataTooltip()} + + ); } diff --git a/apps/web/src/components/shared/free-model-data-disclosure.test.ts b/apps/web/src/components/shared/free-model-data-disclosure.test.ts index 5fdae31414..c778bc7284 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.test.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { FREE_MODEL_DATA_LABEL, + FREE_MODEL_FREE_LABEL, getFreeModelDataTooltip, isFreeModelOption, } from './free-model-data-disclosure'; @@ -8,6 +9,7 @@ import { describe('free model data disclosure', () => { it('uses the disclosure label expected in model pickers', () => { expect(FREE_MODEL_DATA_LABEL).toBe('Data collected'); + expect(FREE_MODEL_FREE_LABEL).toBe('Free'); }); it('detects explicit and known free model options', () => { diff --git a/apps/web/src/components/shared/free-model-data-disclosure.ts b/apps/web/src/components/shared/free-model-data-disclosure.ts index 0a8d5a6478..d6e52e0930 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.ts @@ -1,4 +1,5 @@ export const FREE_MODEL_DATA_LABEL = 'Data collected'; +export const FREE_MODEL_FREE_LABEL = 'Free'; export function getFreeModelDataTooltip() { return FREE_MODEL_DATA_LABEL; From d6aa29e5eddc8aef09c9c348d56f059edf9d9964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 1 Jun 2026 15:57:55 +0200 Subject: [PATCH 4/6] fix: limit data labels to kilo gateway models --- .../src/app/(app)/agent-chat/model-picker.tsx | 18 +++--- .../src/components/agents/model-selector.tsx | 4 +- .../lib/free-model-data-disclosure.test.ts | 8 +++ .../src/lib/free-model-data-disclosure.ts | 9 +++ .../src/components/shared/ModelCombobox.tsx | 61 ++++++++++++------- .../shared/free-model-data-disclosure.test.ts | 8 +++ .../shared/free-model-data-disclosure.ts | 9 +++ 7 files changed, 87 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index b735c2d4d5..56f80a523b 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -9,6 +9,7 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, + isFreeKiloGatewayModelOption, isFreeModelOption, } from '@/lib/free-model-data-disclosure'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -223,7 +224,8 @@ export default function ModelPickerScreen() { const modelOption = item.model; const selected = modelOption.id === selectedModel; - const collectsData = isFreeModelOption(modelOption); + const free = isFreeModelOption(modelOption); + const collectsData = isFreeKiloGatewayModelOption(modelOption); const hasVariants = modelOption.variants.length > 1; return ( @@ -239,7 +241,7 @@ export default function ModelPickerScreen() { {modelOption.name} {modelOption.id} - {collectsData ? ( + {free ? ( - + {collectsData ? ( + + ) : null} ) : null} diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index adc2625f88..d9b56f8ab8 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, Brain, ChevronDown } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; import { getFreeModelDataAccessibilityLabel, - isFreeModelOption, + isFreeKiloGatewayModelOption, } from '@/lib/free-model-data-disclosure'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -43,7 +43,7 @@ export function ModelSelector({ const selectedModel = options.find(m => m.id === value); const label = selectedModel?.name ?? (value || 'Model'); - const collectsData = isFreeModelOption(selectedModel); + const collectsData = isFreeKiloGatewayModelOption(selectedModel); const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false; const variantLabel = variant ? thinkingEffortLabel(variant) : ''; const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : ''; diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts index 4aa092fb43..f45cd0ec12 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.test.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -3,6 +3,7 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, + isFreeKiloGatewayModelOption, isFreeModelOption, } from './free-model-data-disclosure'; @@ -19,6 +20,13 @@ describe('free model data disclosure', () => { expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); }); + it('only marks Kilo Gateway free models as data collected', () => { + expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/free' })).toBe(true); + expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/frontier', isFree: true })).toBe(true); + expect(isFreeKiloGatewayModelOption({ id: 'openrouter/free' })).toBe(false); + expect(isFreeKiloGatewayModelOption({ id: 'anthropic/claude', isFree: true })).toBe(false); + }); + it('adds a data collection phrase to accessibility labels', () => { expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected'); }); diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 7b139d4dd6..9a9aa2106a 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -13,6 +13,15 @@ export function isFreeModelOption(model: { id: string; isFree?: boolean } | unde ); } +export function isFreeKiloGatewayModelOption(model: { id: string; isFree?: boolean } | undefined) { + if (!model) { + return false; + } + return ( + model.id === 'kilo-auto/free' || (model.isFree === true && model.id.startsWith('kilo-auto/')) + ); +} + export function getFreeModelDataAccessibilityLabel(label: string) { return `${label}, ${FREE_MODEL_DATA_LABEL}`; } diff --git a/apps/web/src/components/shared/ModelCombobox.tsx b/apps/web/src/components/shared/ModelCombobox.tsx index fcd05c0b30..5a43a5bcc1 100644 --- a/apps/web/src/components/shared/ModelCombobox.tsx +++ b/apps/web/src/components/shared/ModelCombobox.tsx @@ -22,6 +22,7 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataTooltip, + isFreeKiloGatewayModelOption, isFreeModelOption, } from '@/components/shared/free-model-data-disclosure'; @@ -118,7 +119,7 @@ export function ModelCombobox({ const selectedModel = models.find(model => model.id === value); const isCompact = variant === 'compact'; const showLabel = !isCompact && label; - const selectedIsFree = isFreeModelOption(selectedModel); + const selectedCollectsData = isFreeKiloGatewayModelOption(selectedModel); if (isLoading) { if (isCompact) { @@ -220,7 +221,7 @@ export function ModelCombobox({ {selectedModel ? formatShortModelDisplayName(selectedModel.name) : placeholder} - {selectedIsFree && } + {selectedCollectsData && } @@ -253,7 +254,11 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && } + {isFreeModelOption(model) && ( + + )} {model.id} @@ -291,7 +296,11 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && } + {isFreeModelOption(model) && ( + + )} {model.id} @@ -338,7 +347,7 @@ export function ModelCombobox({ {selectedModel ? selectedModel.name : placeholder} - {selectedIsFree && } + {selectedCollectsData && } @@ -375,7 +384,11 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && } + {isFreeModelOption(model) && ( + + )} {model.id} @@ -413,7 +426,11 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && } + {isFreeModelOption(model) && ( + + )} {model.id} @@ -454,25 +471,27 @@ function FreeModelDataIcon() { ); } -function FreeModelDataBadge() { +function FreeModelDataBadge(props: { collectsData: boolean }) { return ( {FREE_MODEL_FREE_LABEL} - - - - - - - {getFreeModelDataTooltip()} - + {props.collectsData ? ( + + + + + + + {getFreeModelDataTooltip()} + + ) : null} ); } diff --git a/apps/web/src/components/shared/free-model-data-disclosure.test.ts b/apps/web/src/components/shared/free-model-data-disclosure.test.ts index c778bc7284..5f68ffde86 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.test.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.test.ts @@ -3,6 +3,7 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataTooltip, + isFreeKiloGatewayModelOption, isFreeModelOption, } from './free-model-data-disclosure'; @@ -19,6 +20,13 @@ describe('free model data disclosure', () => { expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); }); + it('only marks Kilo Gateway free models as data collected', () => { + expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/free' })).toBe(true); + expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/frontier', isFree: true })).toBe(true); + expect(isFreeKiloGatewayModelOption({ id: 'openrouter/free' })).toBe(false); + expect(isFreeKiloGatewayModelOption({ id: 'anthropic/claude', isFree: true })).toBe(false); + }); + it('uses the short disclosure text as tooltip content', () => { expect(getFreeModelDataTooltip()).toBe('Data collected'); }); diff --git a/apps/web/src/components/shared/free-model-data-disclosure.ts b/apps/web/src/components/shared/free-model-data-disclosure.ts index d6e52e0930..1c3f5edfcc 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.ts @@ -16,3 +16,12 @@ export function isFreeModelOption(model: { id: string; isFree?: boolean } | unde model.id === 'openrouter/free' ); } + +export function isFreeKiloGatewayModelOption(model: { id: string; isFree?: boolean } | undefined) { + if (!model) { + return false; + } + return ( + model.id === 'kilo-auto/free' || (model.isFree === true && model.id.startsWith('kilo-auto/')) + ); +} From 4825321efebf5372fb8fec085d3fc973a0cbecf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 2 Jun 2026 14:58:02 +0200 Subject: [PATCH 5/6] fix(model-picker): warn on all free models --- .../src/app/(app)/agent-chat/model-picker.tsx | 3 +-- .../src/components/agents/model-selector.tsx | 4 ++-- .../lib/free-model-data-disclosure.test.ts | 11 ++--------- .../src/lib/free-model-data-disclosure.ts | 19 +------------------ .../src/components/shared/ModelCombobox.tsx | 19 +++++-------------- .../shared/free-model-data-disclosure.test.ts | 11 ++--------- .../shared/free-model-data-disclosure.ts | 19 +------------------ 7 files changed, 14 insertions(+), 72 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index 56f80a523b..b906a050e1 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -9,7 +9,6 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, - isFreeKiloGatewayModelOption, isFreeModelOption, } from '@/lib/free-model-data-disclosure'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -225,7 +224,7 @@ export default function ModelPickerScreen() { const modelOption = item.model; const selected = modelOption.id === selectedModel; const free = isFreeModelOption(modelOption); - const collectsData = isFreeKiloGatewayModelOption(modelOption); + const collectsData = isFreeModelOption(modelOption); const hasVariants = modelOption.variants.length > 1; return ( diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index d9b56f8ab8..adc2625f88 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, Brain, ChevronDown } from 'lucide-react-native'; import { Text } from '@/components/ui/text'; import { getFreeModelDataAccessibilityLabel, - isFreeKiloGatewayModelOption, + isFreeModelOption, } from '@/lib/free-model-data-disclosure'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -43,7 +43,7 @@ export function ModelSelector({ const selectedModel = options.find(m => m.id === value); const label = selectedModel?.name ?? (value || 'Model'); - const collectsData = isFreeKiloGatewayModelOption(selectedModel); + const collectsData = isFreeModelOption(selectedModel); const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false; const variantLabel = variant ? thinkingEffortLabel(variant) : ''; const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : ''; diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts index f45cd0ec12..7db41ba2bf 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.test.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -3,7 +3,6 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, - isFreeKiloGatewayModelOption, isFreeModelOption, } from './free-model-data-disclosure'; @@ -15,18 +14,12 @@ describe('free model data disclosure', () => { it('detects explicit and known free model options', () => { expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true); - expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/free', isFree: true })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(false); expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(false); expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); }); - it('only marks Kilo Gateway free models as data collected', () => { - expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/free' })).toBe(true); - expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/frontier', isFree: true })).toBe(true); - expect(isFreeKiloGatewayModelOption({ id: 'openrouter/free' })).toBe(false); - expect(isFreeKiloGatewayModelOption({ id: 'anthropic/claude', isFree: true })).toBe(false); - }); - it('adds a data collection phrase to accessibility labels', () => { expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected'); }); diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 9a9aa2106a..c2783d794f 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -2,24 +2,7 @@ export const FREE_MODEL_DATA_LABEL = 'Data collected'; export const FREE_MODEL_FREE_LABEL = 'Free'; export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { - if (!model) { - return false; - } - return ( - model.isFree === true || - model.id === 'kilo-auto/free' || - model.id.endsWith(':free') || - model.id === 'openrouter/free' - ); -} - -export function isFreeKiloGatewayModelOption(model: { id: string; isFree?: boolean } | undefined) { - if (!model) { - return false; - } - return ( - model.id === 'kilo-auto/free' || (model.isFree === true && model.id.startsWith('kilo-auto/')) - ); + return model?.isFree === true; } export function getFreeModelDataAccessibilityLabel(label: string) { diff --git a/apps/web/src/components/shared/ModelCombobox.tsx b/apps/web/src/components/shared/ModelCombobox.tsx index 5a43a5bcc1..42e5212de6 100644 --- a/apps/web/src/components/shared/ModelCombobox.tsx +++ b/apps/web/src/components/shared/ModelCombobox.tsx @@ -22,7 +22,6 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataTooltip, - isFreeKiloGatewayModelOption, isFreeModelOption, } from '@/components/shared/free-model-data-disclosure'; @@ -119,7 +118,7 @@ export function ModelCombobox({ const selectedModel = models.find(model => model.id === value); const isCompact = variant === 'compact'; const showLabel = !isCompact && label; - const selectedCollectsData = isFreeKiloGatewayModelOption(selectedModel); + const selectedCollectsData = isFreeModelOption(selectedModel); if (isLoading) { if (isCompact) { @@ -255,9 +254,7 @@ export function ModelCombobox({ )} {isFreeModelOption(model) && ( - + )} {model.id} @@ -297,9 +294,7 @@ export function ModelCombobox({ )} {isFreeModelOption(model) && ( - + )} {model.id} @@ -385,9 +380,7 @@ export function ModelCombobox({ )} {isFreeModelOption(model) && ( - + )} {model.id} @@ -427,9 +420,7 @@ export function ModelCombobox({ )} {isFreeModelOption(model) && ( - + )} {model.id} diff --git a/apps/web/src/components/shared/free-model-data-disclosure.test.ts b/apps/web/src/components/shared/free-model-data-disclosure.test.ts index 5f68ffde86..97ef773a4d 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.test.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.test.ts @@ -3,7 +3,6 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataTooltip, - isFreeKiloGatewayModelOption, isFreeModelOption, } from './free-model-data-disclosure'; @@ -15,18 +14,12 @@ describe('free model data disclosure', () => { it('detects explicit and known free model options', () => { expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).toBe(true); - expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/free', isFree: true })).toBe(true); + expect(isFreeModelOption({ id: 'openrouter/free' })).toBe(false); expect(isFreeModelOption({ id: 'openrouter/model-alpha' })).toBe(false); expect(isFreeModelOption({ id: 'anthropic/claude' })).toBe(false); }); - it('only marks Kilo Gateway free models as data collected', () => { - expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/free' })).toBe(true); - expect(isFreeKiloGatewayModelOption({ id: 'kilo-auto/frontier', isFree: true })).toBe(true); - expect(isFreeKiloGatewayModelOption({ id: 'openrouter/free' })).toBe(false); - expect(isFreeKiloGatewayModelOption({ id: 'anthropic/claude', isFree: true })).toBe(false); - }); - it('uses the short disclosure text as tooltip content', () => { expect(getFreeModelDataTooltip()).toBe('Data collected'); }); diff --git a/apps/web/src/components/shared/free-model-data-disclosure.ts b/apps/web/src/components/shared/free-model-data-disclosure.ts index 1c3f5edfcc..9c7182d9b0 100644 --- a/apps/web/src/components/shared/free-model-data-disclosure.ts +++ b/apps/web/src/components/shared/free-model-data-disclosure.ts @@ -6,22 +6,5 @@ export function getFreeModelDataTooltip() { } export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) { - if (!model) { - return false; - } - return ( - model.isFree === true || - model.id === 'kilo-auto/free' || - model.id.endsWith(':free') || - model.id === 'openrouter/free' - ); -} - -export function isFreeKiloGatewayModelOption(model: { id: string; isFree?: boolean } | undefined) { - if (!model) { - return false; - } - return ( - model.id === 'kilo-auto/free' || (model.isFree === true && model.id.startsWith('kilo-auto/')) - ); + return model?.isFree === true; } From cf47151a389df9a7312bc0ee9e502e275ce276dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 2 Jun 2026 15:04:11 +0200 Subject: [PATCH 6/6] fix(model-picker): tighten warning icon spacing --- .../src/components/shared/ModelCombobox.tsx | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/shared/ModelCombobox.tsx b/apps/web/src/components/shared/ModelCombobox.tsx index 42e5212de6..18d882e884 100644 --- a/apps/web/src/components/shared/ModelCombobox.tsx +++ b/apps/web/src/components/shared/ModelCombobox.tsx @@ -217,10 +217,12 @@ export function ModelCombobox({ className={cn('h-9 justify-between gap-1.5', className)} ref={triggerRef} > - - {selectedModel ? formatShortModelDisplayName(selectedModel.name) : placeholder} + + + {selectedModel ? formatShortModelDisplayName(selectedModel.name) : placeholder} + + {selectedCollectsData && } - {selectedCollectsData && } @@ -253,9 +255,7 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && ( - - )} + {isFreeModelOption(model) && } {model.id} @@ -293,9 +293,7 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && ( - - )} + {isFreeModelOption(model) && } {model.id} @@ -339,10 +337,12 @@ export function ModelCombobox({ className={cn('w-full justify-between', className)} ref={triggerRef} > - - {selectedModel ? selectedModel.name : placeholder} + + + {selectedModel ? selectedModel.name : placeholder} + + {selectedCollectsData && } - {selectedCollectsData && } @@ -379,9 +379,7 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && ( - - )} + {isFreeModelOption(model) && } {model.id} @@ -419,9 +417,7 @@ export function ModelCombobox({ Supports vision )} - {isFreeModelOption(model) && ( - - )} + {isFreeModelOption(model) && } {model.id} @@ -462,27 +458,25 @@ function FreeModelDataIcon() { ); } -function FreeModelDataBadge(props: { collectsData: boolean }) { +function FreeModelDataBadge() { return ( {FREE_MODEL_FREE_LABEL} - {props.collectsData ? ( - - - - - - - {getFreeModelDataTooltip()} - - ) : null} + + + + + + + {getFreeModelDataTooltip()} + ); }