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] 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'))) + ); +}