Skip to content
Draft
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
5 changes: 4 additions & 1 deletion apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type ReasoningEffort,
} from "@/features/tasks/composer/options";
import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer";
import { useModels } from "@/features/tasks/hooks/useModels";
import { taskKeys } from "@/features/tasks/hooks/useTasks";
import {
pendingTaskPromptStoreApi,
Expand Down Expand Up @@ -146,6 +147,7 @@ export default function TaskDetailScreen() {
const composerModel = composerConfig?.model ?? DEFAULT_MODEL;
const composerReasoning: ReasoningEffort =
composerConfig?.reasoning ?? DEFAULT_REASONING;
const { models } = useModels();

const { height } = useReanimatedKeyboardAnimation();

Expand Down Expand Up @@ -275,7 +277,7 @@ export default function TaskDetailScreen() {
)
: text;

const supportsReasoning = modelSupportsReasoning(composerModel);
const supportsReasoning = modelSupportsReasoning(composerModel, models);
const updatedTask = await runTaskInCloud(taskId, {
resumeFromRunId: task.latest_run?.id,
pendingUserMessage,
Expand Down Expand Up @@ -306,6 +308,7 @@ export default function TaskDetailScreen() {
composerMode,
composerModel,
composerReasoning,
models,
],
);

Expand Down
14 changes: 8 additions & 6 deletions apps/mobile/src/app/task/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ import {
DEFAULT_REASONING,
EXECUTION_MODES,
type ExecutionMode,
MODELS,
modeLabel,
modelLabel,
modelSupportsReasoning,
Expand All @@ -61,6 +60,7 @@ import {
import { Pill } from "@/features/tasks/composer/Pill";
import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline";
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
import { useModels } from "@/features/tasks/hooks/useModels";
import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations";
import {
generatePendingTaskKey,
Expand Down Expand Up @@ -126,6 +126,7 @@ export default function NewTaskScreen() {
refetch,
getUserIntegrationId,
} = useUserIntegrations();
const { models } = useModels();

const containerStyle = useAnimatedStyle(() => {
const kbHeight = -keyboard.height.value;
Expand Down Expand Up @@ -333,7 +334,7 @@ export default function NewTaskScreen() {
)
: trimmedPrompt;

const supportsReasoning = modelSupportsReasoning(model);
const supportsReasoning = modelSupportsReasoning(model, models);

await runTaskInCloud(task.id, {
pendingUserMessage,
Expand Down Expand Up @@ -361,6 +362,7 @@ export default function NewTaskScreen() {
creating,
mode,
model,
models,
prompt,
reasoning,
router,
Expand All @@ -373,7 +375,7 @@ export default function NewTaskScreen() {
const hasContent = !!prompt.trim() || attachments.length > 0;
const canSubmit =
hasContent && isRepositorySelectionComplete(selection) && !creating;
const showReasoningPill = modelSupportsReasoning(model);
const showReasoningPill = modelSupportsReasoning(model, models);

if (isLoading && hasGithubIntegration === null) {
return (
Expand Down Expand Up @@ -591,7 +593,7 @@ export default function NewTaskScreen() {

<Pill
icon={<Robot size={14} color={themeColors.gray[11]} />}
label={modelLabel(model)}
label={modelLabel(model, models)}
onPress={() => setModelSheetOpen(true)}
/>

Expand Down Expand Up @@ -708,12 +710,12 @@ export default function NewTaskScreen() {
value={model}
onChange={(value) => {
setModel(value);
if (!modelSupportsReasoning(value)) {
if (!modelSupportsReasoning(value, models)) {
setReasoning(DEFAULT_REASONING);
}
}}
onClose={() => setModelSheetOpen(false)}
options={MODELS.map((modelOption) => ({
options={models.map((modelOption) => ({
value: modelOption.value,
label: modelOption.label,
description: modelOption.description,
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
fetchGatewayModels,
formatGatewayModelName,
getLlmGatewayUrl,
isAnthropicModel,
supportsReasoningEffort,
} from "@posthog/shared";
import { fetch } from "expo/fetch";
import {
authedFetch,
Expand All @@ -7,6 +14,7 @@ import {
getProjectId,
} from "@/lib/api";
import { logger } from "@/lib/logger";
import type { ModelOption } from "./composer/options";
import type {
CreateTaskAutomationOptions,
CreateTaskOptions,
Expand All @@ -21,6 +29,25 @@ import type {

const log = logger.scope("tasks-api");

/**
* Download the list of available models from the LLM gateway, the same way the
* desktop app does (`AgentService.getPreviewConfigOptions`): hit the gateway's
* `/v1/models` endpoint for the current region, keep the Anthropic models, and
* format them for the picker. Fetching this rather than hardcoding it means new
* models show up without shipping a new mobile build.
*/
export async function getAvailableModels(): Promise<ModelOption[]> {
const gatewayUrl = getLlmGatewayUrl(getBaseUrl());
const gatewayModels = await fetchGatewayModels({ gatewayUrl });

return gatewayModels.filter(isAnthropicModel).map((model) => ({
value: model.id,
label: formatGatewayModelName(model),
description: `Context: ${model.context_window.toLocaleString()} tokens`,
supportsReasoning: supportsReasoningEffort(model.id),
}));
}

export class HttpError extends Error {
readonly status: number;

Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
View,
} from "react-native";
import { useVoiceRecording } from "@/features/chat";
import { useModels } from "@/features/tasks/hooks/useModels";
import { logger } from "@/lib/logger";
import { useThemeColors } from "@/lib/theme";
import { AttachmentSheet } from "./attachments/AttachmentSheet";
Expand All @@ -45,7 +46,6 @@ import {
DEFAULT_REASONING,
EXECUTION_MODES,
type ExecutionMode,
MODELS,
modeLabel,
modelLabel,
modelSupportsReasoning,
Expand Down Expand Up @@ -156,6 +156,7 @@ export function TaskChatComposer({
onReasoningChange,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
const { models } = useModels();
const [message, setMessage] = useState(() => initialMessage ?? "");
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
Expand All @@ -179,7 +180,7 @@ export function TaskChatComposer({
const [modelSheetOpen, setModelSheetOpen] = useState(false);
const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false);

const showReasoningPill = modelSupportsReasoning(model);
const showReasoningPill = modelSupportsReasoning(model, models);

const hasContent = message.trim().length > 0 || attachments.length > 0;
const canSend = hasContent && !disabled && !isRecording;
Expand Down Expand Up @@ -302,7 +303,7 @@ export function TaskChatComposer({

<Pill
icon={<Robot size={14} color={themeColors.gray[11]} />}
label={modelLabel(model)}
label={modelLabel(model, models)}
onPress={() => setModelSheetOpen(true)}
/>

Expand Down Expand Up @@ -378,12 +379,12 @@ export function TaskChatComposer({
// If the new model doesn't support reasoning, drop the level so the
// payload stays consistent. Default reasoning re-applies when
// switching back to a reasoning-capable model.
if (!modelSupportsReasoning(v)) {
if (!modelSupportsReasoning(v, models)) {
onReasoningChange(DEFAULT_REASONING);
}
}}
onClose={() => setModelSheetOpen(false)}
options={MODELS.map((m) => ({
options={models.map((m) => ({
value: m.value,
label: m.label,
description: m.description,
Expand Down
28 changes: 17 additions & 11 deletions apps/mobile/src/features/tasks/composer/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export interface ModelOption {
supportsReasoning: boolean;
}

export const MODELS: ModelOption[] = [
{
value: "claude-fable-5",
label: "Claude Fable 5",
description: "Newest, most capable",
supportsReasoning: true,
},
/**
* Last-resort model list. The real list is downloaded from the LLM gateway
* (see `getAvailableModels` / `useModels`), exactly like the desktop app. This
* only renders on a cold start before the first fetch lands, or if the gateway
* is unreachable, so the picker is never empty.
*/
export const FALLBACK_MODELS: ModelOption[] = [
{
value: "claude-opus-4-8",
label: "Claude Opus 4.8",
Expand Down Expand Up @@ -71,8 +71,11 @@ export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan";
export const DEFAULT_MODEL = "claude-opus-4-8";
export const DEFAULT_REASONING: ReasoningEffort = "high";

export function modelLabel(value: string): string {
return MODELS.find((m) => m.value === value)?.label ?? value;
export function modelLabel(
value: string,
models: ModelOption[] = FALLBACK_MODELS,
): string {
return models.find((m) => m.value === value)?.label ?? value;
}

export function modeLabel(value: ExecutionMode): string {
Expand All @@ -83,6 +86,9 @@ export function reasoningLabel(value: ReasoningEffort): string {
return REASONING_LEVELS.find((r) => r.value === value)?.label ?? value;
}

export function modelSupportsReasoning(value: string): boolean {
return MODELS.find((m) => m.value === value)?.supportsReasoning ?? false;
export function modelSupportsReasoning(
value: string,
models: ModelOption[] = FALLBACK_MODELS,
): boolean {
return models.find((m) => m.value === value)?.supportsReasoning ?? false;
}
73 changes: 73 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import type { CloudRegion } from "@/features/auth";
import { useAuthStore } from "@/features/auth";
import { getAvailableModels } from "../api";
import type { ModelOption } from "../composer/options";
import { useModelStore } from "../stores/modelStore";

export const modelKeys = {
all: ["models"] as const,
// The gateway URL is region-derived, so the cache must be keyed by region —
// otherwise switching regions would serve the previous region's models for
// the full staleTime window.
list: (region: CloudRegion | null) =>
[...modelKeys.all, "list", region] as const,
};

function modelsEqual(a: ModelOption[], b: ModelOption[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (
a[i].value !== b[i].value ||
a[i].label !== b[i].label ||
a[i].description !== b[i].description ||
a[i].supportsReasoning !== b[i].supportsReasoning
) {
return false;
}
}
return true;
}

/**
* Downloads the available model list from the LLM gateway (the same source the
* desktop app uses) and mirrors it into a persisted cache. Returns the live
* list once it lands, falling back to the cached snapshot so the picker always
* has something to render.
*/
export function useModels() {
const oauthAccessToken = useAuthStore((s) => s.oauthAccessToken);
const cloudRegion = useAuthStore((s) => s.cloudRegion);
const cachedModels = useModelStore((s) => s.models);
const setModels = useModelStore((s) => s.setModels);

const query = useQuery({
queryKey: modelKeys.list(cloudRegion),
queryFn: getAvailableModels,
enabled: !!oauthAccessToken && !!cloudRegion,
staleTime: 10 * 60 * 1000,
});

// Mirror the latest non-empty fetch into the persisted cache. Skip empty
// results (gateway unreachable) so a transient failure can't wipe a working
// snapshot, and skip no-op writes so we don't churn store subscribers.
// biome-ignore lint/correctness/useExhaustiveDependencies: setModels is a stable Zustand action
useEffect(() => {
const live = query.data;
if (!live || live.length === 0) return;
if (modelsEqual(live, cachedModels)) return;
setModels(live);
}, [query.data, cachedModels]);

// Prefer live data once it's in; fall back to the persisted snapshot.
const models =
query.data && query.data.length > 0 ? query.data : cachedModels;

return {
models,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
25 changes: 25 additions & 0 deletions apps/mobile/src/features/tasks/stores/modelStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { FALLBACK_MODELS, type ModelOption } from "../composer/options";

/** Caches the last model list downloaded from the LLM gateway so the picker
* renders instantly on a cold start while `useModels` refetches in the
* background. Seeded with the built-in fallback so it's never empty. */
interface ModelCacheState {
models: ModelOption[];
setModels: (models: ModelOption[]) => void;
}

export const useModelStore = create<ModelCacheState>()(
persist(
(set) => ({
models: FALLBACK_MODELS,
setModels: (models) => set({ models }),
}),
{
name: "ph-model-cache",
storage: createJSONStorage(() => AsyncStorage),
},
),
);
13 changes: 5 additions & 8 deletions packages/agent/src/adapters/claude/session/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { supportsReasoningEffort } from "@posthog/shared";

export const DEFAULT_MODEL = "opus";

const GATEWAY_TO_SDK_MODEL: Record<string, string> = {
Expand All @@ -21,21 +23,16 @@ export function supports1MContext(modelId: string): boolean {
return MODELS_WITH_1M_CONTEXT.has(modelId);
}

const MODELS_WITH_EFFORT = new Set([
"claude-opus-4-7",
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-fable-5",
]);

const MODELS_WITH_XHIGH_EFFORT = new Set([
"claude-opus-4-7",
"claude-opus-4-8",
"claude-fable-5",
]);

// Single source of truth lives in @posthog/shared so the mobile picker and the
// desktop/agent effort options agree on which models expose an effort control.
export function supportsEffort(modelId: string): boolean {
return MODELS_WITH_EFFORT.has(modelId);
return supportsReasoningEffort(modelId);
}

export function supportsXhighEffort(modelId: string): boolean {
Expand Down
Loading
Loading