From 210dbb6d980cf1ae1c28be13a256a0ab8142de3c Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Tue, 24 Mar 2026 20:56:29 -0400 Subject: [PATCH] feat(site): add AI Bridge sessions queries (#23385) Introduces the query for paginated sessions --- site/src/api/api.ts | 7 + site/src/api/queries/aiBridge.ts | 6 +- .../ListSessionsFilter.stories.tsx | 64 ++++++++ .../ListSessionsPage/ListSessionsFilter.tsx | 58 +++++++ .../icons/AIBridgeProviderIcon.tsx | 7 + .../SessionSummaryTable.stories.tsx | 58 +++++++ .../SessionSummaryTable.tsx | 143 ++++++++++++++++++ .../AgenticLoopTable.stories.tsx | 46 ++++++ .../SessionTimeline/AgenticLoopTable.tsx | 37 +++++ .../SessionTimeline/PromptTable.stories.tsx | 50 ++++++ .../SessionTimeline/PromptTable.tsx | 66 ++++++++ .../SessionTimeline/ToolCallTable.stories.tsx | 51 +++++++ .../SessionTimeline/ToolCallTable.tsx | 57 +++++++ site/src/pages/AIBridgePage/TokenBadges.tsx | 93 ++++++++++++ site/src/pages/AIBridgePage/utils.ts | 49 ++++++ site/src/testHelpers/entities.ts | 26 ++++ 16 files changed, 815 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.tsx create mode 100644 site/src/pages/AIBridgePage/TokenBadges.tsx create mode 100644 site/src/pages/AIBridgePage/utils.ts diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b81c604071..47dbf08288 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2992,6 +2992,13 @@ class ApiMethods { return response.data; }; + getAIBridgeSessionList = async (options: SearchParamOptions) => { + const url = getURLWithSearchParams("/api/v2/aibridge/sessions", options); + const response = + await this.axios.get(url); + return response.data; + }; + getAIBridgeModels = async (options: SearchParamOptions) => { const url = getURLWithSearchParams("/api/v2/aibridge/models", options); diff --git a/site/src/api/queries/aiBridge.ts b/site/src/api/queries/aiBridge.ts index 039847ef2d..b44bd62bb8 100644 --- a/site/src/api/queries/aiBridge.ts +++ b/site/src/api/queries/aiBridge.ts @@ -1,7 +1,7 @@ -import { API } from "api/api"; -import type { AIBridgeListInterceptionsResponse } from "api/typesGenerated"; -import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; +import { API } from "#/api/api"; +import type { AIBridgeListInterceptionsResponse } from "#/api/typesGenerated"; import { useFilterParamsKey } from "#/components/Filter/Filter"; +import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery"; export const paginatedInterceptions = ( searchParams: URLSearchParams, diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx new file mode 100644 index 0000000000..246d5af875 --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentProps } from "react"; +import { + getDefaultFilterProps, + MockMenu, +} from "#/components/Filter/storyHelpers"; +import { ListSessionsFilter } from "./ListSessionsFilter"; + +type FilterProps = ComponentProps; + +const defaultFilterProps = getDefaultFilterProps< + Pick +>({ + query: "", + values: { + username: undefined, + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + }, +}); + +const meta: Meta = { + title: "pages/AIBridgePage/ListSessionsFilter", + component: ListSessionsFilter, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + ...defaultFilterProps, + }, +}; + +export const WithQuery: Story = { + args: { + ...getDefaultFilterProps>({ + query: "initiator:me", + values: { + username: "me", + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + }, + used: true, + }), + }, +}; + +export const Loading: Story = { + args: { + ...defaultFilterProps, + menus: { + user: { ...MockMenu, isInitializing: true }, + provider: MockMenu, + }, + }, +}; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx new file mode 100644 index 0000000000..364777dbc4 --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx @@ -0,0 +1,58 @@ +import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; +import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; +import { + ProviderFilter, + type ProviderFilterMenu, +} from "pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ProviderFilter"; +import type { FC } from "react"; + +interface ListSessionsFilterProps { + filter: ReturnType; + error?: unknown; + menus: { + user: UserFilterMenu; + provider: ProviderFilterMenu; + }; +} + +export const ListSessionsFilter: FC = ({ + filter, + error, + menus, +}) => { + return ( + <> +
+ Organization: + {/* TODO: add organization filter */} + {/* console.info("Selected organization", org)} */} + {/* /> */} +
+ } + isLoading={menus.user.isInitializing} + presets={[ + { + name: "All sessions", + query: "", + }, + { + name: "My sessions", + query: "initiator:me", + }, + ]} + error={error} + options={ + <> + + + {/* TODO: add client filter */} + {/* */} + + } + /> + + ); +}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx index 5534037908..f0d7866722 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx @@ -26,6 +26,13 @@ export const AIBridgeProviderIcon = ({ className={cn(iconClassName, className)} /> ); + case "anthropic-neue": + return ( + + ); case "copilot": return ( = { + title: "pages/AIBridgePage/SessionSummaryTable", + component: SessionSummaryTable, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + sessionId: MockSession.id, + startTime: new Date(MockSession.started_at), + endTime: new Date(MockSession.ended_at!), + initiator: MockSession.initiator, + client: MockSession.client ?? "Unknown", + providers: MockSession.providers, + inputTokens: MockSession.token_usage_summary.input_tokens, + outputTokens: MockSession.token_usage_summary.output_tokens, + threadCount: MockSession.threads, + toolCallCount: 12, + }, +}; + +export const InProgress: Story = { + args: { + ...Default.args, + endTime: undefined, + }, +}; + +export const MultipleProviders: Story = { + args: { + ...Default.args, + providers: ["anthropic", "openai", "copilot"], + }, +}; + +export const WithTokenMetadata: Story = { + args: { + ...Default.args, + tokenUsageMetadata: { + cache_read_input_tokens: 3200, + cache_creation_input_tokens: 800, + }, + }, +}; + +export const LargeTokenCounts: Story = { + args: { + ...Default.args, + inputTokens: 198_000, + outputTokens: 32_000, + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx new file mode 100644 index 0000000000..469b4eebe9 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx @@ -0,0 +1,143 @@ +import type { MinimalUser } from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { Badge } from "components/Badge/Badge"; +import { AIBridgeClientIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon"; +import { AIBridgeProviderIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon"; +import { formatDateTime } from "utils/time"; +import { TokenBadges } from "../TokenBadges"; +import { getProviderDisplayName, getProviderIconName } from "../utils"; + +const Separator = () => ( +
+); + +interface SessionSummaryTableProps { + sessionId: string; + startTime: Date; + endTime?: Date; + initiator: MinimalUser; + client: string; + providers: readonly string[]; + inputTokens: number; + outputTokens: number; + threadCount: number; + toolCallCount: number; + tokenUsageMetadata?: Record; +} + +export const SessionSummaryTable = ({ + sessionId, + startTime, + endTime, + initiator, + providers, + client, + inputTokens, + outputTokens, + threadCount, + toolCallCount, + tokenUsageMetadata, +}: SessionSummaryTableProps) => { + const durationInMs = + endTime != null + ? new Date(endTime).getTime() - new Date(startTime).getTime() + : undefined; + + return ( +
+
+ Session ID + + {sessionId} + +
+
+ Start time + + {formatDateTime(startTime)} + +
+
+ End time + + {endTime ? formatDateTime(endTime) : "—"} + +
+
+ Duration + + {durationInMs != null ? `${Math.round(durationInMs / 1000)} s` : "—"} + +
+
+ Initiator +
+ + + {initiator.name} + +
+
+
+ Client + +
+ +
+ + {client ?? "Unknown"} + +
+
+
+ Provider +
+ {providers.map((p) => ( + + + + {getProviderDisplayName(p)} + + + ))} +
+
+ +
+ In / out tokens + +
+
+ Threads + {threadCount} +
+
+ Tool calls + {toolCallCount} +
+
+ ); +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.stories.tsx new file mode 100644 index 0000000000..3826d356b6 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AgenticLoopTable } from "./AgenticLoopTable"; + +const meta: Meta = { + title: "pages/AIBridgePage/SessionTimeline/AgenticLoopTable", + component: AgenticLoopTable, +}; + +export default meta; +type Story = StoryObj; + +export const Short: Story = { + args: { + duration: 4_200, + toolCalls: 3, + inputTokens: 1234, + outputTokens: 567, + }, +}; + +export const Long: Story = { + args: { + duration: 125_000, + toolCalls: 42, + inputTokens: 150_000, + outputTokens: 12_000, + }, +}; + +export const SingleToolCall: Story = { + args: { + duration: 980, + toolCalls: 1, + inputTokens: 320, + outputTokens: 88, + }, +}; + +export const NoToolCalls: Story = { + args: { + duration: 500, + toolCalls: 0, + inputTokens: 100, + outputTokens: 50, + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx new file mode 100644 index 0000000000..7f5d1839b4 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/AgenticLoopTable.tsx @@ -0,0 +1,37 @@ +import type { FC } from "react"; +import { cn } from "utils/cn"; +import { TokenBadges } from "../../TokenBadges"; +import { roundDurationDisplay } from "../../utils"; + +interface AgenticLoopTableProps { + duration: number; // in seconds + toolCalls: number; + inputTokens: number; + outputTokens: number; + className?: string; +} + +export const AgenticLoopTable: FC = ({ + duration, + toolCalls, + inputTokens, + outputTokens, + className, +}) => { + return ( +
+
+ In / out tokens + +
+
+ Tool calls + {toolCalls} +
+
+ Duration + {roundDurationDisplay(duration)} +
+
+ ); +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.stories.tsx new file mode 100644 index 0000000000..c86cb8996d --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PromptTable } from "./PromptTable"; + +const meta: Meta = { + title: "pages/AIBridgePage/SessionTimeline/PromptTable", + component: PromptTable, +}; + +export default meta; +type Story = StoryObj; + +export const Anthropic: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + model: "claude-sonnet-4-5", + inputTokens: 1234, + outputTokens: 567, + }, +}; + +export const OpenAI: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + model: "gpt-4o", + inputTokens: 8192, + outputTokens: 1024, + }, +}; + +export const WithTokenMetadata: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + model: "claude-sonnet-4-5", + inputTokens: 5000, + outputTokens: 2500, + tokenUsageMetadata: { + cache_read_input_tokens: 3200, + cache_creation_input_tokens: 800, + }, + }, +}; + +export const LargeTokenCounts: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + model: "claude-opus-4-5", + inputTokens: 198_000, + outputTokens: 8_000, + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx new file mode 100644 index 0000000000..5c05f0bbfb --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/PromptTable.tsx @@ -0,0 +1,66 @@ +import { Badge } from "components/Badge/Badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { AIBridgeModelIcon } from "pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon"; +import type { FC } from "react"; +import { cn } from "utils/cn"; +import { formatDate } from "utils/time"; +import { TokenBadges } from "../../TokenBadges"; + +interface PromptTableProps { + timestamp: Date; + model: string; + inputTokens: number; + outputTokens: number; + tokenUsageMetadata?: Record; + className?: string; +} + +export const PromptTable: FC = ({ + timestamp, + model, + inputTokens, + outputTokens, + tokenUsageMetadata, + className, +}) => { + return ( +
+
+ Timestamp + + {formatDate(timestamp)} + +
+
+ Model + + + + + + {model} + + + {model} + + +
+
+ In / out tokens + +
+
+ ); +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.stories.tsx new file mode 100644 index 0000000000..f4543daa80 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ToolCallTable } from "./ToolCallTable"; + +const meta: Meta = { + title: "pages/AIBridgePage/SessionTimeline/ToolCallTable", + component: ToolCallTable, +}; + +export default meta; +type Story = StoryObj; + +export const WithMCPServer: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + serverURL: "http://localhost:3000/mcp", + inputTokens: 1234, + outputTokens: 567, + }, +}; + +export const WithoutMCPServer: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + serverURL: "", + inputTokens: 1234, + outputTokens: 567, + }, +}; + +export const WithTokenMetadata: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + serverURL: "https://mcp.example.com/api/v1", + inputTokens: 5000, + outputTokens: 2500, + tokenUsageMetadata: { + cache_read_input_tokens: 3200, + cache_creation_input_tokens: 800, + }, + }, +}; + +export const LongServerURL: Story = { + args: { + timestamp: new Date("2025-03-19T14:22:00Z"), + serverURL: + "https://very-long-mcp-server-hostname.internal.example.com/api/v2/mcp/tools", + inputTokens: 800, + outputTokens: 200, + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.tsx new file mode 100644 index 0000000000..21430b23ec --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/ToolCallTable.tsx @@ -0,0 +1,57 @@ +import { CopyButton } from "components/CopyButton/CopyButton"; +import type { FC } from "react"; +import { cn } from "utils/cn"; +import { formatDate } from "utils/time"; +import { TokenBadges } from "../../TokenBadges"; + +interface ToolCallTableProps { + timestamp: Date; + serverURL: string; + inputTokens: number; + outputTokens: number; + tokenUsageMetadata?: Record; + className?: string; +} + +export const ToolCallTable: FC = ({ + timestamp, + serverURL, + inputTokens, + outputTokens, + tokenUsageMetadata, + className, +}) => { + return ( +
+
+ In / out tokens + +
+
+ Started at + + {formatDate(timestamp)} + +
+ {serverURL && ( +
+ MCP server + {serverURL} + +
+ )} +
+ ); +}; diff --git a/site/src/pages/AIBridgePage/TokenBadges.tsx b/site/src/pages/AIBridgePage/TokenBadges.tsx new file mode 100644 index 0000000000..fb436b09e6 --- /dev/null +++ b/site/src/pages/AIBridgePage/TokenBadges.tsx @@ -0,0 +1,93 @@ +import { Badge } from "components/Badge/Badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import type { FC } from "react"; +import { prettyFormatJSON, roundTokenDisplay } from "./utils"; + +interface TokenBadgesProps { + size?: "xs" | "sm" | "md"; + inputTokens: number; + outputTokens: number; + tokenUsageMetadata?: Record; +} + +export const TokenBadges: FC = ({ + size = "sm", + inputTokens, + outputTokens, + tokenUsageMetadata, +}) => ( +
+ + + + + + + + {roundTokenDisplay(inputTokens)} + + + + + + {roundTokenDisplay(outputTokens)} + + + + + +
+
+
+ + + Input tokens + +
+
+
Input
+
+ {roundTokenDisplay(inputTokens)} +
+
+
+ +
+
+ + + Output tokens + +
+
+
Output
+
+ {roundTokenDisplay(outputTokens)} +
+
+
+
+ {tokenUsageMetadata && ( + <> +
+ Token usage metadata +
+
+								{prettyFormatJSON(JSON.stringify(tokenUsageMetadata))}
+							
+ + )} +
+
+
+
+); diff --git a/site/src/pages/AIBridgePage/utils.ts b/site/src/pages/AIBridgePage/utils.ts new file mode 100644 index 0000000000..53055c33f8 --- /dev/null +++ b/site/src/pages/AIBridgePage/utils.ts @@ -0,0 +1,49 @@ +export const roundTokenDisplay = (tokens: number) => { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return tokens.toString(); +}; + +export const roundDurationDisplay = (duration: number) => { + if (duration >= 1000) { + return `${(duration / 1000).toFixed(1)}s`; + } + return `${duration.toFixed(0)}ms`; +}; + +export const getProviderDisplayName = (provider: string) => { + switch (provider) { + case "anthropic": + return "Anthropic"; + case "openai": + return "OpenAI"; + case "copilot": + return "Github"; + default: + return "Unknown"; + } +}; + +// FIXME the current AIBridgeProviderIcon uses the claude icon for the +// anthropic provider. while it's still in use in the RequestLogsPage, we need +// to hack around it here, but when we delete that page, we can just swap the +// icon +export const getProviderIconName = (provider: string) => { + if (provider === "anthropic") { + return "anthropic-neue"; + } + return provider; +}; + +export const prettyFormatJSON = (input: string) => { + let formattedInput = input; + + try { + formattedInput = JSON.stringify(JSON.parse(input), null, 2); + } catch { + // not JSON, use as-is + } + + return formattedInput; +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ddb2b43a91..2d4061c023 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5279,3 +5279,29 @@ export const MockInterceptionCopilot: TypesGen.AIBridgeInterception = { }, ], }; + +export const MockSession: TypesGen.AIBridgeSession = { + id: "c8f2df8c-149c-43e1-9d51-898daaa2c505", + initiator: { + id: "59da0bfe-9c99-47fa-a563-f9fdb18449d0", + username: "bob", + name: "The Builder, Bob", + avatar_url: + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQeDqc5b7Ny5bJOKxDeFvy17kBQ2_ZmBE8vKw&s", + }, + providers: ["anthropic", "openai"], + models: ["claude-opus-4-6", "gpt-5.4"], + client: "Mux", + metadata: { + request_user_agent: + "mux/0.20.1-next.8.g0f494106 ai-sdk/anthropic/3.0.58 ai-sdk/provider-utils/4.0.19 runtime/node.js/22", + }, + started_at: "2026-03-09T09:28:15.03152Z", + ended_at: "2026-03-09T10:28:17.294897Z", + threads: 17, + token_usage_summary: { + input_tokens: 1234, + output_tokens: 4321, + }, + last_prompt: "But *can* I really fix it?", +};