From 74f896ebc6f566d133145ef075afac917985862f Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 24 Jun 2026 15:52:47 +0200 Subject: [PATCH 1/9] feat(agent-applications): model policy config UI + session model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface and edit `spec.model_policy` (the auto/manual model picker) in the agent config pane, and show which model a session used. - agent-platform-types: add AgentModelPolicy (auto level / manual list) + ModelCatalog types; make spec.model optional (legacy). - Model section: interactive policy editor (mode/level/reasoning dropdowns with descriptions + icons), an "auto level resolves to" preview with live pricing, and a searchable model browser (all served models + cost profiles). Manual mode supports add-from-browser and drag-to-reorder. Editing is local-state only — no save wired yet. - useModelCatalog: catalog stand-in (snapshot of the gateway /v1/models), the single swap point for a real model-info endpoint. - Session detail: add a "Model" KPI showing the model(s) that answered. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/agent-platform-types.ts | 55 +- .../components/AgentConfigurationPane.tsx | 8 +- .../components/AgentModelConfig.tsx | 620 ++++++++++++++++++ .../components/AgentSessionDetailBody.tsx | 25 +- .../hooks/useModelCatalog.ts | 446 +++++++++++++ 5 files changed, 1147 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx create mode 100644 packages/ui/src/features/agent-applications/hooks/useModelCatalog.ts diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts index de61bdb7f..f961fd7ba 100644 --- a/packages/shared/src/agent-platform-types.ts +++ b/packages/shared/src/agent-platform-types.ts @@ -57,12 +57,63 @@ export interface AgentApplication { ingress_base_url: string | null; } +export type AgentReasoningEffort = + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export type AgentModelLevel = "low" | "medium" | "high"; + +/** One model in a manual policy: a canonical model id (e.g. + * `anthropic/claude-sonnet-4-6`) plus an optional per-model reasoning override. */ +export interface AgentModelEntry { + model: string; + reasoning?: AgentReasoningEffort; +} + +/** + * How a revision picks its model. `auto` resolves a maintained, priority-ordered, + * cross-provider list from `level` at runtime; `manual` pins an author-ordered + * fallback list (primary first). Mirrors `spec.model_policy` in the backend. + */ +export type AgentModelPolicy = + | { mode: "auto"; level?: AgentModelLevel; reasoning?: AgentReasoningEffort } + | { mode: "manual"; models: AgentModelEntry[] }; + +/** + * A served model + its cost profile, as the model browser shows it. Mirrors the + * ai-gateway catalog (`@posthog/agent-applications-models`). Pricing is USD per + * million tokens. + */ +export interface ModelCatalogEntry { + /** Canonical id, e.g. `anthropic/claude-sonnet-4.6`. */ + model: string; + provider: string; + context_window: number; + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; +} + +/** The full served catalog plus the curated `auto` level → model mapping. */ +export interface ModelCatalog { + models: ModelCatalogEntry[]; + /** Canonical ids each auto level resolves to, in priority order. */ + levels: Record; +} + /** * The agent spec carried on a revision. Known top-level fields are surfaced and * the rest passes through pending fully-typed elaboration. */ export interface AgentSpec { - model: string; + /** Model selection. `model` is the legacy single-string form; current specs + * carry `model_policy`. One or the other is present. */ + model_policy?: AgentModelPolicy; + model?: string; triggers?: unknown[]; tools?: unknown[]; mcps?: unknown[]; @@ -75,7 +126,7 @@ export interface AgentSpec { max_wall_seconds?: number; }; entrypoint?: string; - reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoning?: AgentReasoningEffort; [key: string]: unknown; } diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 7c4206d6d..4df298119 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -37,6 +37,7 @@ import { useAgentRevisionBundle } from "../hooks/useAgentRevisionBundle"; import { useAgentRevisions } from "../hooks/useAgentRevisions"; import { triggerRequiredSecretsFor } from "../utils/triggerSecrets"; import { AgentDetailEmptyState, AgentDetailLayout } from "./AgentDetailLayout"; +import { AgentModelConfig } from "./AgentModelConfig"; import { AgentRevisionBar } from "./AgentRevisionBar"; import { CopyButton } from "./CopyButton"; import { CronFireButton } from "./CronFireButton"; @@ -460,7 +461,7 @@ export function AgentConfigurationPane({ const SECTION_INFO: Record = { "cfg:model": - "The model every request goes to. `reasoning` sets the extended-thinking budget; limits cap a run's turns, tool calls and wall time.", + "How the agent picks its model. `auto` resolves a level (low/medium/high) to a maintained cross-provider list at runtime; `manual` pins an explicit priority list. `reasoning` sets the extended-thinking budget.", "cfg:instructions": "The agent's entrypoint prompt (agent.md) — the always-on system instructions.", "cfg:triggers": "What can start a session — chat, webhook, mcp, slack, cron.", @@ -695,9 +696,8 @@ function byPath(files: BundleFile[], path: string): BundleFile | undefined { function ModelBody({ spec }: { spec: AgentSpec }) { return ( - - - + + {spec.entrypoint ? ( ) : null} diff --git a/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx b/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx new file mode 100644 index 000000000..578312834 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx @@ -0,0 +1,620 @@ +import { PointerSensor } from "@dnd-kit/dom"; +import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; +import { + BrainIcon, + CaretDownIcon, + GaugeIcon, + MagnifyingGlassIcon, + SlidersHorizontalIcon, +} from "@phosphor-icons/react"; +import type { + AgentModelEntry, + AgentModelLevel, + AgentModelPolicy, + AgentReasoningEffort, + AgentSpec, + ModelCatalogEntry, +} from "@posthog/shared/agent-platform-types"; +import { Badge } from "@posthog/ui/primitives/Badge"; +import { Flex, Popover, Text } from "@radix-ui/themes"; +import { type ReactNode, type RefCallback, useMemo, useState } from "react"; +import { useModelCatalog } from "../hooks/useModelCatalog"; + +/** + * The rich model section: an interactive policy editor (mode + level + + * reasoning), a preview of what an `auto` level resolves to, and a searchable + * browser of every served model with its cost profile. + * + * Editing is local-state only — there's no save wired yet; the point is to see + * the UX. The catalog comes from `useModelCatalog` (a stand-in for the + * model-info endpoint). + */ +export function AgentModelConfig({ spec }: { spec: AgentSpec }) { + const { catalog } = useModelCatalog(); + const initial = spec.model_policy; + + const [mode, setMode] = useState<"auto" | "manual">(initial?.mode ?? "auto"); + const [level, setLevel] = useState( + initial?.mode === "auto" ? (initial.level ?? "medium") : "medium", + ); + const [reasoning, setReasoning] = useState( + initial?.mode === "auto" ? initial.reasoning : spec.reasoning, + ); + const [manual, setManual] = useState( + initial?.mode === "manual" ? initial.models : [], + ); + + const policy: AgentModelPolicy = + mode === "auto" + ? { mode: "auto", level, ...(reasoning ? { reasoning } : {}) } + : { mode: "manual", models: manual }; + + const dirty = + JSON.stringify(policy) !== + JSON.stringify(initial ?? { mode: "auto", level: "medium" }); + + const byId = useMemo( + () => new Map(catalog.models.map((m) => [m.model, m])), + [catalog.models], + ); + + return ( + + + } + value={level} + onChange={(v) => setLevel(v as AgentModelLevel)} + options={LEVEL_OPTIONS} + /> + setQ(e.currentTarget.value)} + placeholder="Search models…" + aria-label="Search models" + className="h-8 w-full rounded-(--radius-2) border border-border bg-(--color-panel-solid) pr-2 pl-8 text-[12.5px]" + /> + + setSort(v as SortKey)} + options={[ + { value: "name", label: "Name" }, + { value: "cheapest", label: "Cheapest" }, + { value: "priciest", label: "Priciest" }, + ]} + /> + + + + {rows.map((m) => { + const added = selected.includes(m.model); + return ( + + + + {m.model} + + {canAdd ? ( + onAdd(m.model)} + disabled={added} + /> + ) : null} + + + {m.provider} + + + + {m.cacheRead != null ? ( + + ) : null} + + + ); + })} + {rows.length === 0 ? ( + + No models match “{q}”. + + ) : null} + + + ); +} + +// --- small presentational helpers --- + +function CostInline({ m }: { m: ModelCatalogEntry }) { + return ( + + in {fmtUsd(m.input)} · out {fmtUsd(m.output)} + /Mtok + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + + {label} {value} + + ); +} + +function Select({ + label, + icon, + value, + onChange, + options, +}: { + label: string; + icon?: ReactNode; + value: string; + onChange: (v: string) => void; + options: readonly { value: string; title: string; description: string }[]; +}) { + const [open, setOpen] = useState(false); + const current = options.find((o) => o.value === value) ?? options[0]; + return ( + + + + {icon} + {label} + + + + + + +
    + {options.map((o) => ( +
  • + +
  • + ))} +
+
+
+
+ {current?.description ? ( + + {current.description} + + ) : null} +
+ ); +} + +function Seg({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; +}) { + return ( + + {options.map((o) => ( + + ))} + + ); +} + +function MiniBtn({ + label, + title, + onClick, + disabled, +}: { + label: string; + title: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +function Subhead({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Muted({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +function fmtUsd(n: number): string { + return `$${n}`; +} + +function fmtCtx(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + return `${Math.round(n / 1000)}K`; +} diff --git a/packages/ui/src/features/agent-applications/components/AgentSessionDetailBody.tsx b/packages/ui/src/features/agent-applications/components/AgentSessionDetailBody.tsx index 2de536756..99fc3193d 100644 --- a/packages/ui/src/features/agent-applications/components/AgentSessionDetailBody.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentSessionDetailBody.tsx @@ -19,6 +19,9 @@ interface SessionMetrics { messages: number; toolCalls: number; errors: number; + /** Distinct models that answered, in first-seen order. Usually one; more than + * one means the turn(s) fell back across the policy list. */ + models: string[]; } function computeMetrics( @@ -26,12 +29,14 @@ function computeMetrics( ): SessionMetrics { let toolCalls = 0; let errors = 0; + const models: string[] = []; for (const msg of session.conversation) { if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "toolCall") toolCalls += 1; } if (msg.errorMessage) errors += 1; + if (msg.model && !models.includes(msg.model)) models.push(msg.model); } else if (msg.role === "toolResult" && msg.isError) { errors += 1; } @@ -40,6 +45,7 @@ function computeMetrics( messages: session.conversation_total_turns ?? session.conversation.length, toolCalls, errors, + models, }; } @@ -134,6 +140,18 @@ export function AgentSessionDetailBody({ label="Tool calls" value={String(metrics.toolCalls)} /> + {metrics.models.length > 0 ? ( + + ) : null} @@ -230,9 +252,10 @@ function MetricItem({ {label} {value} diff --git a/packages/ui/src/features/agent-applications/hooks/useModelCatalog.ts b/packages/ui/src/features/agent-applications/hooks/useModelCatalog.ts new file mode 100644 index 000000000..f6d74fd59 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useModelCatalog.ts @@ -0,0 +1,446 @@ +import type { + ModelCatalog, + ModelCatalogEntry, +} from "@posthog/shared/agent-platform-types"; + +/** + * The served-model catalog for the model browser + auto-level preview. + * + * STAND-IN: this is a snapshot of the live ai-gateway `/v1/models` catalog. When + * the catalog endpoint lands (e.g. `GET …/agent_applications/models`), replace + * the body with a real query — `ModelCatalog` already matches the wire shape, so + * nothing downstream changes. + */ +const MODELS: ModelCatalogEntry[] = [ + { + model: "anthropic/claude-haiku-4.5", + provider: "anthropic", + context_window: 200000, + input: 1.0, + output: 5.0, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + { + model: "anthropic/claude-opus-4.1", + provider: "anthropic", + context_window: 200000, + input: 15.0, + output: 75.0, + cacheRead: 1.5, + cacheWrite: 18.75, + }, + { + model: "anthropic/claude-opus-4.5", + provider: "anthropic", + context_window: 200000, + input: 5.0, + output: 25.0, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + { + model: "anthropic/claude-opus-4.6", + provider: "anthropic", + context_window: 1000000, + input: 5.0, + output: 25.0, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + { + model: "anthropic/claude-opus-4.7", + provider: "anthropic", + context_window: 1000000, + input: 5.0, + output: 25.0, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + { + model: "anthropic/claude-opus-4.8", + provider: "anthropic", + context_window: 1000000, + input: 5.0, + output: 25.0, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + { + model: "anthropic/claude-sonnet-4.5", + provider: "anthropic", + context_window: 1000000, + input: 3.0, + output: 15.0, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + { + model: "anthropic/claude-sonnet-4.6", + provider: "anthropic", + context_window: 1000000, + input: 3.0, + output: 15.0, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + { + model: "openai/gpt-3.5-turbo", + provider: "openai", + context_window: 16385, + input: 0.5, + output: 1.5, + }, + { + model: "openai/gpt-3.5-turbo-16k", + provider: "openai", + context_window: 16385, + input: 3.0, + output: 4.0, + }, + { + model: "openai/gpt-3.5-turbo-instruct", + provider: "openai", + context_window: 4095, + input: 1.5, + output: 2.0, + }, + { + model: "openai/gpt-4", + provider: "openai", + context_window: 8191, + input: 30.0, + output: 60.0, + }, + { + model: "openai/gpt-4-turbo", + provider: "openai", + context_window: 128000, + input: 10.0, + output: 30.0, + }, + { + model: "openai/gpt-4.1", + provider: "openai", + context_window: 1047576, + input: 2.0, + output: 8.0, + cacheRead: 0.5, + }, + { + model: "openai/gpt-4.1-mini", + provider: "openai", + context_window: 1047576, + input: 0.4, + output: 1.6, + cacheRead: 0.1, + }, + { + model: "openai/gpt-4.1-nano", + provider: "openai", + context_window: 1047576, + input: 0.1, + output: 0.4, + cacheRead: 0.025, + }, + { + model: "openai/gpt-4o", + provider: "openai", + context_window: 128000, + input: 2.5, + output: 10.0, + cacheRead: 1.25, + }, + { + model: "openai/gpt-4o-2024-05-13", + provider: "openai", + context_window: 128000, + input: 5.0, + output: 15.0, + }, + { + model: "openai/gpt-4o-2024-08-06", + provider: "openai", + context_window: 128000, + input: 2.5, + output: 10.0, + cacheRead: 1.25, + }, + { + model: "openai/gpt-4o-2024-11-20", + provider: "openai", + context_window: 128000, + input: 2.5, + output: 10.0, + cacheRead: 1.25, + }, + { + model: "openai/gpt-4o-mini", + provider: "openai", + context_window: 128000, + input: 0.15, + output: 0.6, + cacheRead: 0.075, + }, + { + model: "openai/gpt-4o-mini-2024-07-18", + provider: "openai", + context_window: 128000, + input: 0.15, + output: 0.6, + cacheRead: 0.075, + }, + { + model: "openai/gpt-4o-mini-search-preview", + provider: "openai", + context_window: 128000, + input: 0.15, + output: 0.6, + }, + { + model: "openai/gpt-4o-search-preview", + provider: "openai", + context_window: 128000, + input: 2.5, + output: 10.0, + }, + { + model: "openai/gpt-5", + provider: "openai", + context_window: 400000, + input: 1.25, + output: 10.0, + cacheRead: 0.125, + }, + { + model: "openai/gpt-5-codex", + provider: "openai", + context_window: 400000, + input: 1.25, + output: 10.0, + cacheRead: 0.125, + }, + { + model: "openai/gpt-5-mini", + provider: "openai", + context_window: 400000, + input: 0.25, + output: 2.0, + cacheRead: 0.025, + }, + { + model: "openai/gpt-5-nano", + provider: "openai", + context_window: 400000, + input: 0.05, + output: 0.4, + cacheRead: 0.005, + }, + { + model: "openai/gpt-5-pro", + provider: "openai", + context_window: 400000, + input: 15.0, + output: 120.0, + }, + { + model: "openai/gpt-5.1", + provider: "openai", + context_window: 400000, + input: 1.25, + output: 10.0, + cacheRead: 0.125, + }, + { + model: "openai/gpt-5.1-codex", + provider: "openai", + context_window: 400000, + input: 1.25, + output: 10.0, + cacheRead: 0.125, + }, + { + model: "openai/gpt-5.1-codex-max", + provider: "openai", + context_window: 400000, + input: 1.25, + output: 10.0, + cacheRead: 0.125, + }, + { + model: "openai/gpt-5.1-codex-mini", + provider: "openai", + context_window: 400000, + input: 0.25, + output: 2.0, + cacheRead: 0.025, + }, + { + model: "openai/gpt-5.2", + provider: "openai", + context_window: 400000, + input: 1.75, + output: 14.0, + cacheRead: 0.175, + }, + { + model: "openai/gpt-5.2-codex", + provider: "openai", + context_window: 400000, + input: 1.75, + output: 14.0, + cacheRead: 0.175, + }, + { + model: "openai/gpt-5.2-pro", + provider: "openai", + context_window: 400000, + input: 21.0, + output: 168.0, + }, + { + model: "openai/gpt-5.3-codex", + provider: "openai", + context_window: 400000, + input: 1.75, + output: 14.0, + cacheRead: 0.175, + }, + { + model: "openai/gpt-5.4", + provider: "openai", + context_window: 1050000, + input: 2.5, + output: 15.0, + cacheRead: 0.25, + }, + { + model: "openai/gpt-5.4-mini", + provider: "openai", + context_window: 400000, + input: 0.75, + output: 4.5, + cacheRead: 0.075, + }, + { + model: "openai/gpt-5.4-nano", + provider: "openai", + context_window: 400000, + input: 0.2, + output: 1.25, + cacheRead: 0.02, + }, + { + model: "openai/gpt-5.4-pro", + provider: "openai", + context_window: 1050000, + input: 30.0, + output: 180.0, + }, + { + model: "openai/gpt-5.5", + provider: "openai", + context_window: 1050000, + input: 5.0, + output: 30.0, + cacheRead: 0.5, + }, + { + model: "openai/gpt-5.5-pro", + provider: "openai", + context_window: 1050000, + input: 30.0, + output: 180.0, + }, + { + model: "openai/gpt-audio", + provider: "openai", + context_window: 128000, + input: 2.5, + output: 10.0, + }, + { + model: "openai/gpt-audio-mini", + provider: "openai", + context_window: 128000, + input: 0.6, + output: 2.4, + }, + { + model: "openai/o1", + provider: "openai", + context_window: 200000, + input: 15.0, + output: 60.0, + cacheRead: 7.5, + }, + { + model: "openai/o1-pro", + provider: "openai", + context_window: 200000, + input: 150.0, + output: 600.0, + }, + { + model: "openai/o3", + provider: "openai", + context_window: 200000, + input: 2.0, + output: 8.0, + cacheRead: 0.5, + }, + { + model: "openai/o3-deep-research", + provider: "openai", + context_window: 200000, + input: 10.0, + output: 40.0, + cacheRead: 2.5, + }, + { + model: "openai/o3-mini", + provider: "openai", + context_window: 200000, + input: 1.1, + output: 4.4, + cacheRead: 0.55, + }, + { + model: "openai/o3-pro", + provider: "openai", + context_window: 200000, + input: 20.0, + output: 80.0, + }, + { + model: "openai/o4-mini", + provider: "openai", + context_window: 200000, + input: 1.1, + output: 4.4, + cacheRead: 0.275, + }, + { + model: "openai/o4-mini-deep-research", + provider: "openai", + context_window: 200000, + input: 2.0, + output: 8.0, + cacheRead: 0.5, + }, +]; + +// Curated auto levels → canonical ids in priority order. Mirrors the backend's +// MODEL_POLICY_LEVELS (resolved to the catalog's canonical ids). +const LEVELS: ModelCatalog["levels"] = { + low: ["anthropic/claude-haiku-4.5", "openai/gpt-5-mini"], + medium: ["anthropic/claude-sonnet-4.6", "openai/gpt-5"], + high: ["anthropic/claude-opus-4.7", "openai/gpt-5-pro"], +}; + +export function useModelCatalog(): { + catalog: ModelCatalog; + isLoading: boolean; +} { + return { catalog: { models: MODELS, levels: LEVELS }, isLoading: false }; +} From 49838643b245ca038c080ddc33ab118caae91c86 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 24 Jun 2026 16:40:46 +0200 Subject: [PATCH 2/9] feat(agent-applications): save model policy + live catalog endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the model picker functional: save edits and read the real catalog. - api-client: updateAgentRevisionSpec (draft-only PATCH) + getAgentModelCatalog. - useApplyAgentSpec: "create draft and apply changes" — PATCH a draft in place, else clone the revision to a fresh draft, apply, and select it. Freeze/promote stay on the existing lifecycle buttons. - AgentModelConfig: Save / Reset bar (auto-branches a draft on non-draft revisions); threaded through the config pane. - useModelCatalog: read GET …/agent_applications/models/ (drops the stand-in snapshot; small levels fallback while loading). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/posthog-client.ts | 32 ++ .../components/AgentConfigurationPane.tsx | 23 +- .../components/AgentModelConfig.tsx | 82 +++- .../hooks/useApplyAgentSpec.ts | 57 +++ .../hooks/useModelCatalog.ts | 462 +----------------- 5 files changed, 207 insertions(+), 449 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 3573de35b..5c4b8d89b 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -32,9 +32,11 @@ import type { AgentSessionLogsParams, AgentSessionsListParams, AgentSlackManifest, + AgentSpec, AgentUsersListResponse, BundleFile, DecideApprovalRequest, + ModelCatalog, } from "@posthog/shared/agent-platform-types"; import type { ActionabilityJudgmentArtefact, @@ -4661,6 +4663,36 @@ export class PostHogAPIClient { return (await response.json()) as AgentRevision; } + /** The served-model catalog + curated auto-level → model map (project-agnostic; + * proxies the AI gateway catalog). Powers the config-pane model browser. */ + async getAgentModelCatalog(): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}models/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + return (await response.json()) as ModelCatalog; + } + + /** Update a draft revision's spec (PATCH). Draft-only on the server — a + * ready/live spec is frozen. Replaces `spec` wholesale, so callers send the + * full updated spec. Returns the updated revision. */ + async updateAgentRevisionSpec( + idOrSlug: string, + revisionId: string, + spec: AgentSpec, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path, + overrides: { body: JSON.stringify({ spec }) }, + }); + return (await response.json()) as AgentRevision; + } + /** Run a revision lifecycle transition: freeze (draft→ready), promote * (ready→live, demoting the old live), or archive. Returns the updated revision. */ async transitionAgentRevision( diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 4df298119..ba0031e55 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -21,6 +21,7 @@ import { WrenchIcon, } from "@phosphor-icons/react"; import type { + AgentRevisionState, AgentSpec, BundleFile, } from "@posthog/shared/agent-platform-types"; @@ -63,9 +64,15 @@ const USAGE_HOST = "https://"; interface Ctx { idOrSlug: string; revisionId: string; + /** Application UUID — needed to branch a new draft on save. */ + applicationId?: string; + /** State of the viewed revision — drives draft-only edit vs auto-clone. */ + revisionState?: AgentRevisionState; ingressBaseUrl?: string; setKeys: string[]; onSelect: (node: string) => void; + /** Select a revision in the picker (used to jump to a freshly branched draft). */ + onSelectRevision?: (revisionId: string) => void; onOpenSession?: (sessionId: string) => void; } @@ -399,9 +406,12 @@ export function AgentConfigurationPane({ ? { idOrSlug, revisionId, + applicationId: application?.id, + revisionState: revision?.state, ingressBaseUrl: application?.ingress_base_url ?? undefined, setKeys, onSelect: onSelectNode, + onSelectRevision, onOpenSession, } : null; @@ -615,7 +625,7 @@ function DetailBody({ }) { switch (section) { case "model": - return ; + return ; case "instructions": return ( f.path === path); } -function ModelBody({ spec }: { spec: AgentSpec }) { +function ModelBody({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { return ( - + {spec.entrypoint ? ( ) : null} diff --git a/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx b/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx index 578312834..87595ee0c 100644 --- a/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentModelConfig.tsx @@ -13,12 +13,15 @@ import type { AgentModelLevel, AgentModelPolicy, AgentReasoningEffort, + AgentRevisionState, AgentSpec, ModelCatalogEntry, } from "@posthog/shared/agent-platform-types"; import { Badge } from "@posthog/ui/primitives/Badge"; +import { Button } from "@posthog/ui/primitives/Button"; import { Flex, Popover, Text } from "@radix-ui/themes"; import { type ReactNode, type RefCallback, useMemo, useState } from "react"; +import { useApplyAgentSpec } from "../hooks/useApplyAgentSpec"; import { useModelCatalog } from "../hooks/useModelCatalog"; /** @@ -30,8 +33,23 @@ import { useModelCatalog } from "../hooks/useModelCatalog"; * the UX. The catalog comes from `useModelCatalog` (a stand-in for the * model-info endpoint). */ -export function AgentModelConfig({ spec }: { spec: AgentSpec }) { +export function AgentModelConfig({ + spec, + idOrSlug, + applicationId, + revisionId, + revisionState, + onSelectRevision, +}: { + spec: AgentSpec; + idOrSlug: string; + applicationId?: string; + revisionId: string; + revisionState?: AgentRevisionState; + onSelectRevision?: (revisionId: string) => void; +}) { const { catalog } = useModelCatalog(); + const apply = useApplyAgentSpec(idOrSlug, applicationId); const initial = spec.model_policy; const [mode, setMode] = useState<"auto" | "manual">(initial?.mode ?? "auto"); @@ -53,14 +71,67 @@ export function AgentModelConfig({ spec }: { spec: AgentSpec }) { const dirty = JSON.stringify(policy) !== JSON.stringify(initial ?? { mode: "auto", level: "medium" }); + const willBranch = revisionState !== "draft"; const byId = useMemo( () => new Map(catalog.models.map((m) => [m.model, m])), [catalog.models], ); + function reset() { + setMode(initial?.mode ?? "auto"); + setLevel(initial?.mode === "auto" ? (initial.level ?? "medium") : "medium"); + setReasoning(initial?.mode === "auto" ? initial.reasoning : spec.reasoning); + setManual(initial?.mode === "manual" ? initial.models : []); + } + + function save() { + apply.mutate( + { + revision: { id: revisionId, state: revisionState ?? "draft" }, + spec: { ...spec, model_policy: policy }, + }, + { onSuccess: (rev) => onSelectRevision?.(rev.id) }, + ); + } + return ( + {dirty ? ( + + + + {willBranch + ? "Unsaved changes — saving branches a new draft." + : "Unsaved changes."} + + + + + + + {apply.isError ? ( + + {apply.error?.message ?? "Save failed"} + + ) : null} + + ) : null} + } + value={optimizeFor} + onChange={(v) => setOptimizeFor(v as AgentModelOptimizeFor)} + options={OPTIMIZE_OPTIONS} + /> + {mode === "auto" ? ( <>