From eb49b190ffae6a4baf4dc239aa6d450c67cf572c Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 10 Jun 2026 10:52:26 +0300 Subject: [PATCH 1/3] feat(mobile): fetch available model list from the LLM gateway The mobile app hardcoded its model list in `composer/options.ts`, so new models (e.g. 4.8 / 5) never appeared without shipping a new build. The desktop app instead downloads the catalogue from the LLM gateway's `/v1/models` endpoint. Move the gateway URL construction and model-fetch/format logic into `@posthog/shared` (zero-dependency, already a mobile dependency) as the single source of truth, and have `packages/agent` re-export from it so the desktop path is literally the same code. Mobile now fetches the list the same way via a `useModels` hook backed by a persisted cache, falling back to a small built-in list only on a cold start or when the gateway is unreachable. Generated-By: PostHog Code Task-Id: 89aa4bb4-eaa8-4894-8c4c-3f6b096fc78a --- apps/mobile/src/app/task/[id].tsx | 5 +- apps/mobile/src/app/task/index.tsx | 14 +- apps/mobile/src/features/tasks/api.ts | 27 ++ .../tasks/composer/TaskChatComposer.tsx | 11 +- .../src/features/tasks/composer/options.ts | 28 ++- .../src/features/tasks/hooks/useModels.ts | 68 +++++ .../src/features/tasks/stores/modelStore.ts | 25 ++ packages/agent/src/gateway-models.ts | 233 ++---------------- packages/agent/src/utils/gateway.ts | 52 +--- packages/shared/src/gateway-models.test.ts | 57 +++++ packages/shared/src/gateway-models.ts | 231 +++++++++++++++++ packages/shared/src/gateway.test.ts | 54 ++++ packages/shared/src/gateway.ts | 46 ++++ packages/shared/src/index.ts | 22 ++ 14 files changed, 595 insertions(+), 278 deletions(-) create mode 100644 apps/mobile/src/features/tasks/hooks/useModels.ts create mode 100644 apps/mobile/src/features/tasks/stores/modelStore.ts create mode 100644 packages/shared/src/gateway-models.test.ts create mode 100644 packages/shared/src/gateway-models.ts create mode 100644 packages/shared/src/gateway.test.ts create mode 100644 packages/shared/src/gateway.ts 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..e7c981b808 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useModels.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +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, + list: () => [...modelKeys.all, "list"] 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(), + 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/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..524d589519 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -1,8 +1,13 @@ -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 +56,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..d4e888feb0 --- /dev/null +++ b/packages/shared/src/gateway-models.test.ts @@ -0,0 +1,57 @@ +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("blocks deprecated gateway models case-insensitively", () => { + expect(isBlockedModelId("claude-haiku-4-5")).toBe(true); + expect(isBlockedModelId("ANTHROPIC/CLAUDE-HAIKU-4-5")).toBe(true); + expect(isBlockedModelId("gpt-5.3-codex")).toBe(true); + }); + + it("keeps current models", () => { + expect(isBlockedModelId("claude-opus-4-8")).toBe(false); + expect(isBlockedModelId("claude-sonnet-4-6")).toBe(false); + }); +}); + +describe("supportsReasoningEffort", () => { + it("is true for models with an effort control", () => { + expect(supportsReasoningEffort("claude-opus-4-8")).toBe(true); + expect(supportsReasoningEffort("claude-sonnet-4-6")).toBe(true); + expect(supportsReasoningEffort("claude-fable-5")).toBe(true); + }); + + it("is false for unknown models", () => { + expect(supportsReasoningEffort("some-future-model")).toBe(false); + }); +}); diff --git a/packages/shared/src/gateway-models.ts b/packages/shared/src/gateway-models.ts new file mode 100644 index 0000000000..4a50c50a78 --- /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(" "); +} + +// Models that expose a reasoning-effort control. Mirrors `MODELS_WITH_EFFORT` +// in packages/agent/src/adapters/claude/session/models.ts — keep in sync. +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, From af9fff20b2a4306c654b3fbe17abeeee86312602 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 10 Jun 2026 11:12:38 +0300 Subject: [PATCH 2/3] fix: sort gateway.ts imports and dedupe effort-model set into shared - Fix the `quality` (biome ci) failure: organize imports/exports in utils/gateway.ts. - Address Greptile review: delete the agent's duplicate MODELS_WITH_EFFORT set and have `supportsEffort` delegate to `supportsReasoningEffort` in @posthog/shared, so there's a single source of truth instead of two sets kept in sync manually. Generated-By: PostHog Code Task-Id: 89aa4bb4-eaa8-4894-8c4c-3f6b096fc78a --- .../agent/src/adapters/claude/session/models.ts | 13 +++++-------- packages/agent/src/utils/gateway.ts | 1 + packages/shared/src/gateway-models.ts | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) 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/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index 524d589519..8986eafca8 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -7,6 +7,7 @@ export { getGatewayUsageUrl, getLlmGatewayUrl, } from "@posthog/shared"; + import type { GatewayProduct } from "@posthog/shared"; export function resolveGatewayProduct({ diff --git a/packages/shared/src/gateway-models.ts b/packages/shared/src/gateway-models.ts index 4a50c50a78..50c89d252b 100644 --- a/packages/shared/src/gateway-models.ts +++ b/packages/shared/src/gateway-models.ts @@ -217,8 +217,8 @@ export function formatModelId(modelId: string): string { return words.join(" "); } -// Models that expose a reasoning-effort control. Mirrors `MODELS_WITH_EFFORT` -// in packages/agent/src/adapters/claude/session/models.ts — keep in sync. +// 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", From cc47f468e35a061fca22883bc7ee297666eef3fd Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Wed, 10 Jun 2026 11:34:15 +0300 Subject: [PATCH 3/3] fix(mobile): key model query by region; parameterize gateway tests Address Greptile review: - Include cloudRegion in the useModels query key. The gateway URL is region-derived, so a constant key served the previous region's cached models for the full staleTime window after a region switch. - Convert the isBlockedModelId and supportsReasoningEffort tests to it.each tables to match the repo's parameterized-test convention. Generated-By: PostHog Code Task-Id: 89aa4bb4-eaa8-4894-8c4c-3f6b096fc78a --- .../src/features/tasks/hooks/useModels.ts | 9 ++++-- packages/shared/src/gateway-models.test.ts | 32 +++++++++---------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/tasks/hooks/useModels.ts b/apps/mobile/src/features/tasks/hooks/useModels.ts index e7c981b808..e331f61fd1 100644 --- a/apps/mobile/src/features/tasks/hooks/useModels.ts +++ b/apps/mobile/src/features/tasks/hooks/useModels.ts @@ -1,5 +1,6 @@ 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"; @@ -7,7 +8,11 @@ import { useModelStore } from "../stores/modelStore"; export const modelKeys = { all: ["models"] as const, - list: () => [...modelKeys.all, "list"] 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 { @@ -39,7 +44,7 @@ export function useModels() { const setModels = useModelStore((s) => s.setModels); const query = useQuery({ - queryKey: modelKeys.list(), + queryKey: modelKeys.list(cloudRegion), queryFn: getAvailableModels, enabled: !!oauthAccessToken && !!cloudRegion, staleTime: 10 * 60 * 1000, diff --git a/packages/shared/src/gateway-models.test.ts b/packages/shared/src/gateway-models.test.ts index d4e888feb0..9259b836cf 100644 --- a/packages/shared/src/gateway-models.test.ts +++ b/packages/shared/src/gateway-models.test.ts @@ -32,26 +32,24 @@ describe("formatGatewayModelName", () => { }); describe("isBlockedModelId", () => { - it("blocks deprecated gateway models case-insensitively", () => { - expect(isBlockedModelId("claude-haiku-4-5")).toBe(true); - expect(isBlockedModelId("ANTHROPIC/CLAUDE-HAIKU-4-5")).toBe(true); - expect(isBlockedModelId("gpt-5.3-codex")).toBe(true); - }); - - it("keeps current models", () => { - expect(isBlockedModelId("claude-opus-4-8")).toBe(false); - expect(isBlockedModelId("claude-sonnet-4-6")).toBe(false); + 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("is true for models with an effort control", () => { - expect(supportsReasoningEffort("claude-opus-4-8")).toBe(true); - expect(supportsReasoningEffort("claude-sonnet-4-6")).toBe(true); - expect(supportsReasoningEffort("claude-fable-5")).toBe(true); - }); - - it("is false for unknown models", () => { - expect(supportsReasoningEffort("some-future-model")).toBe(false); + 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); }); });