Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/mobile/src/app/(app)/agent-chat/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand All @@ -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' : ''}`}
>
<View className="flex-1">
<Text className="text-base text-foreground">{modelOption.name}</Text>
<Text className="text-xs text-muted-foreground">{modelOption.id}</Text>
{collectsData ? (
<View
className="mt-1 self-start rounded-full border px-2 py-0.5"
style={{
borderColor: colors.warn,
backgroundColor: `${colors.warn}1A`,
}}
>
<Text
className="text-[11px] font-medium"
style={{ color: colors.warn }}
numberOfLines={1}
>
{FREE_MODEL_DATA_LABEL}
</Text>
</View>
) : null}
</View>
{selected && <Check size={18} color={colors.primary} />}
</Pressable>
Expand Down
15 changes: 11 additions & 4 deletions apps/mobile/src/components/agents/model-selector.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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'
Expand All @@ -76,6 +82,7 @@ export function ModelSelector({
>
{label}
</Text>
{collectsData ? <Info size={12} color={colors.warn} /> : null}
{hasVariants && compactVariantLabel ? (
<View className="flex-row items-center gap-1 rounded-full bg-neutral-200 px-1.5 py-0.5 dark:bg-neutral-800">
<Brain size={12} color={colors.mutedForeground} />
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/lib/free-model-data-disclosure.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
20 changes: 20 additions & 0 deletions apps/mobile/src/lib/free-model-data-disclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const FREE_MODEL_DATA_LABEL = 'Free - data collected';
export const FREE_MODEL_DATA_SHORT_LABEL = 'Data collected';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Same as the web counterpart — FREE_MODEL_DATA_SHORT_LABEL is exported but unused. Both files define it identically; if it is genuinely needed in the future, consider consolidating into a shared package rather than keeping two copies.


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/') &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Same false-positive risk as the web version — openrouter/*-alpha and openrouter/*-beta ID pattern is a heuristic, not a reliable signal. Prefer trusting the server-provided isFree flag exclusively.

(model.id.endsWith('-alpha') || model.id.endsWith('-beta')))
);
}

export function getFreeModelDataAccessibilityLabel(label: string) {
return `${label}, free model, usage data collected`;
}
4 changes: 4 additions & 0 deletions apps/mobile/src/lib/hooks/use-available-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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,
}));
Expand All @@ -123,6 +126,7 @@ export function useAvailableModels(organizationId: string | undefined) {
name: item.name,
variants: item.variants,
isPreferred: item.preferredIndex !== undefined,
isFree: item.isFree,
}));
}, [data]);

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1956,7 +1956,11 @@ export function SettingsTab({
const modelOptions = useMemo<ModelOption[]>(
() =>
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ export function EditWebhookTriggerContent({

// Transform models to ModelOption format
const modelOptions = useMemo<ModelOption[]>(
() => (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]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri

// Transform models to ModelOption format
const modelOptions = useMemo<ModelOption[]>(
() => (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]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props)
} = useModelSelectorList(organizationId);

const modelOptions = useMemo<ModelOption[]>(
() => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [],
() =>
modelsData?.data.map(model => ({
id: model.id,
name: model.name,
isFree: model.isFree,
})) ?? [],
[modelsData]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,12 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
} = useModelSelectorList(organizationId);

const modelOptions = useMemo<ModelOption[]>(
() => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [],
() =>
modelsData?.data.map(model => ({
id: model.id,
name: model.name,
isFree: model.isFree,
})) ?? [],
[modelsData]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,12 @@ export function OnboardingStepModel() {
} = useModelSelectorList(undefined);

const modelOptions = useMemo<ModelOption[]>(
() => modelsData?.data.map(model => ({ id: model.id, name: model.name })) ?? [],
() =>
modelsData?.data.map(model => ({
id: model.id,
name: model.name,
isFree: model.isFree,
})) ?? [],
[modelsData]
);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/app-builder/AppBuilderChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/app-builder/AppBuilderLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})) ?? []
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/cloud-agent/CloudSessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ export function useOrganizationModels(organizationId?: string): UseOrganizationM

// Format models for the combobox
const modelOptions = useMemo<ModelOption[]>(() => {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ export function DiscordIntegrationDetails({
useModelSelectorList(organizationId);

const modelOptions = useMemo<ModelOption[]>(() => {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ export function LinearIntegrationDetails({
useModelSelectorList(organizationId);

const modelOptions = useMemo<ModelOption[]>(() => {
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<string>('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ export function SlackIntegrationDetails({
useModelSelectorList(organizationId);

const modelOptions = useMemo<ModelOption[]>(() => {
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
Expand Down
Loading
Loading