Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,4 +836,89 @@ describe("PostHogAPIClient", () => {
).rejects.toThrow("Unexpected response");
});
});

describe("agent model policy + catalog", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };
return client;
}

it("createAgentDraftRevisionFrom unwraps the { revision } envelope", async () => {
// Regression: new_draft returns `{ revision, source_revision_id }`, not a
// flat revision — returning the wrapper left `.id` undefined and broke the
// follow-up PATCH (404 on /revisions/undefined/).
const fetch = vi.fn().mockResolvedValue({
json: async () => ({
revision: { id: "draft-1", state: "draft" },
source_revision_id: "rev-0",
}),
});
const client = makeClient(fetch);

const rev = await client.createAgentDraftRevisionFrom("app-1", "rev-0");

expect(rev.id).toBe("draft-1");
expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
method: "post",
path: "/api/projects/123/agent_applications/app-1/revisions/new_draft/",
overrides: {
body: JSON.stringify({
application_id: "app-1",
source_revision_id: "rev-0",
}),
},
}),
);
});

it("updateAgentRevisionSpec PATCHes the revision with the full spec", async () => {
const fetch = vi.fn().mockResolvedValue({
json: async () => ({ id: "draft-1", state: "draft" }),
});
const client = makeClient(fetch);
const spec = { models: { mode: "auto", level: "high" } };

await client.updateAgentRevisionSpec(
"agent-slug",
"draft-1",
spec as never,
);

expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
method: "patch",
path: "/api/projects/123/agent_applications/agent-slug/revisions/draft-1/",
overrides: { body: JSON.stringify({ spec }) },
}),
);
});

it("getAgentModelCatalog GETs the project-level models endpoint", async () => {
const catalog = {
models: [{ model: "anthropic/claude-haiku-4.5" }],
levels: { low: ["anthropic/claude-haiku-4.5"] },
};
const fetch = vi.fn().mockResolvedValue({ json: async () => catalog });
const client = makeClient(fetch);

await expect(client.getAgentModelCatalog()).resolves.toEqual(catalog);
expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
method: "get",
path: "/api/projects/123/agent_applications/models/",
}),
);
});
});
});
34 changes: 34 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import type {
AgentSessionLogsParams,
AgentSessionsListParams,
AgentSlackManifest,
AgentSpec,
AgentUsersListResponse,
BundleFile,
DecideApprovalRequest,
ModelCatalog,
} from "@posthog/shared/agent-platform-types";
import type {
ActionabilityJudgmentArtefact,
Expand Down Expand Up @@ -4667,6 +4669,38 @@ export class PostHogAPIClient {
}),
},
});
// new_draft wraps the created revision: `{ revision, source_revision_id }`.
const data = (await response.json()) as { revision: AgentRevision };
return data.revision;
}

/** 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<ModelCatalog> {
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<AgentRevision> {
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;
}

Expand Down
73 changes: 70 additions & 3 deletions packages/shared/src/agent-platform-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,80 @@ export interface AgentApplication {
ingress_base_url: string | null;
}

export type AgentReasoningEffort =
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";

export type AgentModelLevel = "low" | "medium" | "high";

/**
* Session model stability vs. resilience. `cost` (default): pin the first served
* model for the whole session — warm prompt cache, no cross-model failover.
* `availability`: lead with the last-served model but fail over on failure.
* Mirrors `spec.models.optimize_for` in the backend.
*/
export type AgentModelOptimizeFor = "cost" | "availability";

/** 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.models` in the backend.
*/
export type AgentModelPolicy =
| {
mode: "auto";
level?: AgentModelLevel;
reasoning?: AgentReasoningEffort;
optimize_for?: AgentModelOptimizeFor;
}
| {
mode: "manual";
models: AgentModelEntry[];
optimize_for?: AgentModelOptimizeFor;
};

/**
* 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<AgentModelLevel, string[]>;
}

/**
* 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 `models`. One or the other is present. */
models?: AgentModelPolicy;
model?: string;
triggers?: unknown[];
tools?: unknown[];
mcps?: unknown[];
Expand All @@ -74,8 +142,7 @@ export interface AgentSpec {
max_tool_calls?: number;
max_wall_seconds?: number;
};
entrypoint?: string;
reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
reasoning?: AgentReasoningEffort;
[key: string]: unknown;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
WrenchIcon,
} from "@phosphor-icons/react";
import type {
AgentRevisionState,
AgentSpec,
BundleFile,
} from "@posthog/shared/agent-platform-types";
Expand All @@ -37,6 +38,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";
Expand All @@ -62,9 +64,15 @@ const USAGE_HOST = "https://<ingress-host>";
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;
}

Expand Down Expand Up @@ -398,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;
Expand Down Expand Up @@ -460,7 +471,7 @@ export function AgentConfigurationPane({

const SECTION_INFO: Record<string, string> = {
"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.",
Expand Down Expand Up @@ -614,7 +625,7 @@ function DetailBody({
}) {
switch (section) {
case "model":
return <ModelBody spec={spec} />;
return <ModelBody key={ctx.revisionId} spec={spec} ctx={ctx} />;
case "instructions":
return (
<BundleFileBody
Expand Down Expand Up @@ -693,15 +704,16 @@ function byPath(files: BundleFile[], path: string): BundleFile | undefined {
return files.find((f) => f.path === path);
}

function ModelBody({ spec }: { spec: AgentSpec }) {
function ModelBody({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) {
return (
<Flex direction="column" gap="2">
<Row label="model" value={spec.model ?? "not set"} mono />
<Row label="reasoning" value={spec.reasoning ?? "default"} />
{spec.entrypoint ? (
<Row label="entrypoint" value={spec.entrypoint} mono />
) : null}
</Flex>
<AgentModelConfig
spec={spec}
idOrSlug={ctx.idOrSlug}
applicationId={ctx.applicationId}
revisionId={ctx.revisionId}
revisionState={ctx.revisionState}
onSelectRevision={ctx.onSelectRevision}
/>
);
}

Expand Down
Loading
Loading