diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx
index a6acd60af4..e7b2f89538 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -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,
@@ -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();
@@ -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,
@@ -306,6 +308,7 @@ export default function TaskDetailScreen() {
composerMode,
composerModel,
composerReasoning,
+ models,
],
);
diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx
index 16e91f6700..ff24c94b11 100644
--- a/apps/mobile/src/app/task/index.tsx
+++ b/apps/mobile/src/app/task/index.tsx
@@ -50,7 +50,6 @@ import {
DEFAULT_REASONING,
EXECUTION_MODES,
type ExecutionMode,
- MODELS,
modeLabel,
modelLabel,
modelSupportsReasoning,
@@ -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,
@@ -126,6 +126,7 @@ export default function NewTaskScreen() {
refetch,
getUserIntegrationId,
} = useUserIntegrations();
+ const { models } = useModels();
const containerStyle = useAnimatedStyle(() => {
const kbHeight = -keyboard.height.value;
@@ -333,7 +334,7 @@ export default function NewTaskScreen() {
)
: trimmedPrompt;
- const supportsReasoning = modelSupportsReasoning(model);
+ const supportsReasoning = modelSupportsReasoning(model, models);
await runTaskInCloud(task.id, {
pendingUserMessage,
@@ -361,6 +362,7 @@ export default function NewTaskScreen() {
creating,
mode,
model,
+ models,
prompt,
reasoning,
router,
@@ -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 (
@@ -591,7 +593,7 @@ export default function NewTaskScreen() {
}
- label={modelLabel(model)}
+ label={modelLabel(model, models)}
onPress={() => setModelSheetOpen(true)}
/>
@@ -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,
diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts
index c8f6b06997..dd789c1bac 100644
--- a/apps/mobile/src/features/tasks/api.ts
+++ b/apps/mobile/src/features/tasks/api.ts
@@ -1,3 +1,10 @@
+import {
+ fetchGatewayModels,
+ formatGatewayModelName,
+ getLlmGatewayUrl,
+ isAnthropicModel,
+ supportsReasoningEffort,
+} from "@posthog/shared";
import { fetch } from "expo/fetch";
import {
authedFetch,
@@ -7,6 +14,7 @@ import {
getProjectId,
} from "@/lib/api";
import { logger } from "@/lib/logger";
+import type { ModelOption } from "./composer/options";
import type {
CreateTaskAutomationOptions,
CreateTaskOptions,
@@ -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 {
+ 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;
diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
index 40ba430e71..96e9ceee17 100644
--- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
+++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
@@ -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";
@@ -45,7 +46,6 @@ import {
DEFAULT_REASONING,
EXECUTION_MODES,
type ExecutionMode,
- MODELS,
modeLabel,
modelLabel,
modelSupportsReasoning,
@@ -156,6 +156,7 @@ export function TaskChatComposer({
onReasoningChange,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
+ const { models } = useModels();
const [message, setMessage] = useState(() => initialMessage ?? "");
const [attachments, setAttachments] = useState([]);
const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false);
@@ -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;
@@ -302,7 +303,7 @@ export function TaskChatComposer({
}
- label={modelLabel(model)}
+ label={modelLabel(model, models)}
onPress={() => setModelSheetOpen(true)}
/>
@@ -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,
diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts
index 02f7c8d58e..a67e1e58bd 100644
--- a/apps/mobile/src/features/tasks/composer/options.ts
+++ b/apps/mobile/src/features/tasks/composer/options.ts
@@ -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",
@@ -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 {
@@ -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;
}
diff --git a/apps/mobile/src/features/tasks/hooks/useModels.ts b/apps/mobile/src/features/tasks/hooks/useModels.ts
new file mode 100644
index 0000000000..e331f61fd1
--- /dev/null
+++ b/apps/mobile/src/features/tasks/hooks/useModels.ts
@@ -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,
+ };
+}
diff --git a/apps/mobile/src/features/tasks/stores/modelStore.ts b/apps/mobile/src/features/tasks/stores/modelStore.ts
new file mode 100644
index 0000000000..33dbf7d747
--- /dev/null
+++ b/apps/mobile/src/features/tasks/stores/modelStore.ts
@@ -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()(
+ persist(
+ (set) => ({
+ models: FALLBACK_MODELS,
+ setModels: (models) => set({ models }),
+ }),
+ {
+ name: "ph-model-cache",
+ storage: createJSONStorage(() => AsyncStorage),
+ },
+ ),
+);
diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts
index ec2a561246..d1d2b59021 100644
--- a/packages/agent/src/adapters/claude/session/models.ts
+++ b/packages/agent/src/adapters/claude/session/models.ts
@@ -1,3 +1,5 @@
+import { supportsReasoningEffort } from "@posthog/shared";
+
export const DEFAULT_MODEL = "opus";
const GATEWAY_TO_SDK_MODEL: Record = {
@@ -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 {
diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts
index b8aafc78f5..727b38602a 100644
--- a/packages/agent/src/gateway-models.ts
+++ b/packages/agent/src/gateway-models.ts
@@ -1,213 +1,20 @@
-export interface GatewayModel {
- id: string;
- owned_by: string;
- context_window: number;
- supports_streaming: boolean;
- supports_vision: boolean;
-}
-
-interface GatewayModelsResponse {
- object: "list";
- data: GatewayModel[];
-}
-
-export interface FetchGatewayModelsOptions {
- gatewayUrl: string;
-}
-
-export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8";
-
-export const DEFAULT_CODEX_MODEL = "gpt-5.5";
-
-const BLOCKED_MODELS = new Set([
- "gpt-5-mini",
- "openai/gpt-5-mini",
- "gpt-5.2",
- "openai/gpt-5.2",
- "gpt-5.3",
- "openai/gpt-5.3",
- "gpt-5.3-codex",
- "openai/gpt-5.3-codex",
- "claude-opus-4-5",
- "anthropic/claude-opus-4-5",
- "claude-opus-4-6",
- "anthropic/claude-opus-4-6",
- "claude-sonnet-4-5",
- "anthropic/claude-sonnet-4-5",
- "claude-haiku-4-5",
- "anthropic/claude-haiku-4-5",
-]);
-
-export function isBlockedModelId(modelId: string): boolean {
- return BLOCKED_MODELS.has(modelId.toLowerCase());
-}
-
-type ModelsListResponse =
- | {
- data?: Array<{ id?: string; owned_by?: string }>;
- models?: Array<{ id?: string; owned_by?: string }>;
- }
- | Array<{ id?: string; owned_by?: string }>;
-
-const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
-
-let gatewayModelsCache: {
- models: GatewayModel[];
- expiry: number;
- url: string;
-} | null = null;
-
-export async function fetchGatewayModels(
- options?: FetchGatewayModelsOptions,
-): Promise {
- const gatewayUrl = options?.gatewayUrl ?? process.env.ANTHROPIC_BASE_URL;
- if (!gatewayUrl) {
- return [];
- }
-
- if (
- gatewayModelsCache &&
- gatewayModelsCache.url === gatewayUrl &&
- Date.now() < gatewayModelsCache.expiry
- ) {
- return gatewayModelsCache.models;
- }
-
- const modelsUrl = `${gatewayUrl}/v1/models`;
-
- try {
- const response = await fetch(modelsUrl);
-
- if (!response.ok) {
- return [];
- }
-
- const data = (await response.json()) as GatewayModelsResponse;
- const models = (data.data ?? []).filter((m) => !isBlockedModelId(m.id));
- gatewayModelsCache = {
- models,
- expiry: Date.now() + CACHE_TTL,
- url: gatewayUrl,
- };
- return models;
- } catch {
- return [];
- }
-}
-
-export function isAnthropicModel(model: GatewayModel): boolean {
- if (model.owned_by) {
- return model.owned_by === "anthropic";
- }
- return model.id.startsWith("claude-") || model.id.startsWith("anthropic/");
-}
-
-export function isOpenAIModel(model: GatewayModel): boolean {
- if (model.owned_by) {
- return model.owned_by === "openai";
- }
- return model.id.startsWith("gpt-") || model.id.startsWith("openai/");
-}
-
-export interface ModelInfo {
- id: string;
- owned_by?: string;
-}
-
-let modelsListCache: {
- models: ModelInfo[];
- expiry: number;
- url: string;
-} | null = null;
-
-export async function fetchModelsList(
- options?: FetchGatewayModelsOptions,
-): Promise {
- const gatewayUrl = options?.gatewayUrl ?? process.env.ANTHROPIC_BASE_URL;
- if (!gatewayUrl) {
- return [];
- }
-
- if (
- modelsListCache &&
- modelsListCache.url === gatewayUrl &&
- Date.now() < modelsListCache.expiry
- ) {
- return modelsListCache.models;
- }
-
- try {
- const modelsUrl = `${gatewayUrl}/v1/models`;
- const response = await fetch(modelsUrl);
- if (!response.ok) {
- return [];
- }
- const data = (await response.json()) as ModelsListResponse;
- const models = Array.isArray(data)
- ? data
- : (data.data ?? data.models ?? []);
- const results: ModelInfo[] = [];
- for (const model of models) {
- const id = model?.id ? String(model.id) : "";
- if (!id) continue;
- if (isBlockedModelId(id)) continue;
- results.push({ id, owned_by: model?.owned_by });
- }
- modelsListCache = {
- models: results,
- expiry: Date.now() + CACHE_TTL,
- url: gatewayUrl,
- };
- return results;
- } catch {
- return [];
- }
-}
-
-const PROVIDER_NAMES: Record = {
- anthropic: "Anthropic",
- openai: "OpenAI",
- "google-vertex": "Gemini",
-};
-
-export function getProviderName(ownedBy: string): string {
- return PROVIDER_NAMES[ownedBy] ?? ownedBy;
-}
-
-const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"];
-
-export function formatGatewayModelName(model: GatewayModel): string {
- if (isOpenAIModel(model)) {
- return stripProviderPrefix(model.id).toLowerCase();
- }
-
- return formatModelId(model.id);
-}
-
-function stripProviderPrefix(modelId: string): string {
- for (const prefix of PROVIDER_PREFIXES) {
- if (modelId.startsWith(prefix)) {
- return modelId.slice(prefix.length);
- }
- }
- return modelId;
-}
-
-export function formatModelId(modelId: string): string {
- let cleanId = modelId;
- for (const prefix of PROVIDER_PREFIXES) {
- if (cleanId.startsWith(prefix)) {
- cleanId = cleanId.slice(prefix.length);
- break;
- }
- }
-
- cleanId = cleanId.replace(/(\d)-(\d)/g, "$1.$2");
-
- const words = cleanId.split(/[-_]/).map((word) => {
- if (word.match(/^[0-9.]+$/)) return word;
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
- });
-
- return words.join(" ");
-}
+// The gateway model catalogue logic lives in @posthog/shared so the mobile app
+// and the agent/desktop fetch and format the available model list the exact
+// same way. Re-exported here to preserve this module's existing public surface
+// (consumed via the `@posthog/agent/gateway-models` subpath export).
+export {
+ DEFAULT_CODEX_MODEL,
+ DEFAULT_GATEWAY_MODEL,
+ type FetchGatewayModelsOptions,
+ fetchGatewayModels,
+ fetchModelsList,
+ formatGatewayModelName,
+ formatModelId,
+ type GatewayModel,
+ getProviderName,
+ isAnthropicModel,
+ isBlockedModelId,
+ isOpenAIModel,
+ type ModelInfo,
+ supportsReasoningEffort,
+} from "@posthog/shared";
diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts
index 3dcded7be4..8986eafca8 100644
--- a/packages/agent/src/utils/gateway.ts
+++ b/packages/agent/src/utils/gateway.ts
@@ -1,8 +1,14 @@
-export type GatewayProduct =
- | "posthog_code"
- | "background_agents"
- | "signals"
- | "slack_app";
+// Gateway URL construction lives in @posthog/shared so the mobile app and the
+// agent/desktop resolve the exact same endpoints. Re-exported here to preserve
+// this module's existing public surface.
+export {
+ type GatewayProduct,
+ getGatewayInvalidatePlanCacheUrl,
+ getGatewayUsageUrl,
+ getLlmGatewayUrl,
+} from "@posthog/shared";
+
+import type { GatewayProduct } from "@posthog/shared";
export function resolveGatewayProduct({
isInternal,
@@ -51,40 +57,3 @@ export function buildGatewayPropertyHeaders(
)
.join("\n");
}
-
-function getGatewayBaseUrl(posthogHost: string): string {
- const url = new URL(posthogHost);
- const hostname = url.hostname;
-
- if (hostname === "localhost" || hostname === "127.0.0.1") {
- return `${url.protocol}//localhost:3308`;
- }
-
- if (hostname === "host.docker.internal") {
- return `${url.protocol}//host.docker.internal:3308`;
- }
-
- const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us";
- return `https://gateway.${region}.posthog.com`;
-}
-
-export function getLlmGatewayUrl(
- posthogHost: string,
- product: GatewayProduct = "posthog_code",
-): string {
- return `${getGatewayBaseUrl(posthogHost)}/${product}`;
-}
-
-export function getGatewayUsageUrl(
- posthogHost: string,
- product: GatewayProduct = "posthog_code",
-): string {
- return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}`;
-}
-
-export function getGatewayInvalidatePlanCacheUrl(
- posthogHost: string,
- product: GatewayProduct = "posthog_code",
-): string {
- return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}/invalidate-plan-cache`;
-}
diff --git a/packages/shared/src/gateway-models.test.ts b/packages/shared/src/gateway-models.test.ts
new file mode 100644
index 0000000000..9259b836cf
--- /dev/null
+++ b/packages/shared/src/gateway-models.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatGatewayModelName,
+ isBlockedModelId,
+ supportsReasoningEffort,
+} from "./gateway-models";
+
+describe("formatGatewayModelName", () => {
+ it("keeps Claude models in friendly title case", () => {
+ expect(
+ formatGatewayModelName({
+ id: "claude-opus-4-8",
+ owned_by: "anthropic",
+ context_window: 200000,
+ supports_streaming: true,
+ supports_vision: true,
+ }),
+ ).toBe("Claude Opus 4.8");
+ });
+
+ it("formats OpenAI models as raw lowercase model ids", () => {
+ expect(
+ formatGatewayModelName({
+ id: "openai/gpt-5.5",
+ owned_by: "openai",
+ context_window: 200000,
+ supports_streaming: true,
+ supports_vision: true,
+ }),
+ ).toBe("gpt-5.5");
+ });
+});
+
+describe("isBlockedModelId", () => {
+ it.each([
+ { modelId: "claude-haiku-4-5", expected: true },
+ { modelId: "ANTHROPIC/CLAUDE-HAIKU-4-5", expected: true },
+ { modelId: "gpt-5.3-codex", expected: true },
+ { modelId: "claude-opus-4-8", expected: false },
+ { modelId: "claude-sonnet-4-6", expected: false },
+ ])("$modelId -> blocked=$expected", ({ modelId, expected }) => {
+ expect(isBlockedModelId(modelId)).toBe(expected);
+ });
+});
+
+describe("supportsReasoningEffort", () => {
+ it.each([
+ { modelId: "claude-opus-4-8", expected: true },
+ { modelId: "claude-sonnet-4-6", expected: true },
+ { modelId: "claude-fable-5", expected: true },
+ { modelId: "some-future-model", expected: false },
+ ])("$modelId -> effort=$expected", ({ modelId, expected }) => {
+ expect(supportsReasoningEffort(modelId)).toBe(expected);
+ });
+});
diff --git a/packages/shared/src/gateway-models.ts b/packages/shared/src/gateway-models.ts
new file mode 100644
index 0000000000..50c89d252b
--- /dev/null
+++ b/packages/shared/src/gateway-models.ts
@@ -0,0 +1,231 @@
+// Fetches and formats the LLM gateway's model catalogue. Shared between the
+// agent/desktop (Node) and the mobile app (React Native) so every client
+// downloads the available model list from the gateway the exact same way
+// rather than hardcoding it.
+
+export interface GatewayModel {
+ id: string;
+ owned_by: string;
+ context_window: number;
+ supports_streaming: boolean;
+ supports_vision: boolean;
+}
+
+interface GatewayModelsResponse {
+ object: "list";
+ data: GatewayModel[];
+}
+
+export interface FetchGatewayModelsOptions {
+ gatewayUrl: string;
+}
+
+export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8";
+
+export const DEFAULT_CODEX_MODEL = "gpt-5.5";
+
+const BLOCKED_MODELS = new Set([
+ "gpt-5-mini",
+ "openai/gpt-5-mini",
+ "gpt-5.2",
+ "openai/gpt-5.2",
+ "gpt-5.3",
+ "openai/gpt-5.3",
+ "gpt-5.3-codex",
+ "openai/gpt-5.3-codex",
+ "claude-opus-4-5",
+ "anthropic/claude-opus-4-5",
+ "claude-opus-4-6",
+ "anthropic/claude-opus-4-6",
+ "claude-sonnet-4-5",
+ "anthropic/claude-sonnet-4-5",
+ "claude-haiku-4-5",
+ "anthropic/claude-haiku-4-5",
+]);
+
+export function isBlockedModelId(modelId: string): boolean {
+ return BLOCKED_MODELS.has(modelId.toLowerCase());
+}
+
+type ModelsListResponse =
+ | {
+ data?: Array<{ id?: string; owned_by?: string }>;
+ models?: Array<{ id?: string; owned_by?: string }>;
+ }
+ | Array<{ id?: string; owned_by?: string }>;
+
+const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
+
+let gatewayModelsCache: {
+ models: GatewayModel[];
+ expiry: number;
+ url: string;
+} | null = null;
+
+export async function fetchGatewayModels(
+ options?: FetchGatewayModelsOptions,
+): Promise {
+ const gatewayUrl = options?.gatewayUrl ?? process.env.ANTHROPIC_BASE_URL;
+ if (!gatewayUrl) {
+ return [];
+ }
+
+ if (
+ gatewayModelsCache &&
+ gatewayModelsCache.url === gatewayUrl &&
+ Date.now() < gatewayModelsCache.expiry
+ ) {
+ return gatewayModelsCache.models;
+ }
+
+ const modelsUrl = `${gatewayUrl}/v1/models`;
+
+ try {
+ const response = await fetch(modelsUrl);
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const data = (await response.json()) as GatewayModelsResponse;
+ const models = (data.data ?? []).filter((m) => !isBlockedModelId(m.id));
+ gatewayModelsCache = {
+ models,
+ expiry: Date.now() + CACHE_TTL,
+ url: gatewayUrl,
+ };
+ return models;
+ } catch {
+ return [];
+ }
+}
+
+export function isAnthropicModel(model: GatewayModel): boolean {
+ if (model.owned_by) {
+ return model.owned_by === "anthropic";
+ }
+ return model.id.startsWith("claude-") || model.id.startsWith("anthropic/");
+}
+
+export function isOpenAIModel(model: GatewayModel): boolean {
+ if (model.owned_by) {
+ return model.owned_by === "openai";
+ }
+ return model.id.startsWith("gpt-") || model.id.startsWith("openai/");
+}
+
+export interface ModelInfo {
+ id: string;
+ owned_by?: string;
+}
+
+let modelsListCache: {
+ models: ModelInfo[];
+ expiry: number;
+ url: string;
+} | null = null;
+
+export async function fetchModelsList(
+ options?: FetchGatewayModelsOptions,
+): Promise {
+ const gatewayUrl = options?.gatewayUrl ?? process.env.ANTHROPIC_BASE_URL;
+ if (!gatewayUrl) {
+ return [];
+ }
+
+ if (
+ modelsListCache &&
+ modelsListCache.url === gatewayUrl &&
+ Date.now() < modelsListCache.expiry
+ ) {
+ return modelsListCache.models;
+ }
+
+ try {
+ const modelsUrl = `${gatewayUrl}/v1/models`;
+ const response = await fetch(modelsUrl);
+ if (!response.ok) {
+ return [];
+ }
+ const data = (await response.json()) as ModelsListResponse;
+ const models = Array.isArray(data)
+ ? data
+ : (data.data ?? data.models ?? []);
+ const results: ModelInfo[] = [];
+ for (const model of models) {
+ const id = model?.id ? String(model.id) : "";
+ if (!id) continue;
+ if (isBlockedModelId(id)) continue;
+ results.push({ id, owned_by: model?.owned_by });
+ }
+ modelsListCache = {
+ models: results,
+ expiry: Date.now() + CACHE_TTL,
+ url: gatewayUrl,
+ };
+ return results;
+ } catch {
+ return [];
+ }
+}
+
+const PROVIDER_NAMES: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ "google-vertex": "Gemini",
+};
+
+export function getProviderName(ownedBy: string): string {
+ return PROVIDER_NAMES[ownedBy] ?? ownedBy;
+}
+
+const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"];
+
+export function formatGatewayModelName(model: GatewayModel): string {
+ if (isOpenAIModel(model)) {
+ return stripProviderPrefix(model.id).toLowerCase();
+ }
+
+ return formatModelId(model.id);
+}
+
+function stripProviderPrefix(modelId: string): string {
+ for (const prefix of PROVIDER_PREFIXES) {
+ if (modelId.startsWith(prefix)) {
+ return modelId.slice(prefix.length);
+ }
+ }
+ return modelId;
+}
+
+export function formatModelId(modelId: string): string {
+ let cleanId = modelId;
+ for (const prefix of PROVIDER_PREFIXES) {
+ if (cleanId.startsWith(prefix)) {
+ cleanId = cleanId.slice(prefix.length);
+ break;
+ }
+ }
+
+ cleanId = cleanId.replace(/(\d)-(\d)/g, "$1.$2");
+
+ const words = cleanId.split(/[-_]/).map((word) => {
+ if (word.match(/^[0-9.]+$/)) return word;
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+ });
+
+ return words.join(" ");
+}
+
+// Single source of truth for which models expose a reasoning-effort control.
+// Consumed by the agent's `supportsEffort` and the mobile model picker.
+const MODELS_WITH_EFFORT = new Set([
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-sonnet-4-6",
+ "claude-fable-5",
+]);
+
+export function supportsReasoningEffort(modelId: string): boolean {
+ return MODELS_WITH_EFFORT.has(modelId);
+}
diff --git a/packages/shared/src/gateway.test.ts b/packages/shared/src/gateway.test.ts
new file mode 100644
index 0000000000..a3bd5b6b09
--- /dev/null
+++ b/packages/shared/src/gateway.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import {
+ getGatewayInvalidatePlanCacheUrl,
+ getGatewayUsageUrl,
+ getLlmGatewayUrl,
+} from "./gateway";
+
+describe("getLlmGatewayUrl", () => {
+ it.each([
+ {
+ host: "https://us.posthog.com",
+ expected: "https://gateway.us.posthog.com/posthog_code",
+ },
+ {
+ host: "https://eu.posthog.com",
+ expected: "https://gateway.eu.posthog.com/posthog_code",
+ },
+ {
+ // Unknown self-hosted host falls back to the US gateway.
+ host: "https://app.example.com",
+ expected: "https://gateway.us.posthog.com/posthog_code",
+ },
+ {
+ host: "http://localhost:8000",
+ expected: "http://localhost:3308/posthog_code",
+ },
+ {
+ host: "http://127.0.0.1:8000",
+ expected: "http://localhost:3308/posthog_code",
+ },
+ ])("maps $host -> $expected", ({ host, expected }) => {
+ expect(getLlmGatewayUrl(host)).toBe(expected);
+ });
+
+ it("respects the product segment", () => {
+ expect(getLlmGatewayUrl("https://eu.posthog.com", "slack_app")).toBe(
+ "https://gateway.eu.posthog.com/slack_app",
+ );
+ });
+});
+
+describe("usage urls", () => {
+ it("builds the usage url", () => {
+ expect(getGatewayUsageUrl("https://us.posthog.com")).toBe(
+ "https://gateway.us.posthog.com/v1/usage/posthog_code",
+ );
+ });
+
+ it("builds the invalidate-plan-cache url", () => {
+ expect(getGatewayInvalidatePlanCacheUrl("https://us.posthog.com")).toBe(
+ "https://gateway.us.posthog.com/v1/usage/posthog_code/invalidate-plan-cache",
+ );
+ });
+});
diff --git a/packages/shared/src/gateway.ts b/packages/shared/src/gateway.ts
new file mode 100644
index 0000000000..9948be82df
--- /dev/null
+++ b/packages/shared/src/gateway.ts
@@ -0,0 +1,46 @@
+// LLM gateway URL construction. Shared between the agent/desktop (Node) and the
+// mobile app (React Native) so every client points at the exact same gateway
+// endpoints and resolves the region the same way.
+
+export type GatewayProduct =
+ | "posthog_code"
+ | "background_agents"
+ | "signals"
+ | "slack_app";
+
+function getGatewayBaseUrl(posthogHost: string): string {
+ const url = new URL(posthogHost);
+ const hostname = url.hostname;
+
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
+ return `${url.protocol}//localhost:3308`;
+ }
+
+ if (hostname === "host.docker.internal") {
+ return `${url.protocol}//host.docker.internal:3308`;
+ }
+
+ const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us";
+ return `https://gateway.${region}.posthog.com`;
+}
+
+export function getLlmGatewayUrl(
+ posthogHost: string,
+ product: GatewayProduct = "posthog_code",
+): string {
+ return `${getGatewayBaseUrl(posthogHost)}/${product}`;
+}
+
+export function getGatewayUsageUrl(
+ posthogHost: string,
+ product: GatewayProduct = "posthog_code",
+): string {
+ return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}`;
+}
+
+export function getGatewayInvalidatePlanCacheUrl(
+ posthogHost: string,
+ product: GatewayProduct = "posthog_code",
+): string {
+ return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}/invalidate-plan-cache`;
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 18974bda31..c8a20c5cf4 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -13,6 +13,28 @@ export {
promptBlocksToText,
serializeCloudPrompt,
} from "./cloud-prompt";
+export {
+ type GatewayProduct,
+ getGatewayInvalidatePlanCacheUrl,
+ getGatewayUsageUrl,
+ getLlmGatewayUrl,
+} from "./gateway";
+export {
+ DEFAULT_CODEX_MODEL,
+ DEFAULT_GATEWAY_MODEL,
+ type FetchGatewayModelsOptions,
+ fetchGatewayModels,
+ fetchModelsList,
+ formatGatewayModelName,
+ formatModelId,
+ type GatewayModel,
+ getProviderName,
+ isAnthropicModel,
+ isBlockedModelId,
+ isOpenAIModel,
+ type ModelInfo,
+ supportsReasoningEffort,
+} from "./gateway-models";
export {
ALLOWED_IMAGE_MIME_TYPES,
buildImageDataUrl,