diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx
index 2d7bae6ff8..e499442933 100644
--- a/packages/ui/src/features/loops/components/LoopDetailView.tsx
+++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx
@@ -29,7 +29,6 @@ import {
import { Flex, Text } from "@radix-ui/themes";
import { useRef, useState } from "react";
import { useLoop } from "../hooks/useLoop";
-import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel";
import {
useDeleteLoop,
useRunLoop,
@@ -45,6 +44,7 @@ import {
nextScheduleRun,
summarizeNotificationDestinations,
} from "../loopDisplay";
+import { formatLoopModel } from "../loopModels";
import { LoopLoadError } from "./LoopFallbacks";
import { LoopRunRow } from "./LoopRunRow";
@@ -320,7 +320,7 @@ function PausedNotice({ loop }: { loop: LoopSchemas.Loop }) {
}
function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
- const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model);
+ const displayModel = formatLoopModel(loop.runtime_adapter, loop.model);
const {
members,
isLoading: membersLoading,
diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx
index 93b1b3f485..14842f1b30 100644
--- a/packages/ui/src/features/loops/components/LoopForm.tsx
+++ b/packages/ui/src/features/loops/components/LoopForm.tsx
@@ -33,6 +33,7 @@ import {
loopToFormValues,
normalizeLoopFormValues,
} from "../loopFormTypes";
+import { formatLoopModel } from "../loopModels";
import { LoopBehaviorFields } from "./LoopBehaviorFields";
import { LoopContextFields } from "./LoopContextFields";
import { Field } from "./LoopFormPrimitives";
@@ -548,9 +549,10 @@ function ReviewList({
/>
{showContext ? (
{
- const ids = new Set();
- for (const entry of catalog.models) {
- ids.add(entry.model);
- }
- if (ids.size === 0) {
- for (const level of Object.values(catalog.levels)) {
- for (const id of level) {
- ids.add(id);
- }
- }
- }
- if (model) {
- ids.add(model);
- }
- const catalogOptions = Array.from(ids)
- .sort((a, b) => a.localeCompare(b))
- .map((id) => ({ value: id, label: id }));
- return [
+ const modelOptions = useMemo(
+ () => [
{ value: DEFAULT_MODEL_VALUE, label: "Default (recommended)" },
- ...catalogOptions,
- ];
- }, [catalog, model]);
+ ...loopModelOptions(adapter, configOptions, {
+ glmEnabled,
+ pinnedModel: model,
+ }),
+ ],
+ [adapter, configOptions, glmEnabled, model],
+ );
+
+ const reasoningOptions = useMemo(
+ () => [
+ { value: AUTO_REASONING_VALUE, label: "Auto" },
+ ...loopReasoningEffortOptions(adapter, model),
+ ],
+ [adapter, model],
+ );
+
+ const handleAdapterChange = (value: string) => {
+ const nextAdapter = value as LoopSchemas.LoopRuntimeAdapterEnum;
+ onAdapterChange(nextAdapter);
+ // Adapters have disjoint model catalogs, so a pinned model can't carry over.
+ if (model) onModelChange("");
+ const clamped = clampLoopReasoningEffort(nextAdapter, "", reasoningEffort);
+ if (clamped !== reasoningEffort) onReasoningEffortChange(clamped);
+ };
+
+ const handleModelChange = (value: string) => {
+ const nextModel = value === DEFAULT_MODEL_VALUE ? "" : value;
+ onModelChange(nextModel);
+ const clamped = clampLoopReasoningEffort(
+ adapter,
+ nextModel,
+ reasoningEffort,
+ );
+ if (clamped !== reasoningEffort) onReasoningEffortChange(clamped);
+ };
return (
@@ -96,9 +106,7 @@ export function LoopModelFields({
value={model || DEFAULT_MODEL_VALUE}
options={modelOptions}
placeholder="Default (recommended)"
- onValueChange={(value) =>
- onModelChange(value === DEFAULT_MODEL_VALUE ? "" : value)
- }
+ onValueChange={handleModelChange}
disabled={disabled}
size="lg"
ariaLabel="Model"
@@ -110,9 +118,7 @@ export function LoopModelFields({
- onAdapterChange(value as LoopSchemas.LoopRuntimeAdapterEnum)
- }
+ onValueChange={handleAdapterChange}
disabled={disabled}
size="lg"
ariaLabel="Adapter"
@@ -122,7 +128,8 @@ export function LoopModelFields({
onReasoningEffortChange(
value === AUTO_REASONING_VALUE
diff --git a/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts b/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts
deleted file mode 100644
index a63d7660dc..0000000000
--- a/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { LoopSchemas } from "@posthog/api-client/loops";
-import {
- REPORT_MODEL_RESOLVER,
- type ReportModelResolver,
-} from "@posthog/core/inbox/identifiers";
-import { useService } from "@posthog/di/react";
-import { getCloudUrlFromRegion } from "@posthog/shared";
-import { useAuthStateValue } from "@posthog/ui/features/auth/store";
-import { useQuery } from "@tanstack/react-query";
-
-export function useLoopDisplayModel(
- adapter: LoopSchemas.LoopRuntimeAdapterEnum,
- configuredModel: string,
-): string {
- const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
- const modelResolver = useService(REPORT_MODEL_RESOLVER);
- const { data } = useQuery({
- queryKey: ["loops", "default-model", cloudRegion, adapter],
- queryFn: () => {
- if (!cloudRegion) return undefined;
- return modelResolver.resolveDefaultModel(
- getCloudUrlFromRegion(cloudRegion),
- adapter,
- );
- },
- enabled: !configuredModel && !!cloudRegion,
- staleTime: 5 * 60_000,
- });
-
- return configuredModel || data || "Default model";
-}
diff --git a/packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts b/packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts
new file mode 100644
index 0000000000..6a48ca1dd4
--- /dev/null
+++ b/packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts
@@ -0,0 +1,31 @@
+import type { SessionConfigOption } from "@agentclientprotocol/sdk";
+import type { LoopSchemas } from "@posthog/api-client/loops";
+import { useHostTRPCClient } from "@posthog/host-router/react";
+import { getCloudUrlFromRegion } from "@posthog/shared";
+import { useAuthStateValue } from "@posthog/ui/features/auth/store";
+import { useQuery } from "@tanstack/react-query";
+
+/**
+ * The per-adapter session config options (models, efforts) the main
+ * create-task picker is built from, for reuse by the loop form's static
+ * pickers. No agent session is created.
+ */
+export function useLoopModelConfigOptions(
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum,
+): SessionConfigOption[] {
+ const hostClient = useHostTRPCClient();
+ const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
+ const { data } = useQuery({
+ queryKey: ["loops", "model-config-options", cloudRegion, adapter],
+ queryFn: ({ signal }) => {
+ if (!cloudRegion) return [];
+ return hostClient.agent.getPreviewConfigOptions.query(
+ { apiHost: getCloudUrlFromRegion(cloudRegion), adapter },
+ { signal },
+ );
+ },
+ enabled: !!cloudRegion,
+ staleTime: 5 * 60_000,
+ });
+ return data ?? [];
+}
diff --git a/packages/ui/src/features/loops/loopModels.test.ts b/packages/ui/src/features/loops/loopModels.test.ts
new file mode 100644
index 0000000000..26bed1649e
--- /dev/null
+++ b/packages/ui/src/features/loops/loopModels.test.ts
@@ -0,0 +1,267 @@
+import type {
+ SessionConfigOption,
+ SessionConfigSelectOptions,
+} from "@agentclientprotocol/sdk";
+import type { LoopSchemas } from "@posthog/api-client/loops";
+import { restrictedModelMeta } from "@posthog/shared";
+import { describe, expect, it } from "vitest";
+import {
+ clampLoopReasoningEffort,
+ LOOP_DEFAULT_MODELS,
+ loopModelOptions,
+ loopReasoningEffortOptions,
+} from "./loopModels";
+
+function modelConfigOption(
+ options: SessionConfigSelectOptions,
+): SessionConfigOption[] {
+ return [
+ {
+ type: "select",
+ id: "model",
+ name: "Model",
+ category: "model",
+ currentValue: "claude-sonnet-5",
+ options,
+ },
+ ];
+}
+
+const claudeOptions = modelConfigOption([
+ { value: "claude-sonnet-5", name: "Claude Sonnet 5" },
+ { value: "@cf/zai-org/glm-5.2", name: "GLM-5.2" },
+]);
+
+describe("loopModelOptions", () => {
+ it("maps served model options to value/label pairs", () => {
+ expect(
+ loopModelOptions("claude", claudeOptions, {
+ glmEnabled: true,
+ pinnedModel: "",
+ }),
+ ).toEqual([
+ { value: "claude-sonnet-5", label: "Claude Sonnet 5" },
+ { value: "@cf/zai-org/glm-5.2", label: "GLM-5.2" },
+ ]);
+ });
+
+ it("flattens grouped select options", () => {
+ const grouped = modelConfigOption([
+ {
+ group: "anthropic",
+ name: "Anthropic",
+ options: [{ value: "claude-sonnet-5", name: "Claude Sonnet 5" }],
+ },
+ ]);
+ expect(
+ loopModelOptions("claude", grouped, {
+ glmEnabled: true,
+ pinnedModel: "",
+ }),
+ ).toEqual([{ value: "claude-sonnet-5", label: "Claude Sonnet 5" }]);
+ });
+
+ it("drops plan-restricted models", () => {
+ const withRestricted = modelConfigOption([
+ { value: "claude-sonnet-5", name: "Claude Sonnet 5" },
+ {
+ value: "claude-fable-5",
+ name: "Claude Fable 5",
+ _meta: restrictedModelMeta(),
+ },
+ ]);
+ expect(
+ loopModelOptions("claude", withRestricted, {
+ glmEnabled: true,
+ pinnedModel: "",
+ }),
+ ).toEqual([{ value: "claude-sonnet-5", label: "Claude Sonnet 5" }]);
+ });
+
+ it.each([
+ {
+ name: "hides GLM when the flag is off",
+ glmEnabled: false,
+ pinnedModel: "",
+ expectedValues: ["claude-sonnet-5"],
+ },
+ {
+ name: "shows GLM when the flag is on",
+ glmEnabled: true,
+ pinnedModel: "",
+ expectedValues: ["claude-sonnet-5", "@cf/zai-org/glm-5.2"],
+ },
+ {
+ name: "keeps a pinned GLM model visible with the flag off",
+ glmEnabled: false,
+ pinnedModel: "@cf/zai-org/glm-5.2",
+ expectedValues: ["claude-sonnet-5", "@cf/zai-org/glm-5.2"],
+ },
+ ])("$name", ({ glmEnabled, pinnedModel, expectedValues }) => {
+ const values = loopModelOptions("claude", claudeOptions, {
+ glmEnabled,
+ pinnedModel,
+ }).map((option) => option.value);
+ expect(values).toEqual(expectedValues);
+ });
+
+ it("keeps a pinned model that the catalog no longer serves", () => {
+ expect(
+ loopModelOptions("claude", claudeOptions, {
+ glmEnabled: true,
+ pinnedModel: "claude-opus-4-6",
+ }),
+ ).toContainEqual({ value: "claude-opus-4-6", label: "claude-opus-4-6" });
+ });
+
+ it.each<{
+ name: string;
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum;
+ glmEnabled: boolean;
+ expectedValues: string[];
+ }>([
+ {
+ name: "falls back to the known claude models when the config has no model select",
+ adapter: "claude",
+ glmEnabled: true,
+ expectedValues: [
+ "claude-sonnet-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "@cf/zai-org/glm-5.2",
+ ],
+ },
+ {
+ name: "falls back to the known codex models when the config has no model select",
+ adapter: "codex",
+ glmEnabled: true,
+ expectedValues: [
+ "gpt-5",
+ "gpt-5.5",
+ "gpt-5.6-sol",
+ "gpt-5.6-terra",
+ "gpt-5.6-luna",
+ ],
+ },
+ {
+ name: "applies the GLM flag to the fallback list",
+ adapter: "claude",
+ glmEnabled: false,
+ expectedValues: [
+ "claude-sonnet-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ ],
+ },
+ ])("$name", ({ adapter, glmEnabled, expectedValues }) => {
+ const values = loopModelOptions(adapter, [], {
+ glmEnabled,
+ pinnedModel: "",
+ }).map((option) => option.value);
+ expect(values).toEqual(expectedValues);
+ });
+});
+
+describe("LOOP_DEFAULT_MODELS", () => {
+ it("mirrors the backend loop defaults", () => {
+ expect(LOOP_DEFAULT_MODELS.claude.id).toBe("claude-sonnet-5");
+ expect(LOOP_DEFAULT_MODELS.codex.id).toBe("gpt-5");
+ });
+});
+
+describe("loopReasoningEffortOptions", () => {
+ it.each<{
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum;
+ model: string;
+ expectedValues: LoopSchemas.LoopReasoningEffortEnum[];
+ }>([
+ {
+ adapter: "claude",
+ model: "",
+ expectedValues: ["low", "medium", "high", "xhigh", "max"],
+ },
+ {
+ adapter: "claude",
+ model: "claude-sonnet-5",
+ expectedValues: ["low", "medium", "high", "xhigh", "max"],
+ },
+ {
+ adapter: "claude",
+ model: "@cf/zai-org/glm-5.2",
+ expectedValues: ["high", "max"],
+ },
+ { adapter: "claude", model: "unknown-model", expectedValues: [] },
+ { adapter: "codex", model: "", expectedValues: ["low", "medium", "high"] },
+ {
+ adapter: "codex",
+ model: "gpt-5.5",
+ expectedValues: ["low", "medium", "high", "xhigh"],
+ },
+ {
+ adapter: "codex",
+ model: "gpt-5.6-sol",
+ expectedValues: ["low", "medium", "high", "xhigh", "max"],
+ },
+ ])(
+ "$adapter with model '$model' offers $expectedValues",
+ ({ adapter, model, expectedValues }) => {
+ expect(
+ loopReasoningEffortOptions(adapter, model).map(
+ (option) => option.value,
+ ),
+ ).toEqual(expectedValues);
+ },
+ );
+});
+
+describe("clampLoopReasoningEffort", () => {
+ it.each<{
+ name: string;
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum;
+ model: string;
+ effort: LoopSchemas.LoopReasoningEffortEnum | null;
+ expected: LoopSchemas.LoopReasoningEffortEnum | null;
+ }>([
+ {
+ name: "keeps a supported effort",
+ adapter: "claude",
+ model: "claude-sonnet-5",
+ effort: "low",
+ expected: "low",
+ },
+ {
+ name: "clears an effort the model doesn't support",
+ adapter: "claude",
+ model: "@cf/zai-org/glm-5.2",
+ effort: "low",
+ expected: null,
+ },
+ {
+ name: "clears an effort the default model doesn't support",
+ adapter: "codex",
+ model: "",
+ effort: "xhigh",
+ expected: null,
+ },
+ {
+ name: "keeps auto as auto",
+ adapter: "codex",
+ model: "",
+ effort: null,
+ expected: null,
+ },
+ {
+ name: "clears max on a codex model without it",
+ adapter: "codex",
+ model: "gpt-5",
+ effort: "max",
+ expected: null,
+ },
+ ])("$name", ({ adapter, model, effort, expected }) => {
+ expect(clampLoopReasoningEffort(adapter, model, effort)).toBe(expected);
+ });
+});
diff --git a/packages/ui/src/features/loops/loopModels.ts b/packages/ui/src/features/loops/loopModels.ts
new file mode 100644
index 0000000000..0c16f6c978
--- /dev/null
+++ b/packages/ui/src/features/loops/loopModels.ts
@@ -0,0 +1,122 @@
+import type { SessionConfigOption } from "@agentclientprotocol/sdk";
+import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort";
+import type { LoopSchemas } from "@posthog/api-client/loops";
+import { flattenSelectOptions, isRestrictedModelOption } from "@posthog/shared";
+
+export interface LoopModelOption {
+ value: string;
+ label: string;
+}
+
+// Mirrors DEFAULT_MODEL_BY_RUNTIME_ADAPTER in posthog's
+// products/tasks/backend/temporal/process_task/utils.py: the model a loop
+// fires with when none is pinned, and the one the serializer validates a
+// blank-model loop's reasoning effort against.
+export const LOOP_DEFAULT_MODELS: Record<
+ LoopSchemas.LoopRuntimeAdapterEnum,
+ { id: string; label: string }
+> = {
+ claude: { id: "claude-sonnet-5", label: "Claude Sonnet 5" },
+ codex: { id: "gpt-5", label: "GPT-5" },
+};
+
+function isGlmModelId(modelId: string): boolean {
+ return modelId.toLowerCase().includes("glm");
+}
+
+// Served-catalog stand-in while the preview config loads or when the request
+// fails, so the picker never collapses to "Default" alone. Matches the
+// backend's per-adapter catalogs in process_task/utils.py minus client-blocked
+// models; the served catalog stays authoritative once it arrives.
+const FALLBACK_MODEL_OPTIONS: Record<
+ LoopSchemas.LoopRuntimeAdapterEnum,
+ LoopModelOption[]
+> = {
+ claude: [
+ { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
+ { value: "claude-opus-4-7", label: "Claude Opus 4.7" },
+ { value: "claude-opus-4-8", label: "Claude Opus 4.8" },
+ { value: "claude-sonnet-5", label: "Claude Sonnet 5" },
+ { value: "claude-fable-5", label: "Claude Fable 5" },
+ { value: "@cf/zai-org/glm-5.2", label: "GLM-5.2" },
+ ],
+ codex: [
+ { value: "gpt-5", label: "GPT-5" },
+ { value: "gpt-5.5", label: "GPT-5.5" },
+ { value: "gpt-5.6-sol", label: "GPT-5.6 Sol" },
+ { value: "gpt-5.6-terra", label: "GPT-5.6 Terra" },
+ { value: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
+ ],
+};
+
+/** The model a loop's runs use, for display: the pinned id, or the adapter's
+ * loop default (which differs from the live-session default the
+ * ReportModelResolver serves, so it can't be resolved from there). */
+export function formatLoopModel(
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum,
+ configuredModel: string,
+): string {
+ return configuredModel || `${LOOP_DEFAULT_MODELS[adapter].label} (default)`;
+}
+
+/**
+ * Pinnable models for a loop, derived from the same per-adapter preview
+ * config that feeds the main create-task picker, so the loops picker offers
+ * exactly the ids the loops API accepts. Restricted (plan-locked) models are
+ * dropped, GLM is flag-gated like the main picker, and the currently pinned
+ * model always stays selectable so an existing loop's model never drops out.
+ */
+export function loopModelOptions(
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum,
+ configOptions: SessionConfigOption[],
+ { glmEnabled, pinnedModel }: { glmEnabled: boolean; pinnedModel: string },
+): LoopModelOption[] {
+ const modelOption = configOptions.find(
+ (option) => option.category === "model" || option.id === "model",
+ );
+ const served =
+ modelOption?.type === "select"
+ ? flattenSelectOptions(modelOption.options)
+ .filter((option) => !isRestrictedModelOption(option._meta))
+ .map((option) => ({
+ value: option.value,
+ label: option.name ?? option.value,
+ }))
+ : [];
+ const options = (
+ served.length > 0 ? served : FALLBACK_MODEL_OPTIONS[adapter]
+ ).filter(
+ (option) =>
+ glmEnabled || option.value === pinnedModel || !isGlmModelId(option.value),
+ );
+ if (pinnedModel && !options.some((option) => option.value === pinnedModel)) {
+ options.push({ value: pinnedModel, label: pinnedModel });
+ }
+ return options;
+}
+
+/** Efforts the loops API accepts for the model that would actually run:
+ * the pinned model, or the adapter's default when the loop leaves it unset. */
+export function loopReasoningEffortOptions(
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum,
+ model: string,
+): { value: LoopSchemas.LoopReasoningEffortEnum; label: string }[] {
+ const effectiveModel = model || LOOP_DEFAULT_MODELS[adapter].id;
+ const options = getReasoningEffortOptions(adapter, effectiveModel) ?? [];
+ return options.map((option) => ({ value: option.value, label: option.name }));
+}
+
+/** The effort unchanged when the effective model supports it, else null
+ * (auto), so an adapter or model switch never leaves an invalid combo. */
+export function clampLoopReasoningEffort(
+ adapter: LoopSchemas.LoopRuntimeAdapterEnum,
+ model: string,
+ effort: LoopSchemas.LoopReasoningEffortEnum | null,
+): LoopSchemas.LoopReasoningEffortEnum | null {
+ if (effort === null) return null;
+ return loopReasoningEffortOptions(adapter, model).some(
+ (option) => option.value === effort,
+ )
+ ? effort
+ : null;
+}