No cost data yet
diff --git a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/usePlatformCostContent.ts b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/usePlatformCostContent.ts
index 01db1c5130..7b3f92036d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/usePlatformCostContent.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/components/usePlatformCostContent.ts
@@ -3,17 +3,26 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import {
+ getV2ExportPlatformCostLogs,
useGetV2GetPlatformCostDashboard,
useGetV2GetPlatformCostLogs,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { okData } from "@/app/api/helpers";
-import { estimateCostForRow, toLocalInput, toUtcIso } from "../helpers";
+import {
+ buildCostLogsCsv,
+ estimateCostForRow,
+ toLocalInput,
+ toUtcIso,
+} from "../helpers";
interface InitialSearchParams {
start?: string;
end?: string;
provider?: string;
user_id?: string;
+ model?: string;
+ block_name?: string;
+ tracking_type?: string;
page?: string;
tab?: string;
}
@@ -29,14 +38,23 @@ export function usePlatformCostContent(searchParams: InitialSearchParams) {
const providerFilter =
urlParams.get("provider") || searchParams.provider || "";
const userFilter = urlParams.get("user_id") || searchParams.user_id || "";
+ const modelFilter = urlParams.get("model") || searchParams.model || "";
+ const blockFilter =
+ urlParams.get("block_name") || searchParams.block_name || "";
+ const typeFilter =
+ urlParams.get("tracking_type") || searchParams.tracking_type || "";
const [startInput, setStartInput] = useState(toLocalInput(startDate));
const [endInput, setEndInput] = useState(toLocalInput(endDate));
const [providerInput, setProviderInput] = useState(providerFilter);
const [userInput, setUserInput] = useState(userFilter);
+ const [modelInput, setModelInput] = useState(modelFilter);
+ const [blockInput, setBlockInput] = useState(blockFilter);
+ const [typeInput, setTypeInput] = useState(typeFilter);
const [rateOverrides, setRateOverrides] = useState>(
{},
);
+ const [exporting, setExporting] = useState(false);
// Pass ISO date strings through `as unknown as Date` so Orval's URL builder
// forwards them as-is. Date.toString() produces a format FastAPI rejects;
@@ -46,6 +64,9 @@ export function usePlatformCostContent(searchParams: InitialSearchParams) {
end: (endDate || undefined) as unknown as Date | undefined,
provider: providerFilter || undefined,
user_id: userFilter || undefined,
+ model: modelFilter || undefined,
+ block_name: blockFilter || undefined,
+ tracking_type: typeFilter || undefined,
};
const {
@@ -91,6 +112,9 @@ export function usePlatformCostContent(searchParams: InitialSearchParams) {
end: toUtcIso(endInput),
provider: providerInput,
user_id: userInput,
+ model: modelInput,
+ block_name: blockInput,
+ tracking_type: typeInput,
page: "1",
});
}
@@ -105,6 +129,33 @@ export function usePlatformCostContent(searchParams: InitialSearchParams) {
});
}
+ async function handleExport() {
+ setExporting(true);
+ try {
+ const response = await getV2ExportPlatformCostLogs(filterParams);
+ const data = okData(response);
+ if (!data) throw new Error("Export failed: unexpected response");
+ const csv = buildCostLogsCsv(data.logs);
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `platform_costs_${new Date().toISOString().slice(0, 10)}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ if (data.truncated) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `Export truncated: only the first ${data.total_rows} rows were included.`,
+ );
+ }
+ } finally {
+ setExporting(false);
+ }
+ }
+
const totalEstimatedCost =
dashboard?.by_provider.reduce((sum, row) => {
const est = estimateCostForRow(row, rateOverrides);
@@ -128,9 +179,17 @@ export function usePlatformCostContent(searchParams: InitialSearchParams) {
setProviderInput,
userInput,
setUserInput,
+ modelInput,
+ setModelInput,
+ blockInput,
+ setBlockInput,
+ typeInput,
+ setTypeInput,
rateOverrides,
handleRateOverride,
updateUrl,
handleFilter,
+ exporting,
+ handleExport,
};
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/helpers.ts
index 63d14a82c1..9883a5a952 100644
--- a/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/admin/platform-costs/helpers.ts
@@ -1,3 +1,4 @@
+import type { CostLogRow } from "@/app/api/__generated__/models/costLogRow";
import type { ProviderCostSummary } from "@/app/api/__generated__/models/providerCostSummary";
const MICRODOLLARS_PER_USD = 1_000_000;
@@ -111,13 +112,14 @@ export function defaultRateFor(
}
}
-// Overrides are keyed on `${provider}:${tracking_type}` since the same
-// provider can have multiple rows with different billing models.
+// Overrides are keyed on `${provider}:${tracking_type}:${model}` since the
+// same provider can have multiple rows with different billing models and models.
export function rateKey(
provider: string,
trackingType: string | null | undefined,
+ model?: string | null,
): string {
- return `${provider}:${trackingType ?? "per_run"}`;
+ return `${provider}:${trackingType ?? "per_run"}:${model ?? ""}`;
}
export function estimateCostForRow(
@@ -136,17 +138,34 @@ export function estimateCostForRow(
}
const rate =
- rateOverrides[rateKey(row.provider, tt)] ??
+ rateOverrides[rateKey(row.provider, tt, row.model)] ??
defaultRateFor(row.provider, tt);
if (rate === null || rate === undefined) return null;
// Compute the amount for this tracking type, then multiply by rate.
let amount: number;
switch (tt) {
- case "tokens":
+ case "tokens": {
+ // Anthropic cache tokens are billed at different rates:
+ // - cache reads: 10% of base input rate
+ // - cache writes: 125% of base input rate
+ // - uncached input: 100% of base input rate
+ const cacheRead = row.total_cache_read_tokens ?? 0;
+ const cacheWrite = row.total_cache_creation_tokens ?? 0;
+ if (cacheRead > 0 || cacheWrite > 0) {
+ const uncachedInput = row.total_input_tokens;
+ const output = row.total_output_tokens;
+ const cost =
+ (uncachedInput / 1000) * rate +
+ (cacheRead / 1000) * rate * 0.1 +
+ (cacheWrite / 1000) * rate * 1.25 +
+ (output / 1000) * rate;
+ return Math.round(cost * MICRODOLLARS_PER_USD);
+ }
// Rate is per-1K tokens.
amount = (row.total_input_tokens + row.total_output_tokens) / 1000;
break;
+ }
case "characters":
// Rate is per-1K chars. trackingAmount aggregates char counts.
amount = (row.total_tracking_amount || 0) / 1000;
@@ -175,6 +194,11 @@ export function trackingValue(row: ProviderCostSummary) {
if (tt === "cost_usd") return formatMicrodollars(row.total_cost_microdollars);
if (tt === "tokens") {
const tokens = row.total_input_tokens + row.total_output_tokens;
+ const cacheRead = row.total_cache_read_tokens ?? 0;
+ const cacheWrite = row.total_cache_creation_tokens ?? 0;
+ if (cacheRead > 0 || cacheWrite > 0) {
+ return `${formatTokens(tokens)} tokens (+${formatTokens(cacheRead)}r/${formatTokens(cacheWrite)}w cached)`;
+ }
return `${formatTokens(tokens)} tokens`;
}
if (tt === "sandbox_seconds" || tt === "walltime_seconds")
@@ -202,3 +226,54 @@ export function toUtcIso(local: string) {
const d = new Date(local);
return isNaN(d.getTime()) ? "" : d.toISOString();
}
+
+const CSV_HEADERS = [
+ "Time (UTC)",
+ "User ID",
+ "Email",
+ "Block",
+ "Provider",
+ "Type",
+ "Model",
+ "Cost (USD)",
+ "Input Tokens",
+ "Output Tokens",
+ "Cache Read Tokens",
+ "Cache Creation Tokens",
+ "Duration (s)",
+ "Graph Exec ID",
+ "Node Exec ID",
+];
+
+function csvEscape(val: unknown): string {
+ const s = val == null ? "" : String(val);
+ return `"${s.replace(/"/g, '""')}"`;
+}
+
+export function buildCostLogsCsv(logs: CostLogRow[]): string {
+ const header = CSV_HEADERS.map(csvEscape).join(",");
+ const rows = logs.map((log) =>
+ [
+ log.created_at,
+ log.user_id,
+ log.email,
+ log.block_name,
+ log.provider,
+ log.tracking_type,
+ log.model,
+ log.cost_microdollars != null
+ ? (log.cost_microdollars / 1_000_000).toFixed(8)
+ : null,
+ log.input_tokens,
+ log.output_tokens,
+ log.cache_read_tokens,
+ log.cache_creation_tokens,
+ log.duration,
+ log.graph_exec_id,
+ log.node_exec_id,
+ ]
+ .map(csvEscape)
+ .join(","),
+ );
+ return [header, ...rows].join("\r\n");
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx
new file mode 100644
index 0000000000..23f600dc58
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx
@@ -0,0 +1,452 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { cn } from "@/lib/utils";
+import {
+ ArrowCounterClockwise,
+ ChatCircle,
+ PaperPlaneTilt,
+ SpinnerGap,
+ StopCircle,
+ X,
+} from "@phosphor-icons/react";
+import { KeyboardEvent, useEffect, useRef } from "react";
+import { ToolUIPart } from "ai";
+import { MessagePartRenderer } from "@/app/(platform)/copilot/components/ChatMessagesContainer/components/MessagePartRenderer";
+import { CopilotChatActionsProvider } from "@/app/(platform)/copilot/components/CopilotChatActionsProvider/CopilotChatActionsProvider";
+import type { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
+import {
+ GraphAction,
+ SEED_PROMPT_PREFIX,
+ extractTextFromParts,
+ getActionKey,
+ getNodeDisplayName,
+} from "./helpers";
+import { useBuilderChatPanel } from "./useBuilderChatPanel";
+
+interface Props {
+ className?: string;
+ isGraphLoaded?: boolean;
+ onGraphEdited?: () => void;
+}
+
+export function BuilderChatPanel({
+ className,
+ isGraphLoaded,
+ onGraphEdited,
+}: Props) {
+ const panelRef = useRef(null);
+ const {
+ isOpen,
+ handleToggle,
+ retrySession,
+ messages,
+ stop,
+ error,
+ isCreatingSession,
+ sessionError,
+ nodes,
+ parsedActions,
+ appliedActionKeys,
+ handleApplyAction,
+ undoStack,
+ handleUndoLastAction,
+ inputValue,
+ setInputValue,
+ handleSend,
+ sendRawMessage,
+ handleKeyDown,
+ isStreaming,
+ canSend,
+ } = useBuilderChatPanel({ isGraphLoaded, onGraphEdited, panelRef });
+
+ const messagesEndRef = useRef(null);
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages.length]);
+
+ // Move focus to the textarea when the panel opens so keyboard users can type immediately.
+ useEffect(() => {
+ if (isOpen) {
+ textareaRef.current?.focus();
+ }
+ }, [isOpen]);
+
+ return (
+
+ {isOpen && (
+
+
+
+ )}
+
+
+
+ );
+}
+
+function PanelHeader({
+ onClose,
+ undoCount,
+ onUndo,
+}: {
+ onClose: () => void;
+ undoCount: number;
+ onUndo: () => void;
+}) {
+ return (
+
+
+
+
+ Chat with Builder
+
+
+
+ {undoCount > 0 && (
+
+ )}
+
+
+
+ );
+}
+
+interface MessageListProps {
+ messages: ReturnType["messages"];
+ isCreatingSession: boolean;
+ sessionError: boolean;
+ streamError: Error | undefined;
+ nodes: CustomNode[];
+ parsedActions: GraphAction[];
+ appliedActionKeys: Set;
+ onApplyAction: (action: GraphAction) => void;
+ onRetry: () => void;
+ messagesEndRef: React.RefObject;
+ isStreaming: boolean;
+}
+
+function MessageList({
+ messages,
+ isCreatingSession,
+ sessionError,
+ streamError,
+ nodes,
+ parsedActions,
+ appliedActionKeys,
+ onApplyAction,
+ onRetry,
+ messagesEndRef,
+ isStreaming,
+}: MessageListProps) {
+ const visibleMessages = messages.filter((msg) => {
+ const text = extractTextFromParts(msg.parts);
+ if (msg.role === "user" && text.startsWith(SEED_PROMPT_PREFIX))
+ return false;
+ return (
+ Boolean(text) ||
+ (msg.role === "assistant" &&
+ msg.parts?.some((p) => p.type === "dynamic-tool"))
+ );
+ });
+ const lastVisibleRole = visibleMessages.at(-1)?.role;
+ const showTypingIndicator =
+ isStreaming && (!lastVisibleRole || lastVisibleRole === "user");
+
+ return (
+
+ {isCreatingSession && (
+
+
+ Setting up chat session...
+
+ )}
+
+ {sessionError && (
+
+ Failed to start chat session.
+
+
+ )}
+
+ {streamError && (
+
+ Connection error. Please try sending your message again.
+
+ )}
+
+ {visibleMessages.length === 0 && !isCreatingSession && !sessionError && (
+
+
+ Ask me to explain or modify your agent.
+
+ You can say things like “What does this agent do?” or
+ “Add a step that formats the output.”
+
+
+ )}
+
+ {visibleMessages.map((msg) => {
+ const textParts = extractTextFromParts(msg.parts);
+
+ return (
+
+ {msg.role === "assistant"
+ ? (msg.parts ?? []).map((part, i) => {
+ // Normalize dynamic-tool parts → tool-{name} so MessagePartRenderer
+ // can route them: edit_agent/run_agent get their specific renderers,
+ // everything else falls through to GenericTool (collapsed accordion).
+ const renderedPart =
+ part.type === "dynamic-tool"
+ ? ({
+ ...part,
+ type: `tool-${(part as { toolName: string }).toolName}`,
+ } as ToolUIPart)
+ : (part as ToolUIPart);
+ return (
+
+ );
+ })
+ : textParts}
+
+ );
+ })}
+
+ {showTypingIndicator && }
+
+ {parsedActions.length > 0 && (
+
+ )}
+
+
+
+ );
+}
+
+function ActionList({
+ parsedActions,
+ nodes,
+ appliedActionKeys,
+ onApplyAction,
+}: {
+ parsedActions: GraphAction[];
+ nodes: CustomNode[];
+ appliedActionKeys: Set;
+ onApplyAction: (action: GraphAction) => void;
+}) {
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
+ return (
+
+ Suggested changes
+ {parsedActions.map((action) => {
+ const key = getActionKey(action);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function ActionItem({
+ action,
+ nodeMap,
+ isApplied,
+ onApply,
+}: {
+ action: GraphAction;
+ nodeMap: Map;
+ isApplied: boolean;
+ onApply: (action: GraphAction) => void;
+}) {
+ const label =
+ action.type === "update_node_input"
+ ? `Set "${getNodeDisplayName(nodeMap.get(action.nodeId), action.nodeId)}" "${action.key}" = ${JSON.stringify(action.value)}`
+ : `Connect "${getNodeDisplayName(nodeMap.get(action.source), action.source)}" → "${getNodeDisplayName(nodeMap.get(action.target), action.target)}"`;
+
+ return (
+
+ {label}
+ {isApplied ? (
+
+ Applied
+
+ ) : (
+
+ )}
+
+ );
+}
+
+interface PanelInputProps {
+ value: string;
+ onChange: (v: string) => void;
+ onKeyDown: (e: KeyboardEvent) => void;
+ onSend: () => void;
+ onStop: () => void;
+ isStreaming: boolean;
+ isDisabled: boolean;
+ textareaRef?: React.RefObject;
+}
+
+function PanelInput({
+ value,
+ onChange,
+ onKeyDown,
+ onSend,
+ onStop,
+ isStreaming,
+ isDisabled,
+ textareaRef,
+}: PanelInputProps) {
+ return (
+
+ );
+}
+
+function TypingIndicator() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx
new file mode 100644
index 0000000000..b838588a95
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx
@@ -0,0 +1,804 @@
+import {
+ render,
+ screen,
+ fireEvent,
+ cleanup,
+} from "@/tests/integrations/test-utils";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { BuilderChatPanel } from "../BuilderChatPanel";
+import {
+ serializeGraphForChat,
+ parseGraphActions,
+ getActionKey,
+ getNodeDisplayName,
+ buildSeedPrompt,
+ extractTextFromParts,
+ SEED_PROMPT_PREFIX,
+} from "../helpers";
+import type { CustomNode } from "../../FlowEditor/nodes/CustomNode/CustomNode";
+import type { CustomEdge } from "../../FlowEditor/edges/CustomEdge";
+
+// Mock the hook so we isolate the component rendering
+vi.mock("../useBuilderChatPanel", () => ({
+ useBuilderChatPanel: vi.fn(),
+}));
+
+import { useBuilderChatPanel } from "../useBuilderChatPanel";
+
+const mockUseBuilderChatPanel = vi.mocked(useBuilderChatPanel);
+
+function makeMockHook(
+ overrides: Partial> = {},
+): ReturnType {
+ return {
+ isOpen: false,
+ handleToggle: vi.fn(),
+ retrySession: vi.fn(),
+ messages: [],
+ stop: vi.fn(),
+ error: undefined,
+ isCreatingSession: false,
+ sessionError: false,
+ sessionId: null,
+ nodes: [],
+ parsedActions: [],
+ appliedActionKeys: new Set(),
+ handleApplyAction: vi.fn(),
+ undoStack: [],
+ handleUndoLastAction: vi.fn(),
+ inputValue: "",
+ setInputValue: vi.fn(),
+ handleSend: vi.fn(),
+ sendRawMessage: vi.fn(),
+ handleKeyDown: vi.fn(),
+ isStreaming: false,
+ canSend: false,
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mockUseBuilderChatPanel.mockReturnValue(makeMockHook());
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("BuilderChatPanel", () => {
+ it("renders the toggle button when closed", () => {
+ render();
+ expect(screen.getByLabelText("Chat with builder")).toBeDefined();
+ });
+
+ it("does not render the panel content when closed", () => {
+ render();
+ expect(screen.queryByText("Chat with Builder")).toBeNull();
+ });
+
+ it("calls handleToggle when the toggle button is clicked", () => {
+ const handleToggle = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(makeMockHook({ handleToggle }));
+ render();
+ fireEvent.click(screen.getByLabelText("Chat with builder"));
+ expect(handleToggle).toHaveBeenCalledOnce();
+ });
+
+ it("renders the panel when isOpen is true", () => {
+ mockUseBuilderChatPanel.mockReturnValue(makeMockHook({ isOpen: true }));
+ render();
+ expect(screen.getByText("Chat with Builder")).toBeDefined();
+ });
+
+ it("shows creating session indicator when isCreatingSession is true", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({ isOpen: true, isCreatingSession: true }),
+ );
+ render();
+ expect(screen.getByText(/Setting up chat session/i)).toBeDefined();
+ });
+
+ it("shows welcome/empty state when there are no messages", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({ isOpen: true, messages: [] }),
+ );
+ render();
+ expect(
+ screen.getByText(/Ask me to explain or modify your agent/i),
+ ).toBeDefined();
+ });
+
+ it("renders user and assistant messages", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ messages: [
+ {
+ id: "1",
+ role: "user",
+ parts: [{ type: "text", text: "What does this agent do?" }],
+ },
+ {
+ id: "2",
+ role: "assistant",
+ parts: [{ type: "text", text: "This agent searches the web." }],
+ },
+ ] as ReturnType["messages"],
+ }),
+ );
+ render();
+ expect(screen.getByText("What does this agent do?")).toBeDefined();
+ expect(screen.getByText("This agent searches the web.")).toBeDefined();
+ });
+
+ it("renders suggested changes section when parsedActions are present", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ parsedActions: [
+ {
+ type: "update_node_input",
+ nodeId: "1",
+ key: "query",
+ value: "AI news",
+ },
+ ],
+ }),
+ );
+ render();
+ expect(screen.getByText("Suggested changes")).toBeDefined();
+ });
+
+ it("renders the action label correctly for update_node_input", () => {
+ const nodes = [
+ {
+ id: "1",
+ data: {
+ title: "Search",
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "b1",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ },
+ ] as unknown as CustomNode[];
+
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ nodes,
+ parsedActions: [
+ {
+ type: "update_node_input",
+ nodeId: "1",
+ key: "query",
+ value: "AI news",
+ },
+ ],
+ }),
+ );
+ render();
+ expect(screen.getByText(`Set "Search" "query" = "AI news"`)).toBeDefined();
+ });
+
+ it("shows Apply button for unapplied actions and Applied badge for applied actions", () => {
+ const action = {
+ type: "update_node_input" as const,
+ nodeId: "1",
+ key: "query",
+ value: "AI news",
+ };
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ parsedActions: [action],
+ appliedActionKeys: new Set([getActionKey(action)]),
+ }),
+ );
+ render();
+ expect(screen.getByText("Applied")).toBeDefined();
+ expect(screen.queryByText("Apply")).toBeNull();
+ });
+
+ it("calls handleApplyAction when Apply button is clicked", () => {
+ const handleApplyAction = vi.fn();
+ const action = {
+ type: "update_node_input" as const,
+ nodeId: "1",
+ key: "query",
+ value: "AI news",
+ };
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ parsedActions: [action],
+ handleApplyAction,
+ }),
+ );
+ render();
+ fireEvent.click(screen.getByText("Apply"));
+ expect(handleApplyAction).toHaveBeenCalledWith(action);
+ });
+
+ it("does not call handleSend when the textarea is empty and Send button is disabled", () => {
+ const handleSend = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ sessionId: "sess-1",
+ canSend: true,
+ inputValue: "",
+ handleSend,
+ }),
+ );
+ render();
+ const sendButton = screen.getByLabelText("Send");
+ expect((sendButton as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.click(sendButton);
+ expect(handleSend).not.toHaveBeenCalled();
+ });
+
+ it("calls handleSend when the Send button is clicked with text", () => {
+ const handleSend = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ sessionId: "sess-1",
+ canSend: true,
+ inputValue: "Add a summarizer block",
+ handleSend,
+ }),
+ );
+ render();
+ fireEvent.click(screen.getByLabelText("Send"));
+ expect(handleSend).toHaveBeenCalledOnce();
+ });
+
+ it("calls handleKeyDown when a key is pressed in the textarea", () => {
+ const handleKeyDown = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ sessionId: "sess-1",
+ canSend: true,
+ inputValue: "Explain this agent",
+ handleKeyDown,
+ }),
+ );
+ render();
+ const textarea = screen.getByPlaceholderText(/Ask about your agent/i);
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
+ expect(handleKeyDown).toHaveBeenCalled();
+ });
+
+ it("shows Stop button when streaming", () => {
+ const stop = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({ isOpen: true, isStreaming: true, stop }),
+ );
+ render();
+ expect(screen.getByLabelText("Stop")).toBeDefined();
+ fireEvent.click(screen.getByLabelText("Stop"));
+ expect(stop).toHaveBeenCalledOnce();
+ });
+
+ it("shows stream error when error prop is set", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ error: new Error("Connection failed"),
+ }),
+ );
+ render();
+ expect(screen.getByText(/Connection error/i)).toBeDefined();
+ });
+
+ it("shows session error message with Retry when sessionError is true", () => {
+ const retrySession = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({ isOpen: true, sessionError: true, retrySession }),
+ );
+ render();
+ expect(screen.getByText(/Failed to start chat session/i)).toBeDefined();
+ expect(screen.getByText("Retry")).toBeDefined();
+ fireEvent.click(screen.getByText("Retry"));
+ expect(retrySession).toHaveBeenCalledOnce();
+ });
+
+ it("renders the panel with role=complementary and message list with role=log", () => {
+ mockUseBuilderChatPanel.mockReturnValue(makeMockHook({ isOpen: true }));
+ render();
+ expect(screen.getByRole("complementary")).toBeDefined();
+ expect(screen.getByRole("log")).toBeDefined();
+ });
+
+ it("shows undo button in header when undoStack has entries", () => {
+ const handleUndoLastAction = vi.fn();
+ const fakeRestore = vi.fn();
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ undoStack: [{ actionKey: "n1:query", restore: fakeRestore }],
+ handleUndoLastAction,
+ }),
+ );
+ render();
+ const undoBtn = screen.getByLabelText("Undo last applied change");
+ expect(undoBtn).toBeDefined();
+ fireEvent.click(undoBtn);
+ expect(handleUndoLastAction).toHaveBeenCalledOnce();
+ });
+
+ it("does not show undo button when undoStack is empty", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({ isOpen: true, undoStack: [] }),
+ );
+ render();
+ expect(screen.queryByLabelText("Undo last applied change")).toBeNull();
+ });
+
+ it("hides the seed message from the chat UI", () => {
+ mockUseBuilderChatPanel.mockReturnValue(
+ makeMockHook({
+ isOpen: true,
+ messages: [
+ {
+ id: "seed",
+ role: "user",
+ parts: [
+ {
+ type: "text",
+ text: `${SEED_PROMPT_PREFIX} Here is the current graph...`,
+ },
+ ],
+ },
+ {
+ id: "reply",
+ role: "assistant",
+ parts: [{ type: "text", text: "I see you have an empty graph." }],
+ },
+ ] as ReturnType["messages"],
+ }),
+ );
+ render();
+ expect(screen.queryByText(SEED_PROMPT_PREFIX, { exact: false })).toBeNull();
+ expect(screen.getByText("I see you have an empty graph.")).toBeDefined();
+ });
+
+ it("passes onGraphEdited and isGraphLoaded to useBuilderChatPanel", () => {
+ const onGraphEdited = vi.fn();
+ render(
+ ,
+ );
+ expect(mockUseBuilderChatPanel).toHaveBeenCalledWith(
+ expect.objectContaining({ isGraphLoaded: true, onGraphEdited }),
+ );
+ });
+});
+
+describe("serializeGraphForChat", () => {
+ it("returns empty message when no nodes", () => {
+ const result = serializeGraphForChat([], []);
+ expect(result).toBe("The graph is currently empty.");
+ });
+
+ it("lists block names and descriptions", () => {
+ const nodes = [
+ {
+ id: "1",
+ data: {
+ title: "Google Search",
+ description: "Searches the web",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "block-1",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ },
+ ] as unknown as CustomNode[];
+
+ const result = serializeGraphForChat(nodes, []);
+ expect(result).toContain('"Google Search"');
+ expect(result).toContain("Searches the web");
+ });
+
+ it("prefers metadata.customized_name over title", () => {
+ const nodes = [
+ {
+ id: "1",
+ data: {
+ title: "Original Title",
+ description: "",
+ metadata: { customized_name: "My Custom Name" },
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "block-1",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ },
+ ] as unknown as CustomNode[];
+
+ const result = serializeGraphForChat(nodes, []);
+ expect(result).toContain('"My Custom Name"');
+ expect(result).not.toContain('"Original Title"');
+ });
+
+ it("truncates nodes beyond MAX_NODES limit", () => {
+ const nodes = Array.from({ length: 110 }, (_, i) => ({
+ id: String(i),
+ data: {
+ title: `Node ${i}`,
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: `block-${i}`,
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ })) as unknown as CustomNode[];
+
+ const result = serializeGraphForChat(nodes, []);
+ expect(result).toContain("10 additional nodes not shown");
+ });
+
+ it("truncates edges beyond MAX_EDGES limit", () => {
+ const nodes = [
+ {
+ id: "1",
+ data: {
+ title: "A",
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "b1",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ },
+ {
+ id: "2",
+ data: {
+ title: "B",
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "b2",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 200, y: 0 },
+ },
+ ] as unknown as CustomNode[];
+
+ const edges = Array.from({ length: 205 }, (_, i) => ({
+ id: `e${i}`,
+ source: "1",
+ target: "2",
+ sourceHandle: `out${i}`,
+ targetHandle: `in${i}`,
+ type: "custom" as const,
+ })) as unknown as CustomEdge[];
+
+ const result = serializeGraphForChat(nodes, edges);
+ expect(result).toContain("5 additional connections not shown");
+ });
+
+ it("lists connections between nodes", () => {
+ const nodes = [
+ {
+ id: "1",
+ data: {
+ title: "Search",
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "b1",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ },
+ {
+ id: "2",
+ data: {
+ title: "Formatter",
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "b2",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 200, y: 0 },
+ },
+ ] as unknown as CustomNode[];
+
+ const edges = [
+ {
+ id: "1:result->2:input",
+ source: "1",
+ target: "2",
+ sourceHandle: "result",
+ targetHandle: "input",
+ type: "custom" as const,
+ },
+ ] as unknown as CustomEdge[];
+
+ const result = serializeGraphForChat(nodes, edges);
+ expect(result).toContain("Connections");
+ expect(result).toContain('"Search"');
+ expect(result).toContain('"Formatter"');
+ });
+});
+
+describe("parseGraphActions", () => {
+ it("returns empty array for plain text", () => {
+ expect(parseGraphActions("This agent searches the web.")).toEqual([]);
+ });
+
+ it("parses update_node_input action", () => {
+ const text = `
+Here is a suggestion:
+\`\`\`json
+{"action": "update_node_input", "node_id": "1", "key": "query", "value": "AI news"}
+\`\`\`
+ `;
+ const actions = parseGraphActions(text);
+ expect(actions).toHaveLength(1);
+ expect(actions[0]).toEqual({
+ type: "update_node_input",
+ nodeId: "1",
+ key: "query",
+ value: "AI news",
+ });
+ });
+
+ it("parses connect_nodes action", () => {
+ const text = `
+\`\`\`json
+{"action": "connect_nodes", "source": "1", "target": "2", "source_handle": "result", "target_handle": "input"}
+\`\`\`
+ `;
+ const actions = parseGraphActions(text);
+ expect(actions).toHaveLength(1);
+ expect(actions[0]).toEqual({
+ type: "connect_nodes",
+ source: "1",
+ target: "2",
+ sourceHandle: "result",
+ targetHandle: "input",
+ });
+ });
+
+ it("parses multiple action blocks in a single message", () => {
+ const text = `
+Here are the changes:
+\`\`\`json
+{"action": "update_node_input", "node_id": "1", "key": "query", "value": "AI news"}
+\`\`\`
+\`\`\`json
+{"action": "connect_nodes", "source": "1", "target": "2", "source_handle": "result", "target_handle": "input"}
+\`\`\`
+ `;
+ const actions = parseGraphActions(text);
+ expect(actions).toHaveLength(2);
+ expect(actions[0].type).toBe("update_node_input");
+ expect(actions[1].type).toBe("connect_nodes");
+ });
+
+ it("ignores invalid JSON blocks", () => {
+ const text = "```json\nnot valid json\n```";
+ expect(parseGraphActions(text)).toEqual([]);
+ });
+
+ it("ignores blocks without action field", () => {
+ const text = '```json\n{"key": "value"}\n```';
+ expect(parseGraphActions(text)).toEqual([]);
+ });
+
+ it("ignores update_node_input actions with missing required fields", () => {
+ const text =
+ '```json\n{"action": "update_node_input", "node_id": "1"}\n```';
+ expect(parseGraphActions(text)).toEqual([]);
+ });
+
+ it("ignores connect_nodes actions with empty handles", () => {
+ const text =
+ '```json\n{"action": "connect_nodes", "source": "1", "target": "2", "source_handle": "", "target_handle": "input"}\n```';
+ expect(parseGraphActions(text)).toEqual([]);
+ });
+
+ it("ignores update_node_input with non-primitive value", () => {
+ const text =
+ '```json\n{"action": "update_node_input", "node_id": "1", "key": "q", "value": {"nested": "object"}}\n```';
+ expect(parseGraphActions(text)).toEqual([]);
+ });
+
+ it("accepts numeric and boolean primitive values", () => {
+ const textNum =
+ '```json\n{"action": "update_node_input", "node_id": "1", "key": "count", "value": 42}\n```';
+ const textBool =
+ '```json\n{"action": "update_node_input", "node_id": "1", "key": "enabled", "value": true}\n```';
+ const numAction = parseGraphActions(textNum)[0];
+ const boolAction = parseGraphActions(textBool)[0];
+ expect(numAction?.type === "update_node_input" && numAction.value).toBe(42);
+ expect(boolAction?.type === "update_node_input" && boolAction.value).toBe(
+ true,
+ );
+ });
+});
+
+describe("getActionKey", () => {
+ it("returns nodeId:key:value for update_node_input (includes value for multi-turn dedup)", () => {
+ expect(
+ getActionKey({
+ type: "update_node_input",
+ nodeId: "1",
+ key: "query",
+ value: "test",
+ }),
+ ).toBe('1:query:"test"');
+ });
+
+ it("generates distinct keys for same node+key but different values", () => {
+ const key1 = getActionKey({
+ type: "update_node_input",
+ nodeId: "1",
+ key: "query",
+ value: "first",
+ });
+ const key2 = getActionKey({
+ type: "update_node_input",
+ nodeId: "1",
+ key: "query",
+ value: "corrected",
+ });
+ expect(key1).not.toBe(key2);
+ });
+
+ it("returns source:handle->target:handle for connect_nodes", () => {
+ expect(
+ getActionKey({
+ type: "connect_nodes",
+ source: "1",
+ target: "2",
+ sourceHandle: "result",
+ targetHandle: "input",
+ }),
+ ).toBe("1:result->2:input");
+ });
+});
+
+describe("getNodeDisplayName", () => {
+ it("returns customized_name when set", () => {
+ const node = {
+ id: "1",
+ data: {
+ title: "Original",
+ metadata: { customized_name: "My Custom" },
+ },
+ } as unknown as CustomNode;
+ expect(getNodeDisplayName(node, "fallback")).toBe("My Custom");
+ });
+
+ it("falls back to title when no customized_name", () => {
+ const node = {
+ id: "1",
+ data: { title: "Block Title" },
+ } as unknown as CustomNode;
+ expect(getNodeDisplayName(node, "fallback")).toBe("Block Title");
+ });
+
+ it("falls back to the provided fallback when node is undefined", () => {
+ expect(getNodeDisplayName(undefined, "raw-id")).toBe("raw-id");
+ });
+});
+
+describe("buildSeedPrompt", () => {
+ it("starts with SEED_PROMPT_PREFIX", () => {
+ const result = buildSeedPrompt("summary");
+ expect(result.startsWith("I'm building an agent")).toBe(true);
+ });
+
+ it("wraps summary in tags", () => {
+ const result = buildSeedPrompt("some graph summary");
+ expect(result).toContain(
+ "\nsome graph summary\n",
+ );
+ });
+
+ it("includes format instructions for update_node_input", () => {
+ const result = buildSeedPrompt("");
+ expect(result).toContain('"action": "update_node_input"');
+ });
+
+ it("includes format instructions for connect_nodes", () => {
+ const result = buildSeedPrompt("");
+ expect(result).toContain('"action": "connect_nodes"');
+ });
+
+ it("ends with a prompt inviting the user to interact", () => {
+ const result = buildSeedPrompt("");
+ expect(
+ result
+ .trim()
+ .endsWith(
+ "Ask me what you'd like to know about or change in this agent.",
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("extractTextFromParts", () => {
+ it("returns empty string for empty array", () => {
+ expect(extractTextFromParts([])).toBe("");
+ });
+
+ it("concatenates text parts in order", () => {
+ const parts = [
+ { type: "text", text: "Hello, " },
+ { type: "text", text: "world!" },
+ ];
+ expect(extractTextFromParts(parts)).toBe("Hello, world!");
+ });
+
+ it("ignores non-text parts", () => {
+ const parts = [
+ { type: "text", text: "visible" },
+ { type: "tool-call", text: "ignored" },
+ { type: "text", text: " text" },
+ ];
+ expect(extractTextFromParts(parts)).toBe("visible text");
+ });
+
+ it("returns empty string when all parts are non-text", () => {
+ const parts = [{ type: "tool-result" }, { type: "image" }];
+ expect(extractTextFromParts(parts)).toBe("");
+ });
+
+ it("handles parts without a text field", () => {
+ const parts = [{ type: "text" }, { type: "text", text: "hello" }];
+ expect(extractTextFromParts(parts)).toBe("hello");
+ });
+
+ it("returns empty string for null parts", () => {
+ expect(extractTextFromParts(null)).toBe("");
+ });
+
+ it("returns empty string for undefined parts", () => {
+ expect(extractTextFromParts(undefined)).toBe("");
+ });
+});
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts
new file mode 100644
index 0000000000..a772cbe1c1
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { serializeGraphForChat } from "../helpers";
+import type { CustomNode } from "../../FlowEditor/nodes/CustomNode/CustomNode";
+
+describe("serializeGraphForChat – XML injection prevention", () => {
+ it("escapes < and > in node names before embedding in prompt", () => {
+ const nodes = [
+ {
+ id: "1",
+ data: {
+ title: "",
+ description: "",
+ hardcodedValues: {},
+ inputSchema: {},
+ outputSchema: {},
+ uiType: 1,
+ block_id: "b1",
+ costs: [],
+ categories: [],
+ },
+ type: "custom" as const,
+ position: { x: 0, y: 0 },
+ },
+ ] as unknown as CustomNode[];
+
+ const result = serializeGraphForChat(nodes, []);
+ expect(result).not.toContain("
-
-
+
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx
new file mode 100644
index 0000000000..774fe01ed9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/SubscriptionTierSection.tsx
@@ -0,0 +1,140 @@
+"use client";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { useSubscriptionTierSection } from "./useSubscriptionTierSection";
+
+type TierInfo = {
+ key: string;
+ label: string;
+ multiplier: string;
+ description: string;
+};
+
+const TIERS: TierInfo[] = [
+ {
+ key: "FREE",
+ label: "Free",
+ multiplier: "1x",
+ description: "Base rate limits",
+ },
+ {
+ key: "PRO",
+ label: "Pro",
+ multiplier: "5x",
+ description: "5x more AutoPilot capacity",
+ },
+ {
+ key: "BUSINESS",
+ label: "Business",
+ multiplier: "20x",
+ description: "20x more AutoPilot capacity",
+ },
+];
+
+function formatCost(cents: number): string {
+ if (cents === 0) return "Free";
+ return `$${(cents / 100).toFixed(2)}/mo`;
+}
+
+export function SubscriptionTierSection() {
+ const { subscription, isLoading, error, isPending, changeTier } =
+ useSubscriptionTierSection();
+ const [tierError, setTierError] = useState(null);
+
+ if (isLoading) return null;
+
+ if (error) {
+ return (
+
+ Subscription Plan
+
+ {error}
+
+
+ );
+ }
+
+ if (!subscription) return null;
+
+ async function handleTierChange(tierKey: string) {
+ setTierError(null);
+ const err = await changeTier(tierKey);
+ if (err) setTierError(err);
+ }
+
+ return (
+
+ Subscription Plan
+
+ {tierError && (
+
+ {tierError}
+
+ )}
+
+
+ {TIERS.map((tier) => {
+ const isCurrent = subscription.tier === tier.key;
+ const cost = subscription.tier_costs[tier.key] ?? 0;
+ const currentTierOrder = ["FREE", "PRO", "BUSINESS", "ENTERPRISE"];
+ const currentIdx = currentTierOrder.indexOf(subscription.tier);
+ const targetIdx = currentTierOrder.indexOf(tier.key);
+ const isUpgrade = targetIdx > currentIdx;
+ const isDowngrade = targetIdx < currentIdx;
+
+ return (
+
+
+ {tier.label}
+ {isCurrent && (
+
+ Current
+
+ )}
+
+
+ {formatCost(cost)}
+
+ {tier.multiplier} rate limits
+
+
+ {tier.description}
+
+
+ {!isCurrent && (
+
+ )}
+
+ );
+ })}
+
+
+ {subscription.tier !== "FREE" && (
+
+ Your subscription is managed through Stripe. Changes take effect
+ immediately.
+
+ )}
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/useSubscriptionTierSection.ts b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/useSubscriptionTierSection.ts
new file mode 100644
index 0000000000..b0fe635b72
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/components/SubscriptionTierSection/useSubscriptionTierSection.ts
@@ -0,0 +1,55 @@
+import {
+ useGetSubscriptionStatus,
+ useUpdateSubscriptionTier,
+} from "@/app/api/__generated__/endpoints/credits/credits";
+import type { SubscriptionStatusResponse } from "@/app/api/__generated__/models/subscriptionStatusResponse";
+import type { SubscriptionTierRequestTier } from "@/app/api/__generated__/models/subscriptionTierRequestTier";
+
+export type SubscriptionStatus = SubscriptionStatusResponse;
+
+export function useSubscriptionTierSection() {
+ const {
+ data: subscription,
+ isLoading,
+ error: queryError,
+ refetch,
+ } = useGetSubscriptionStatus({
+ query: { select: (data) => (data.status === 200 ? data.data : null) },
+ });
+
+ const error = queryError ? "Failed to load subscription info" : null;
+
+ const { mutateAsync: doUpdateTier, isPending } = useUpdateSubscriptionTier();
+
+ async function changeTier(tier: string): Promise {
+ try {
+ const successUrl = `${window.location.origin}${window.location.pathname}?subscription=success`;
+ const cancelUrl = `${window.location.origin}${window.location.pathname}?subscription=cancelled`;
+ const result = await doUpdateTier({
+ data: {
+ tier: tier as SubscriptionTierRequestTier,
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ },
+ });
+ if (result.status === 200 && result.data.url) {
+ window.location.href = result.data.url;
+ return null;
+ }
+ await refetch();
+ return null;
+ } catch (e: unknown) {
+ const msg =
+ e instanceof Error ? e.message : "Failed to change subscription tier";
+ return msg;
+ }
+ }
+
+ return {
+ subscription: subscription ?? null,
+ isLoading,
+ error,
+ isPending,
+ changeTier,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/page.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/page.tsx
index 34dbb12287..fb565c048b 100644
--- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/credits/page.tsx
@@ -10,6 +10,7 @@ import {
} from "@/components/molecules/Toast/use-toast";
import { RefundModal } from "./RefundModal";
+import { SubscriptionTierSection } from "./components/SubscriptionTierSection/SubscriptionTierSection";
import { CreditTransaction } from "@/lib/autogpt-server-api";
import { UsagePanelContent } from "@/app/(platform)/copilot/components/UsageLimits/UsageLimits";
import type { CoPilotUsageStatus } from "@/app/api/__generated__/models/coPilotUsageStatus";
@@ -141,6 +142,11 @@ export default function CreditsPage() {
Billing
+ {/* Subscription Tier */}
+
+
+
+
{/* Top-up Form */}
diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json
index e68e68b686..446b2eb079 100644
--- a/autogpt_platform/frontend/src/app/api/openapi.json
+++ b/autogpt_platform/frontend/src/app/api/openapi.json
@@ -55,6 +55,33 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
}
+ },
+ {
+ "name": "model",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Model"
+ }
+ },
+ {
+ "name": "block_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Block Name"
+ }
+ },
+ {
+ "name": "tracking_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Tracking Type"
+ }
}
],
"responses": {
@@ -153,6 +180,33 @@
"default": 50,
"title": "Page Size"
}
+ },
+ {
+ "name": "model",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Model"
+ }
+ },
+ {
+ "name": "block_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Block Name"
+ }
+ },
+ {
+ "name": "tracking_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Tracking Type"
+ }
}
],
"responses": {
@@ -180,6 +234,108 @@
}
}
},
+ "/api/admin/platform-costs/logs/export": {
+ "get": {
+ "tags": ["v2", "admin", "platform-cost", "admin"],
+ "summary": "Export Platform Cost Logs",
+ "operationId": "getV2Export platform cost logs",
+ "security": [{ "HTTPBearerJWT": [] }],
+ "parameters": [
+ {
+ "name": "start",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ { "type": "string", "format": "date-time" },
+ { "type": "null" }
+ ],
+ "title": "Start"
+ }
+ },
+ {
+ "name": "end",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ { "type": "string", "format": "date-time" },
+ { "type": "null" }
+ ],
+ "title": "End"
+ }
+ },
+ {
+ "name": "provider",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Provider"
+ }
+ },
+ {
+ "name": "user_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "User Id"
+ }
+ },
+ {
+ "name": "model",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Model"
+ }
+ },
+ {
+ "name": "block_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Block Name"
+ }
+ },
+ {
+ "name": "tracking_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Tracking Type"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformCostExportResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/HTTP401NotAuthenticatedError"
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/HTTPValidationError" }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/analytics/log_raw_analytics": {
"post": {
"tags": ["analytics"],
@@ -2171,6 +2327,68 @@
}
}
},
+ "/api/credits/subscription": {
+ "get": {
+ "tags": ["v1", "credits"],
+ "summary": "Get subscription tier, current cost, and all tier costs",
+ "operationId": "getSubscriptionStatus",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscriptionStatusResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/HTTP401NotAuthenticatedError"
+ }
+ },
+ "security": [{ "HTTPBearerJWT": [] }]
+ },
+ "post": {
+ "tags": ["v1", "credits"],
+ "summary": "Start a Stripe Checkout session to upgrade subscription tier",
+ "operationId": "updateSubscriptionTier",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscriptionTierRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscriptionCheckoutResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/HTTP401NotAuthenticatedError"
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/HTTPValidationError" }
+ }
+ }
+ }
+ },
+ "security": [{ "HTTPBearerJWT": [] }]
+ }
+ },
"/api/credits/transactions": {
"get": {
"tags": ["v1", "credits"],
@@ -8954,6 +9172,14 @@
"model": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Model"
+ },
+ "cache_read_tokens": {
+ "anyOf": [{ "type": "integer" }, { "type": "null" }],
+ "title": "Cache Read Tokens"
+ },
+ "cache_creation_tokens": {
+ "anyOf": [{ "type": "integer" }, { "type": "null" }],
+ "title": "Cache Creation Tokens"
}
},
"type": "object",
@@ -9209,7 +9435,14 @@
},
"CreditTransactionType": {
"type": "string",
- "enum": ["TOP_UP", "USAGE", "GRANT", "REFUND", "CARD_CHECK"],
+ "enum": [
+ "TOP_UP",
+ "USAGE",
+ "GRANT",
+ "REFUND",
+ "CARD_CHECK",
+ "SUBSCRIPTION"
+ ],
"title": "CreditTransactionType"
},
"DeleteFileResponse": {
@@ -11920,6 +12153,20 @@
],
"title": "PlatformCostDashboard"
},
+ "PlatformCostExportResponse": {
+ "properties": {
+ "logs": {
+ "items": { "$ref": "#/components/schemas/CostLogRow" },
+ "type": "array",
+ "title": "Logs"
+ },
+ "total_rows": { "type": "integer", "title": "Total Rows" },
+ "truncated": { "type": "boolean", "title": "Truncated" }
+ },
+ "type": "object",
+ "required": ["logs", "total_rows", "truncated"],
+ "title": "PlatformCostExportResponse"
+ },
"PlatformCostLogsResponse": {
"properties": {
"logs": {
@@ -12334,6 +12581,10 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Tracking Type"
},
+ "model": {
+ "anyOf": [{ "type": "string" }, { "type": "null" }],
+ "title": "Model"
+ },
"total_cost_microdollars": {
"type": "integer",
"title": "Total Cost Microdollars"
@@ -12346,6 +12597,16 @@
"type": "integer",
"title": "Total Output Tokens"
},
+ "total_cache_read_tokens": {
+ "type": "integer",
+ "title": "Total Cache Read Tokens",
+ "default": 0
+ },
+ "total_cache_creation_tokens": {
+ "type": "integer",
+ "title": "Total Cache Creation Tokens",
+ "default": 0
+ },
"total_duration_seconds": {
"type": "number",
"title": "Total Duration Seconds",
@@ -13622,12 +13883,54 @@
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
"title": "SubmissionStatus"
},
+ "SubscriptionCheckoutResponse": {
+ "properties": { "url": { "type": "string", "title": "Url" } },
+ "type": "object",
+ "required": ["url"],
+ "title": "SubscriptionCheckoutResponse"
+ },
+ "SubscriptionStatusResponse": {
+ "properties": {
+ "tier": { "type": "string", "title": "Tier" },
+ "monthly_cost": { "type": "integer", "title": "Monthly Cost" },
+ "tier_costs": {
+ "additionalProperties": { "type": "integer" },
+ "type": "object",
+ "title": "Tier Costs"
+ }
+ },
+ "type": "object",
+ "required": ["tier", "monthly_cost", "tier_costs"],
+ "title": "SubscriptionStatusResponse"
+ },
"SubscriptionTier": {
"type": "string",
"enum": ["FREE", "PRO", "BUSINESS", "ENTERPRISE"],
"title": "SubscriptionTier",
"description": "Subscription tiers with increasing token allowances.\n\nMirrors the ``SubscriptionTier`` enum in ``schema.prisma``.\nOnce ``prisma generate`` is run, this can be replaced with::\n\n from prisma.enums import SubscriptionTier"
},
+ "SubscriptionTierRequest": {
+ "properties": {
+ "tier": {
+ "type": "string",
+ "enum": ["FREE", "PRO", "BUSINESS"],
+ "title": "Tier"
+ },
+ "success_url": {
+ "type": "string",
+ "title": "Success Url",
+ "default": ""
+ },
+ "cancel_url": {
+ "type": "string",
+ "title": "Cancel Url",
+ "default": ""
+ }
+ },
+ "type": "object",
+ "required": ["tier"],
+ "title": "SubscriptionTierRequest"
+ },
"SuggestedGoalResponse": {
"properties": {
"type": {
diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts
index 961776e79e..9b51f2156f 100644
--- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts
+++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts
@@ -194,6 +194,26 @@ export default class BackendAPI {
return this._request("PATCH", "/credits");
}
+ getSubscription(): Promise<{
+ tier: string;
+ monthly_cost: number;
+ tier_costs: Record;
+ }> {
+ return this._get("/credits/subscription");
+ }
+
+ setSubscriptionTier(
+ tier: string,
+ successUrl?: string,
+ cancelUrl?: string,
+ ): Promise<{ url: string }> {
+ return this._request("POST", "/credits/subscription", {
+ tier,
+ success_url: successUrl ?? "",
+ cancel_url: cancelUrl ?? "",
+ });
+ }
+
////////////////////////////////////////
//////////////// GRAPHS ////////////////
////////////////////////////////////////
diff --git a/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts b/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts
index 8a4d0cd9ad..e16f5b765a 100644
--- a/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts
+++ b/autogpt_platform/frontend/src/services/feature-flags/use-get-flag.ts
@@ -10,6 +10,7 @@ export enum Flag {
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment",
ARTIFACTS = "artifacts",
CHAT_MODE_OPTION = "chat-mode-option",
+ BUILDER_CHAT_PANEL = "builder-chat-panel",
}
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -20,6 +21,7 @@ const defaultFlags = {
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
[Flag.ARTIFACTS]: false,
[Flag.CHAT_MODE_OPTION]: false,
+ [Flag.BUILDER_CHAT_PANEL]: false,
};
type FlagValues = typeof defaultFlags;
|