Skip to content
Merged
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
31 changes: 29 additions & 2 deletions apps/mobile/src/app/(app)/agent-chat/model-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
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';
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 +223,8 @@ export default function ModelPickerScreen() {

const modelOption = item.model;
const selected = modelOption.id === selectedModel;
const free = isFreeModelOption(modelOption);
const collectsData = isFreeModelOption(modelOption);
const hasVariants = modelOption.variants.length > 1;

return (
Expand All @@ -227,11 +235,30 @@ 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>
{free ? (
<View className="mt-1 flex-row items-center gap-1 self-start">
<View
className="rounded-full px-2 py-0.5"
style={{ backgroundColor: colors.good }}
>
<Text className="text-[11px] font-medium text-white" numberOfLines={1}>
{FREE_MODEL_FREE_LABEL}
</Text>
</View>
{collectsData ? (
<AlertTriangle
accessibilityLabel={FREE_MODEL_DATA_LABEL}
size={13}
color={colors.warn}
/>
) : null}
</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 { AlertTriangle, Brain, ChevronDown } 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 ? <AlertTriangle 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
26 changes: 26 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,26 @@
import { describe, expect, it } from 'vitest';
import {
FREE_MODEL_DATA_LABEL,
FREE_MODEL_FREE_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('Data collected');
expect(FREE_MODEL_FREE_LABEL).toBe('Free');
});

it('detects explicit and known free model options', () => {
expect(isFreeModelOption({ id: 'anthropic/claude', isFree: true })).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('adds a data collection phrase to accessibility labels', () => {
expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected');
});
});
10 changes: 10 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,10 @@
export const FREE_MODEL_DATA_LABEL = 'Data collected';
export const FREE_MODEL_FREE_LABEL = 'Free';

export function isFreeModelOption(model: { id: string; isFree?: boolean } | undefined) {
return model?.isFree === true;
}

export function getFreeModelDataAccessibilityLabel(label: string) {
return `${label}, ${FREE_MODEL_DATA_LABEL}`;
}
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 @@ -152,6 +152,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 @@ -42,6 +42,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