Compare commits

..

5 Commits

Author SHA1 Message Date
David Corbitt
4e176088e9 Auto-resize project menu width 2023-08-10 22:46:01 -07:00
David Corbitt
3cec1f7786 Merge branch 'main' into logged-calls 2023-08-10 22:19:16 -07:00
David Corbitt
b3d8f96fa8 Merge branch 'main' into logged-calls 2023-08-10 21:49:31 -07:00
David Corbitt
54d97ddfa8 Add getUsage function 2023-08-10 19:51:36 -07:00
David Corbitt
1f8e3b820f Rename prompt and completion tokens to input and output tokens 2023-08-10 19:49:18 -07:00
120 changed files with 3076 additions and 4735 deletions

View File

@@ -1,5 +0,0 @@
**/node_modules/
.git
**/.venv/
**/.env*
**/.next/

5
.gitignore vendored
View File

@@ -1,5 +0,0 @@
.env
.venv/
*.pyc
node_modules/
*.tsbuildinfo

View File

@@ -6,7 +6,7 @@ const config = {
overrides: [ overrides: [
{ {
extends: ["plugin:@typescript-eslint/recommended-requiring-type-checking"], extends: ["plugin:@typescript-eslint/recommended-requiring-type-checking"],
files: ["*.mts", "*.ts", "*.tsx"], files: ["*.ts", "*.tsx"],
parserOptions: { parserOptions: {
project: path.join(__dirname, "tsconfig.json"), project: path.join(__dirname, "tsconfig.json"),
}, },

3
app/.gitignore vendored
View File

@@ -44,6 +44,3 @@ yarn-error.log*
# Sentry Auth Token # Sentry Auth Token
.sentryclirc .sentryclirc
# custom openai intialization
src/server/utils/openaiCustomConfig.json

View File

@@ -18,14 +18,13 @@ declare module "nextjs-routes" {
| StaticRoute<"/api/openapi"> | StaticRoute<"/api/openapi">
| StaticRoute<"/api/sentry-example-api"> | StaticRoute<"/api/sentry-example-api">
| DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }> | DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }>
| StaticRoute<"/dashboard">
| DynamicRoute<"/data/[id]", { "id": string }> | DynamicRoute<"/data/[id]", { "id": string }>
| StaticRoute<"/data"> | StaticRoute<"/data">
| DynamicRoute<"/experiments/[id]", { "id": string }> | DynamicRoute<"/experiments/[id]", { "id": string }>
| StaticRoute<"/experiments"> | StaticRoute<"/experiments">
| StaticRoute<"/"> | StaticRoute<"/">
| StaticRoute<"/logged-calls">
| StaticRoute<"/project/settings"> | StaticRoute<"/project/settings">
| StaticRoute<"/request-logs">
| StaticRoute<"/sentry-example-page"> | StaticRoute<"/sentry-example-page">
| StaticRoute<"/world-champs"> | StaticRoute<"/world-champs">
| StaticRoute<"/world-champs/signup">; | StaticRoute<"/world-champs/signup">;

View File

@@ -6,13 +6,13 @@ RUN yarn global add pnpm
# DEPS # DEPS
FROM base as deps FROM base as deps
WORKDIR /code WORKDIR /app
COPY app/prisma app/package.json ./app/ COPY prisma ./
COPY client-libs/typescript/package.json ./client-libs/typescript/
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN cd app && pnpm install --frozen-lockfile COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# BUILDER # BUILDER
FROM base as builder FROM base as builder
@@ -25,24 +25,22 @@ ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN ARG SENTRY_AUTH_TOKEN
ARG NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS ARG NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS
WORKDIR /code WORKDIR /app
COPY --from=deps /code/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /code/app/node_modules ./app/node_modules
COPY --from=deps /code/client-libs/typescript/node_modules ./client-libs/typescript/node_modules
COPY . . COPY . .
RUN cd app && SKIP_ENV_VALIDATION=1 pnpm build RUN SKIP_ENV_VALIDATION=1 pnpm build
# RUNNER # RUNNER
FROM base as runner FROM base as runner
WORKDIR /code/app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
COPY --from=builder /code/ /code/ COPY --from=builder /app/ ./
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT 3000
# Run the "run-prod.sh" script # Run the "run-prod.sh" script
CMD /code/app/run-prod.sh CMD /app/run-prod.sh

View File

@@ -36,8 +36,6 @@ let config = {
}); });
return config; return config;
}, },
transpilePackages: ["openpipe"],
}; };
config = nextRoutes()(config); config = nextRoutes()(config);

View File

@@ -1,6 +1,5 @@
{ {
"name": "openpipe-app", "name": "openpipe",
"private": true,
"type": "module", "type": "module",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -17,7 +16,7 @@
"postinstall": "prisma generate", "postinstall": "prisma generate",
"lint": "next lint", "lint": "next lint",
"start": "next start", "start": "next start",
"codegen:clients": "tsx src/server/scripts/client-codegen.ts", "codegen": "tsx src/server/scripts/client-codegen.ts",
"seed": "tsx prisma/seed.ts", "seed": "tsx prisma/seed.ts",
"check": "concurrently 'pnpm lint' 'pnpm tsc' 'pnpm prettier . --check'", "check": "concurrently 'pnpm lint' 'pnpm tsc' 'pnpm prettier . --check'",
"test": "pnpm vitest" "test": "pnpm vitest"
@@ -25,6 +24,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.5.8", "@anthropic-ai/sdk": "^0.5.8",
"@apidevtools/json-schema-ref-parser": "^10.1.0", "@apidevtools/json-schema-ref-parser": "^10.1.0",
"@babel/preset-typescript": "^7.22.5",
"@babel/standalone": "^7.22.9", "@babel/standalone": "^7.22.9",
"@chakra-ui/anatomy": "^2.2.0", "@chakra-ui/anatomy": "^2.2.0",
"@chakra-ui/next-js": "^2.1.4", "@chakra-ui/next-js": "^2.1.4",
@@ -100,8 +100,7 @@
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.0", "vite-tsconfig-paths": "^4.2.0",
"zod": "^3.21.4", "zod": "^3.21.4",
"zustand": "^4.3.9", "zustand": "^4.3.9"
"openpipe": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@openapi-contrib/openapi-schema-to-json-schema": "^4.0.5", "@openapi-contrib/openapi-schema-to-json-schema": "^4.0.5",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- You are about to rename the column `completionTokens` to `outputTokens` on the `ModelResponse` table.
- You are about to rename the column `promptTokens` to `inputTokens` on the `ModelResponse` table.
*/
-- Rename completionTokens to outputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "completionTokens" TO "outputTokens";
-- Rename promptTokens to inputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "promptTokens" TO "inputTokens";

View File

@@ -1,66 +0,0 @@
/*
Warnings:
- You are about to rename the column `completionTokens` to `outputTokens` on the `ModelResponse` table.
- You are about to rename the column `promptTokens` to `inputTokens` on the `ModelResponse` table.
- You are about to rename the column `startTime` on the `LoggedCall` table to `requestedAt`. Ensure compatibility with application logic.
- You are about to rename the column `startTime` on the `LoggedCallModelResponse` table to `requestedAt`. Ensure compatibility with application logic.
- You are about to rename the column `endTime` on the `LoggedCallModelResponse` table to `receivedAt`. Ensure compatibility with application logic.
- You are about to rename the column `error` on the `LoggedCallModelResponse` table to `errorMessage`. Ensure compatibility with application logic.
- You are about to rename the column `respStatus` on the `LoggedCallModelResponse` table to `statusCode`. Ensure compatibility with application logic.
- You are about to rename the column `totalCost` on the `LoggedCallModelResponse` table to `cost`. Ensure compatibility with application logic.
- You are about to rename the column `inputHash` on the `ModelResponse` table to `cacheKey`. Ensure compatibility with application logic.
- You are about to rename the column `output` on the `ModelResponse` table to `respPayload`. Ensure compatibility with application logic.
*/
-- DropIndex
DROP INDEX "LoggedCall_startTime_idx";
-- DropIndex
DROP INDEX "ModelResponse_inputHash_idx";
-- Rename completionTokens to outputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "completionTokens" TO "outputTokens";
-- Rename promptTokens to inputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "promptTokens" TO "inputTokens";
-- AlterTable
ALTER TABLE "LoggedCall"
RENAME COLUMN "startTime" TO "requestedAt";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "startTime" TO "requestedAt";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "endTime" TO "receivedAt";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "error" TO "errorMessage";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "respStatus" TO "statusCode";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "totalCost" TO "cost";
-- AlterTable
ALTER TABLE "ModelResponse"
RENAME COLUMN "inputHash" TO "cacheKey";
-- AlterTable
ALTER TABLE "ModelResponse"
RENAME COLUMN "output" TO "respPayload";
-- CreateIndex
CREATE INDEX "LoggedCall_requestedAt_idx" ON "LoggedCall"("requestedAt");
-- CreateIndex
CREATE INDEX "ModelResponse_cacheKey_idx" ON "ModelResponse"("cacheKey");

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "LoggedCall" ADD COLUMN "model" TEXT;

View File

@@ -112,10 +112,10 @@ model ScenarioVariantCell {
model ModelResponse { model ModelResponse {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
cacheKey String inputHash String
requestedAt DateTime? requestedAt DateTime?
receivedAt DateTime? receivedAt DateTime?
respPayload Json? output Json?
cost Float? cost Float?
inputTokens Int? inputTokens Int?
outputTokens Int? outputTokens Int?
@@ -131,7 +131,7 @@ model ModelResponse {
scenarioVariantCell ScenarioVariantCell @relation(fields: [scenarioVariantCellId], references: [id], onDelete: Cascade) scenarioVariantCell ScenarioVariantCell @relation(fields: [scenarioVariantCellId], references: [id], onDelete: Cascade)
outputEvaluations OutputEvaluation[] outputEvaluations OutputEvaluation[]
@@index([cacheKey]) @@index([inputHash])
} }
enum EvalType { enum EvalType {
@@ -256,7 +256,7 @@ model WorldChampEntrant {
model LoggedCall { model LoggedCall {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
requestedAt DateTime startTime DateTime
// True if this call was served from the cache, false otherwise // True if this call was served from the cache, false otherwise
cacheHit Boolean cacheHit Boolean
@@ -273,13 +273,12 @@ model LoggedCall {
projectId String @db.Uuid projectId String @db.Uuid
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
model String? tags LoggedCallTag[]
tags LoggedCallTag[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([requestedAt]) @@index([startTime])
} }
model LoggedCallModelResponse { model LoggedCallModelResponse {
@@ -288,14 +287,14 @@ model LoggedCallModelResponse {
reqPayload Json reqPayload Json
// The HTTP status returned by the model provider // The HTTP status returned by the model provider
statusCode Int? respStatus Int?
respPayload Json? respPayload Json?
// Should be null if the request was successful, and some string if the request failed. // Should be null if the request was successful, and some string if the request failed.
errorMessage String? error String?
requestedAt DateTime startTime DateTime
receivedAt DateTime endTime DateTime
// Note: the function to calculate the cacheKey should include the project // Note: the function to calculate the cacheKey should include the project
// ID so we don't share cached responses between projects, which could be an // ID so we don't share cached responses between projects, which could be an
@@ -309,7 +308,7 @@ model LoggedCallModelResponse {
outputTokens Int? outputTokens Int?
finishReason String? finishReason String?
completionId String? completionId String?
cost Decimal? @db.Decimal(18, 12) totalCost Decimal? @db.Decimal(18, 12)
// The LoggedCall that created this LoggedCallModelResponse // The LoggedCall that created this LoggedCallModelResponse
originalLoggedCallId String @unique @db.Uuid originalLoggedCallId String @unique @db.Uuid

View File

@@ -339,17 +339,17 @@ for (let i = 0; i < 1437; i++) {
MODEL_RESPONSE_TEMPLATES[Math.floor(Math.random() * MODEL_RESPONSE_TEMPLATES.length)]!; MODEL_RESPONSE_TEMPLATES[Math.floor(Math.random() * MODEL_RESPONSE_TEMPLATES.length)]!;
const model = template.reqPayload.model; const model = template.reqPayload.model;
// choose random time in the last two weeks, with a bias towards the last few days // choose random time in the last two weeks, with a bias towards the last few days
const requestedAt = new Date(Date.now() - Math.pow(Math.random(), 2) * 1000 * 60 * 60 * 24 * 14); const startTime = new Date(Date.now() - Math.pow(Math.random(), 2) * 1000 * 60 * 60 * 24 * 14);
// choose random delay anywhere from 2 to 10 seconds later for gpt-4, or 1 to 5 seconds for gpt-3.5 // choose random delay anywhere from 2 to 10 seconds later for gpt-4, or 1 to 5 seconds for gpt-3.5
const delay = const delay =
model === "gpt-4" ? 1000 * 2 + Math.random() * 1000 * 8 : 1000 + Math.random() * 1000 * 4; model === "gpt-4" ? 1000 * 2 + Math.random() * 1000 * 8 : 1000 + Math.random() * 1000 * 4;
const receivedAt = new Date(requestedAt.getTime() + delay); const endTime = new Date(startTime.getTime() + delay);
loggedCallsToCreate.push({ loggedCallsToCreate.push({
id: loggedCallId, id: loggedCallId,
cacheHit: false, cacheHit: false,
requestedAt, startTime,
projectId: project.id, projectId: project.id,
createdAt: requestedAt, createdAt: startTime,
}); });
const { promptTokenPrice, completionTokenPrice } = const { promptTokenPrice, completionTokenPrice } =
@@ -365,20 +365,21 @@ for (let i = 0; i < 1437; i++) {
loggedCallModelResponsesToCreate.push({ loggedCallModelResponsesToCreate.push({
id: loggedCallModelResponseId, id: loggedCallModelResponseId,
requestedAt, startTime,
receivedAt, endTime,
originalLoggedCallId: loggedCallId, originalLoggedCallId: loggedCallId,
reqPayload: template.reqPayload, reqPayload: template.reqPayload,
respPayload: template.respPayload, respPayload: template.respPayload,
statusCode: template.respStatus, respStatus: template.respStatus,
errorMessage: template.error, error: template.error,
createdAt: requestedAt, createdAt: startTime,
cacheKey: hashRequest(project.id, template.reqPayload as JsonValue), cacheKey: hashRequest(project.id, template.reqPayload as JsonValue),
durationMs: receivedAt.getTime() - requestedAt.getTime(), durationMs: endTime.getTime() - startTime.getTime(),
inputTokens: template.inputTokens, inputTokens: template.inputTokens,
outputTokens: template.outputTokens, outputTokens: template.outputTokens,
finishReason: template.finishReason, finishReason: template.finishReason,
cost: template.inputTokens * promptTokenPrice + template.outputTokens * completionTokenPrice, totalCost:
template.inputTokens * promptTokenPrice + template.outputTokens * completionTokenPrice,
}); });
loggedCallsToUpdate.push({ loggedCallsToUpdate.push({
where: { where: {

View File

@@ -33,11 +33,25 @@ export default function AddVariantButton() {
<Flex w="100%" justifyContent="flex-end"> <Flex w="100%" justifyContent="flex-end">
<ActionButton <ActionButton
onClick={onClick} onClick={onClick}
py={7} py={5}
leftIcon={<Icon as={loading ? Spinner : BsPlus} boxSize={6} mr={loading ? 1 : 0} />} leftIcon={<Icon as={loading ? Spinner : BsPlus} boxSize={6} mr={loading ? 1 : 0} />}
> >
<Text display={{ base: "none", md: "flex" }}>Add Variant</Text> <Text display={{ base: "none", md: "flex" }}>Add Variant</Text>
</ActionButton> </ActionButton>
{/* <Button
alignItems="center"
justifyContent="center"
fontWeight="normal"
bgColor="transparent"
_hover={{ bgColor: "gray.100" }}
px={cellPadding.x}
onClick={onClick}
height="unset"
minH={headerMinHeight}
>
<Icon as={loading ? Spinner : BsPlus} boxSize={6} mr={loading ? 1 : 0} />
<Text display={{ base: "none", md: "flex" }}>Add Variant</Text>
</Button> */}
</Flex> </Flex>
); );
} }

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { BsPencil, BsX } from "react-icons/bs"; import { BsPencil, BsX } from "react-icons/bs";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useExperiment, useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks"; import { useExperiment, useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError"; import { maybeReportError } from "~/utils/standardResponses";
import { FloatingLabelInput } from "./FloatingLabelInput"; import { FloatingLabelInput } from "./FloatingLabelInput";
export const ScenarioVar = ({ export const ScenarioVar = ({

View File

@@ -107,7 +107,7 @@ export default function OutputCell({
if (disabledReason) return <Text color="gray.500">{disabledReason}</Text>; if (disabledReason) return <Text color="gray.500">{disabledReason}</Text>;
const showLogs = !streamedMessage && !mostRecentResponse?.respPayload; const showLogs = !streamedMessage && !mostRecentResponse?.output;
if (showLogs) if (showLogs)
return ( return (
@@ -160,13 +160,13 @@ export default function OutputCell({
</CellWrapper> </CellWrapper>
); );
const normalizedOutput = mostRecentResponse?.respPayload const normalizedOutput = mostRecentResponse?.output
? provider.normalizeOutput(mostRecentResponse?.respPayload) ? provider.normalizeOutput(mostRecentResponse?.output)
: streamedMessage : streamedMessage
? provider.normalizeOutput(streamedMessage) ? provider.normalizeOutput(streamedMessage)
: null; : null;
if (mostRecentResponse?.respPayload && normalizedOutput?.type === "json") { if (mostRecentResponse?.output && normalizedOutput?.type === "json") {
return ( return (
<CellWrapper> <CellWrapper>
<SyntaxHighlighter <SyntaxHighlighter

View File

@@ -1,16 +1,21 @@
import { type StackProps } from "@chakra-ui/react";
import { useScenarios } from "~/utils/hooks"; import { useScenarios } from "~/utils/hooks";
import Paginator from "../Paginator"; import Paginator from "../Paginator";
const ScenarioPaginator = (props: StackProps) => { const ScenarioPaginator = () => {
const { data } = useScenarios(); const { data } = useScenarios();
if (!data) return null; if (!data) return null;
const { count } = data; const { scenarios, startIndex, lastPage, count } = data;
return <Paginator count={count} condense {...props} />; return (
<Paginator
numItemsLoaded={scenarios.length}
startIndex={startIndex}
lastPage={lastPage}
count={count}
/>
);
}; };
export default ScenarioPaginator; export default ScenarioPaginator;

View File

@@ -10,7 +10,6 @@ const ScenarioRow = (props: {
variants: PromptVariant[]; variants: PromptVariant[];
canHide: boolean; canHide: boolean;
rowStart: number; rowStart: number;
isLast: boolean;
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -22,12 +21,10 @@ const ScenarioRow = (props: {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
sx={isHovered ? highlightStyle : undefined} sx={isHovered ? highlightStyle : undefined}
bgColor="white"
borderLeftWidth={1} borderLeftWidth={1}
{...borders} {...borders}
rowStart={props.rowStart} rowStart={props.rowStart}
colStart={1} colStart={1}
borderBottomLeftRadius={props.isLast ? 8 : 0}
> >
<ScenarioEditor scenario={props.scenario} hovered={isHovered} canHide={props.canHide} /> <ScenarioEditor scenario={props.scenario} hovered={isHovered} canHide={props.canHide} />
</GridItem> </GridItem>
@@ -37,10 +34,8 @@ const ScenarioRow = (props: {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
sx={isHovered ? highlightStyle : undefined} sx={isHovered ? highlightStyle : undefined}
bgColor="white"
rowStart={props.rowStart} rowStart={props.rowStart}
colStart={i + 2} colStart={i + 2}
borderBottomRightRadius={props.isLast && i === props.variants.length - 1 ? 8 : 0}
{...borders} {...borders}
> >
<OutputCell key={variant.id} scenario={props.scenario} variant={variant} /> <OutputCell key={variant.id} scenario={props.scenario} variant={variant} />

View File

@@ -48,20 +48,7 @@ export const ScenariosHeader = () => {
); );
return ( return (
<HStack <HStack w="100%" pb={cellPadding.y} pt={0} align="center" spacing={0}>
w="100%"
py={cellPadding.y}
px={cellPadding.x}
align="center"
spacing={0}
borderTopRightRadius={8}
borderTopLeftRadius={8}
bgColor="white"
borderWidth={1}
borderBottomWidth={0}
borderColor="gray.300"
mt={8}
>
<Text fontSize={16} fontWeight="bold"> <Text fontSize={16} fontWeight="bold">
Scenarios ({scenarios.data?.count}) Scenarios ({scenarios.data?.count})
</Text> </Text>
@@ -70,16 +57,11 @@ export const ScenariosHeader = () => {
<MenuButton <MenuButton
as={IconButton} as={IconButton}
mt={1} mt={1}
ml={2}
variant="ghost" variant="ghost"
aria-label="Edit Scenarios" aria-label="Edit Scenarios"
icon={<Icon as={loading ? Spinner : BsGear} />} icon={<Icon as={loading ? Spinner : BsGear} />}
maxW={8}
minW={8}
minH={8}
maxH={8}
/> />
<MenuList fontSize="md" zIndex="dropdown" mt={-1}> <MenuList fontSize="md" zIndex="dropdown" mt={-3}>
<MenuItem <MenuItem
icon={<Icon as={BsPlus} boxSize={6} mx="-5px" />} icon={<Icon as={BsPlus} boxSize={6} mx="-5px" />}
onClick={() => onAddScenario(false)} onClick={() => onAddScenario(false)}

View File

@@ -53,29 +53,20 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
colStart: i + 2, colStart: i + 2,
borderLeftWidth: i === 0 ? 1 : 0, borderLeftWidth: i === 0 ? 1 : 0,
marginLeft: i === 0 ? "-1px" : 0, marginLeft: i === 0 ? "-1px" : 0,
backgroundColor: "white", backgroundColor: "gray.100",
}; };
const isFirst = i === 0;
const isLast = i === variants.data.length - 1;
return ( return (
<Fragment key={variant.uiId}> <Fragment key={variant.uiId}>
<VariantHeader <VariantHeader
variant={variant} variant={variant}
canHide={variants.data.length > 1} canHide={variants.data.length > 1}
rowStart={1} rowStart={1}
borderTopLeftRadius={isFirst ? 8 : 0}
borderTopRightRadius={isLast ? 8 : 0}
{...sharedProps} {...sharedProps}
/> />
<GridItem rowStart={2} {...sharedProps}> <GridItem rowStart={2} {...sharedProps}>
<VariantEditor variant={variant} /> <VariantEditor variant={variant} />
</GridItem> </GridItem>
<GridItem <GridItem rowStart={3} {...sharedProps}>
rowStart={3}
{...sharedProps}
borderBottomLeftRadius={isFirst ? 8 : 0}
borderBottomRightRadius={isLast ? 8 : 0}
>
<VariantStats variant={variant} /> <VariantStats variant={variant} />
</GridItem> </GridItem>
</Fragment> </Fragment>
@@ -99,7 +90,6 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
scenario={scenario} scenario={scenario}
variants={variants.data} variants={variants.data}
canHide={visibleScenariosCount > 1} canHide={visibleScenariosCount > 1}
isLast={i === visibleScenariosCount - 1}
/> />
))} ))}
<GridItem <GridItem

View File

@@ -1,117 +1,77 @@
import { HStack, IconButton, Text, Select, type StackProps, Icon } from "@chakra-ui/react"; import { Box, HStack, IconButton } from "@chakra-ui/react";
import React, { useCallback } from "react"; import {
import { FiChevronsLeft, FiChevronsRight, FiChevronLeft, FiChevronRight } from "react-icons/fi"; BsChevronDoubleLeft,
import { usePageParams } from "~/utils/hooks"; BsChevronDoubleRight,
BsChevronLeft,
const pageSizeOptions = [10, 25, 50, 100]; BsChevronRight,
} from "react-icons/bs";
import { usePage } from "~/utils/hooks";
const Paginator = ({ const Paginator = ({
numItemsLoaded,
startIndex,
lastPage,
count, count,
condense, }: {
...props numItemsLoaded: number;
}: { count: number; condense?: boolean } & StackProps) => { startIndex: number;
const { page, pageSize, setPageParams } = usePageParams(); lastPage: number;
count: number;
const lastPage = Math.ceil(count / pageSize); }) => {
const [page, setPage] = usePage();
const updatePageSize = useCallback(
(newPageSize: number) => {
const newPage = Math.floor(((page - 1) * pageSize) / newPageSize) + 1;
setPageParams({ page: newPage, pageSize: newPageSize }, "replace");
},
[page, pageSize, setPageParams],
);
const nextPage = () => { const nextPage = () => {
if (page < lastPage) { if (page < lastPage) {
setPageParams({ page: page + 1 }, "replace"); setPage(page + 1, "replace");
} }
}; };
const prevPage = () => { const prevPage = () => {
if (page > 1) { if (page > 1) {
setPageParams({ page: page - 1 }, "replace"); setPage(page - 1, "replace");
} }
}; };
const goToLastPage = () => setPageParams({ page: lastPage }, "replace"); const goToLastPage = () => setPage(lastPage, "replace");
const goToFirstPage = () => setPageParams({ page: 1 }, "replace"); const goToFirstPage = () => setPage(1, "replace");
return ( return (
<HStack <HStack pt={4}>
pt={4} <IconButton
spacing={8} variant="ghost"
justifyContent={condense ? "flex-start" : "space-between"} size="sm"
alignItems="center" onClick={goToFirstPage}
w="full" isDisabled={page === 1}
{...props} aria-label="Go to first page"
> icon={<BsChevronDoubleLeft />}
{!condense && ( />
<> <IconButton
<HStack> variant="ghost"
<Text>Rows</Text> size="sm"
<Select onClick={prevPage}
value={pageSize} isDisabled={page === 1}
onChange={(e) => updatePageSize(parseInt(e.target.value))} aria-label="Previous page"
w={20} icon={<BsChevronLeft />}
backgroundColor="white" />
> <Box>
{pageSizeOptions.map((option) => ( {startIndex}-{startIndex + numItemsLoaded - 1} / {count}
<option key={option} value={option}> </Box>
{option} <IconButton
</option> variant="ghost"
))} size="sm"
</Select> onClick={nextPage}
</HStack> isDisabled={page === lastPage}
<Text> aria-label="Next page"
Page {page} of {lastPage} icon={<BsChevronRight />}
</Text> />
</> <IconButton
)} variant="ghost"
size="sm"
<HStack> onClick={goToLastPage}
<IconButton isDisabled={page === lastPage}
variant="outline" aria-label="Go to last page"
size="sm" icon={<BsChevronDoubleRight />}
onClick={goToFirstPage} />
isDisabled={page === 1}
aria-label="Go to first page"
icon={<Icon as={FiChevronsLeft} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
<IconButton
variant="outline"
size="sm"
onClick={prevPage}
isDisabled={page === 1}
aria-label="Previous page"
icon={<Icon as={FiChevronLeft} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
{condense && (
<Text>
Page {page} of {lastPage}
</Text>
)}
<IconButton
variant="outline"
size="sm"
onClick={nextPage}
isDisabled={page === lastPage}
aria-label="Next page"
icon={<Icon as={FiChevronRight} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
<IconButton
variant="outline"
size="sm"
onClick={goToLastPage}
isDisabled={page === lastPage}
aria-label="Go to last page"
icon={<Icon as={FiChevronsRight} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
</HStack>
</HStack> </HStack>
); );
}; };

View File

@@ -75,7 +75,7 @@ export default function VariantHeader(
padding={0} padding={0}
sx={{ sx={{
position: "sticky", position: "sticky",
top: "-2", top: "0",
// Ensure that the menu always appears above the sticky header of other variants // Ensure that the menu always appears above the sticky header of other variants
zIndex: menuOpen ? "dropdown" : 10, zIndex: menuOpen ? "dropdown" : 10,
}} }}
@@ -84,7 +84,6 @@ export default function VariantHeader(
> >
<HStack <HStack
spacing={2} spacing={2}
py={2}
alignItems="flex-start" alignItems="flex-start"
minH={headerMinHeight} minH={headerMinHeight}
draggable={!isInputHovered} draggable={!isInputHovered}
@@ -103,9 +102,7 @@ export default function VariantHeader(
setIsDragTarget(false); setIsDragTarget(false);
}} }}
onDrop={onReorder} onDrop={onReorder}
backgroundColor={isDragTarget ? "gray.200" : "white"} backgroundColor={isDragTarget ? "gray.200" : "gray.100"}
borderTopLeftRadius={gridItemProps.borderTopLeftRadius}
borderTopRightRadius={gridItemProps.borderTopRightRadius}
h="full" h="full"
> >
<Icon <Icon

View File

@@ -0,0 +1,201 @@
import {
Box,
Card,
CardHeader,
Heading,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
Tooltip,
Collapse,
HStack,
VStack,
IconButton,
useToast,
Icon,
Button,
ButtonGroup,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { ChevronUpIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { type RouterOutputs, api } from "~/utils/api";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atelierCaveLight } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import stringify from "json-stringify-pretty-compact";
import Link from "next/link";
dayjs.extend(relativeTime);
type LoggedCall = RouterOutputs["dashboard"]["loggedCalls"][0];
const FormattedJson = ({ json }: { json: any }) => {
const jsonString = stringify(json, { maxLength: 40 });
const toast = useToast();
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({
title: "Copied to clipboard",
status: "success",
duration: 2000,
});
} catch (err) {
toast({
title: "Failed to copy to clipboard",
status: "error",
duration: 2000,
});
}
};
return (
<Box position="relative" fontSize="sm" borderRadius="md" overflow="hidden">
<SyntaxHighlighter
customStyle={{ overflowX: "unset" }}
language="json"
style={atelierCaveLight}
lineProps={{
style: { wordBreak: "break-all", whiteSpace: "pre-wrap" },
}}
wrapLines
>
{jsonString}
</SyntaxHighlighter>
<IconButton
aria-label="Copy"
icon={<CopyIcon />}
position="absolute"
top={1}
right={1}
size="xs"
variant="ghost"
onClick={() => void copyToClipboard(jsonString)}
/>
</Box>
);
};
function TableRow({
loggedCall,
isExpanded,
onToggle,
}: {
loggedCall: LoggedCall;
isExpanded: boolean;
onToggle: () => void;
}) {
const isError = loggedCall.modelResponse?.respStatus !== 200;
const timeAgo = dayjs(loggedCall.startTime).fromNow();
const fullTime = dayjs(loggedCall.startTime).toString();
const model = useMemo(
() => loggedCall.tags.find((tag) => tag.name.startsWith("$model"))?.value,
[loggedCall.tags],
);
return (
<>
<Tr
onClick={onToggle}
key={loggedCall.id}
_hover={{ bgColor: "gray.100", cursor: "pointer" }}
sx={{
"> td": { borderBottom: "none" },
}}
>
<Td>
<Icon boxSize={6} as={isExpanded ? ChevronUpIcon : ChevronDownIcon} />
</Td>
<Td>
<Tooltip label={fullTime} placement="top">
<Box whiteSpace="nowrap" minW="120px">
{timeAgo}
</Box>
</Tooltip>
</Td>
<Td width="100%">{model}</Td>
<Td isNumeric>{((loggedCall.modelResponse?.durationMs ?? 0) / 1000).toFixed(2)}s</Td>
<Td isNumeric>{loggedCall.modelResponse?.inputTokens}</Td>
<Td isNumeric>{loggedCall.modelResponse?.outputTokens}</Td>
<Td sx={{ color: isError ? "red.500" : "green.500", fontWeight: "semibold" }} isNumeric>
{loggedCall.modelResponse?.respStatus ?? "No response"}
</Td>
</Tr>
<Tr>
<Td colSpan={8} p={0}>
<Collapse in={isExpanded} unmountOnExit={true}>
<VStack p={4} align="stretch">
<HStack align="stretch">
<VStack flex={1} align="stretch">
<Heading size="sm">Input</Heading>
<FormattedJson json={loggedCall.modelResponse?.reqPayload} />
</VStack>
<VStack flex={1} align="stretch">
<Heading size="sm">Output</Heading>
<FormattedJson json={loggedCall.modelResponse?.respPayload} />
</VStack>
</HStack>
<ButtonGroup alignSelf="flex-end">
<Button as={Link} colorScheme="blue" href={{ pathname: "/experiments" }}>
Experiments
</Button>
</ButtonGroup>
</VStack>
</Collapse>
</Td>
</Tr>
</>
);
}
export default function LoggedCallTable() {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const loggedCalls = api.dashboard.loggedCalls.useQuery({});
return (
<Card variant="outline" width="100%" overflow="hidden">
<CardHeader>
<Heading as="h3" size="sm">
Logged Calls
</Heading>
</CardHeader>
<Table>
<Thead>
<Tr>
<Th />
<Th>Time</Th>
<Th>Model</Th>
<Th isNumeric>Duration</Th>
<Th isNumeric>Input tokens</Th>
<Th isNumeric>Output tokens</Th>
<Th isNumeric>Status</Th>
</Tr>
</Thead>
<Tbody>
{loggedCalls.data?.map((loggedCall) => {
return (
<TableRow
key={loggedCall.id}
loggedCall={loggedCall}
isExpanded={loggedCall.id === expandedRow}
onToggle={() => {
if (loggedCall.id === expandedRow) {
setExpandedRow(null);
} else {
setExpandedRow(loggedCall.id);
}
}}
/>
);
})}
</Tbody>
</Table>
</Card>
);
}

View File

@@ -1,46 +0,0 @@
import { Card, CardHeader, Heading, Table, Tbody, HStack, Button, Text } from "@chakra-ui/react";
import { useState } from "react";
import Link from "next/link";
import { useLoggedCalls } from "~/utils/hooks";
import { TableHeader, TableRow } from "../requestLogs/TableRow";
export default function LoggedCallsTable() {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const { data: loggedCalls } = useLoggedCalls();
return (
<Card width="100%" overflow="hidden">
<CardHeader>
<HStack justifyContent="space-between">
<Heading as="h3" size="sm">
Request Logs
</Heading>
<Button as={Link} href="/request-logs" variant="ghost" colorScheme="blue">
<Text>View All</Text>
</Button>
</HStack>
</CardHeader>
<Table>
<TableHeader />
<Tbody>
{loggedCalls?.calls.map((loggedCall) => {
return (
<TableRow
key={loggedCall.id}
loggedCall={loggedCall}
isExpanded={loggedCall.id === expandedRow}
onToggle={() => {
if (loggedCall.id === expandedRow) {
setExpandedRow(null);
} else {
setExpandedRow(loggedCall.id);
}
}}
/>
);
})}
</Tbody>
</Table>
</Card>
);
}

View File

@@ -1,61 +0,0 @@
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { useMemo } from "react";
import { useSelectedProject } from "~/utils/hooks";
import dayjs from "~/utils/dayjs";
import { api } from "~/utils/api";
export default function UsageGraph() {
const { data: selectedProject } = useSelectedProject();
const stats = api.dashboard.stats.useQuery(
{ projectId: selectedProject?.id ?? "" },
{ enabled: !!selectedProject },
);
const data = useMemo(() => {
return (
stats.data?.periods.map(({ period, numQueries, cost }) => ({
period,
Requests: numQueries,
"Total Spent (USD)": parseFloat(cost.toString()),
})) || []
);
}, [stats.data]);
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<XAxis dataKey="period" tickFormatter={(str: string) => dayjs(str).format("MMM D")} />
<YAxis yAxisId="left" dataKey="Requests" orientation="left" stroke="#8884d8" />
<YAxis
yAxisId="right"
dataKey="Total Spent (USD)"
orientation="right"
unit="$"
stroke="#82ca9d"
/>
<Tooltip />
<Legend />
<CartesianGrid stroke="#f5f5f5" />
<Line dataKey="Requests" stroke="#8884d8" yAxisId="left" dot={false} strokeWidth={2} />
<Line
dataKey="Total Spent (USD)"
stroke="#82ca9d"
yAxisId="right"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -1,16 +1,21 @@
import { type StackProps } from "@chakra-ui/react";
import { useDatasetEntries } from "~/utils/hooks"; import { useDatasetEntries } from "~/utils/hooks";
import Paginator from "../Paginator"; import Paginator from "../Paginator";
const DatasetEntriesPaginator = (props: StackProps) => { const DatasetEntriesPaginator = () => {
const { data } = useDatasetEntries(); const { data } = useDatasetEntries();
if (!data) return null; if (!data) return null;
const { count } = data; const { entries, startIndex, lastPage, count } = data;
return <Paginator count={count} {...props} />; return (
<Paginator
numItemsLoaded={entries.length}
startIndex={startIndex}
lastPage={lastPage}
count={count}
/>
);
}; };
export default DatasetEntriesPaginator; export default DatasetEntriesPaginator;

View File

@@ -7,7 +7,6 @@ import {
Spinner, Spinner,
AspectRatio, AspectRatio,
SkeletonText, SkeletonText,
Card,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { RiFlaskLine } from "react-icons/ri"; import { RiFlaskLine } from "react-icons/ri";
import { formatTimePast } from "~/utils/dayjs"; import { formatTimePast } from "~/utils/dayjs";
@@ -30,22 +29,17 @@ type ExperimentData = {
export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => { export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => {
return ( return (
<Card <AspectRatio ratio={1.2} w="full">
w="full"
h="full"
cursor="pointer"
p={4}
bg="white"
borderRadius={4}
_hover={{ bg: "gray.100" }}
transition="background 0.2s"
aspectRatio={1.2}
>
<VStack <VStack
as={Link} as={Link}
w="full"
h="full"
href={{ pathname: "/experiments/[id]", query: { id: exp.id } }} href={{ pathname: "/experiments/[id]", query: { id: exp.id } }}
bg="gray.50"
_hover={{ bg: "gray.100" }}
transition="background 0.2s"
cursor="pointer"
borderColor="gray.200"
borderWidth={1}
p={4}
justify="space-between" justify="space-between"
> >
<HStack w="full" color="gray.700" justify="center"> <HStack w="full" color="gray.700" justify="center">
@@ -63,7 +57,7 @@ export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => {
<Text flex={1}>Updated {formatTimePast(exp.updatedAt)}</Text> <Text flex={1}>Updated {formatTimePast(exp.updatedAt)}</Text>
</HStack> </HStack>
</VStack> </VStack>
</Card> </AspectRatio>
); );
}; };
@@ -95,30 +89,30 @@ export const NewExperimentCard = () => {
}, [createMutation, router, selectedProjectId]); }, [createMutation, router, selectedProjectId]);
return ( return (
<Card <AspectRatio ratio={1.2} w="full">
w="full" <VStack
h="full" align="center"
cursor="pointer" justify="center"
p={4} _hover={{ cursor: "pointer", bg: "gray.50" }}
bg="white" transition="background 0.2s"
borderRadius={4} cursor="pointer"
_hover={{ bg: "gray.100" }} borderColor="gray.200"
transition="background 0.2s" borderWidth={1}
aspectRatio={1.2} p={4}
> onClick={createExperiment}
<VStack align="center" justify="center" w="full" h="full" p={4} onClick={createExperiment}> >
<Icon as={isLoading ? Spinner : BsPlusSquare} boxSize={8} /> <Icon as={isLoading ? Spinner : BsPlusSquare} boxSize={8} />
<Text display={{ base: "none", md: "block" }} ml={2}> <Text display={{ base: "none", md: "block" }} ml={2}>
New Experiment New Experiment
</Text> </Text>
</VStack> </VStack>
</Card> </AspectRatio>
); );
}; };
export const ExperimentCardSkeleton = () => ( export const ExperimentCardSkeleton = () => (
<AspectRatio ratio={1.2} w="full"> <AspectRatio ratio={1.2} w="full">
<VStack align="center" borderColor="gray.200" borderWidth={1} p={4} bg="white"> <VStack align="center" borderColor="gray.200" borderWidth={1} p={4} bg="gray.50">
<SkeletonText noOfLines={1} w="80%" /> <SkeletonText noOfLines={1} w="80%" />
<SkeletonText noOfLines={2} w="60%" /> <SkeletonText noOfLines={2} w="60%" />
<SkeletonText noOfLines={1} w="80%" /> <SkeletonText noOfLines={1} w="80%" />

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { import {
Heading, Heading,
VStack, VStack,
@@ -9,14 +9,14 @@ import {
Box, Box,
Link as ChakraLink, Link as ChakraLink,
Flex, Flex,
useBreakpointValue,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { BsGearFill, BsGithub, BsPersonCircle } from "react-icons/bs"; import { BsGearFill, BsGithub, BsPersonCircle } from "react-icons/bs";
import { IoStatsChartOutline } from "react-icons/io5"; import { IoStatsChartOutline } from "react-icons/io5";
import { RiHome3Line, RiDatabase2Line, RiFlaskLine } from "react-icons/ri"; import { RiDatabase2Line, RiFlaskLine } from "react-icons/ri";
import { signIn, useSession } from "next-auth/react"; import { signIn, useSession } from "next-auth/react";
import UserMenu from "./UserMenu";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
import ProjectMenu from "./ProjectMenu"; import ProjectMenu from "./ProjectMenu";
import NavSidebarOption from "./NavSidebarOption"; import NavSidebarOption from "./NavSidebarOption";
@@ -27,16 +27,10 @@ const Divider = () => <Box h="1px" bgColor="gray.300" w="full" />;
const NavSidebar = () => { const NavSidebar = () => {
const user = useSession().data; const user = useSession().data;
// Hack to get around initial flash, see https://github.com/chakra-ui/chakra-ui/issues/6452
const isMobile = useBreakpointValue({ base: true, md: false, ssr: false });
const renderCount = useRef(0);
renderCount.current++;
const displayLogo = isMobile && renderCount.current > 1;
return ( return (
<VStack <VStack
align="stretch" align="stretch"
bgColor="gray.50"
py={2} py={2}
px={2} px={2}
pb={0} pb={0}
@@ -46,59 +40,32 @@ const NavSidebar = () => {
borderRightWidth={1} borderRightWidth={1}
borderColor="gray.300" borderColor="gray.300"
> >
{displayLogo && ( <HStack
<> as={Link}
<HStack href="/"
as={Link} _hover={{ textDecoration: "none" }}
href="/" spacing={{ base: 1, md: 0 }}
_hover={{ textDecoration: "none" }} mx={2}
spacing={{ base: 1, md: 0 }} py={{ base: 1, md: 2 }}
mx={2} >
py={{ base: 1, md: 2 }} <Image src="/logo.svg" alt="" boxSize={6} mr={4} ml={{ base: 0.5, md: 0 }} />
> <Heading size="md" fontFamily="inconsolata, monospace">
<Image src="/logo.svg" alt="" boxSize={6} mr={4} ml={{ base: 0.5, md: 0 }} /> OpenPipe
<Heading size="md" fontFamily="inconsolata, monospace"> </Heading>
OpenPipe </HStack>
</Heading> <Divider />
</HStack>
<Divider />
</>
)}
<VStack align="flex-start" overflowY="auto" overflowX="hidden" flex={1}> <VStack align="flex-start" overflowY="auto" overflowX="hidden" flex={1}>
{user != null && ( {user != null && (
<> <>
<ProjectMenu /> <ProjectMenu />
<Divider /> <Divider />
{env.NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS && ( {env.NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS && (
<> <IconLink icon={IoStatsChartOutline} label="Logged Calls" href="/logged-calls" beta />
<IconLink icon={RiHome3Line} label="Dashboard" href="/dashboard" beta />
<IconLink
icon={IoStatsChartOutline}
label="Request Logs"
href="/request-logs"
beta
/>
</>
)} )}
<IconLink icon={RiFlaskLine} label="Experiments" href="/experiments" /> <IconLink icon={RiFlaskLine} label="Experiments" href="/experiments" />
{env.NEXT_PUBLIC_SHOW_DATA && ( {env.NEXT_PUBLIC_SHOW_DATA && (
<IconLink icon={RiDatabase2Line} label="Data" href="/data" /> <IconLink icon={RiDatabase2Line} label="Data" href="/data" />
)} )}
<VStack w="full" alignItems="flex-start" spacing={0} pt={8}>
<Text
pl={2}
pb={2}
fontSize="xs"
fontWeight="bold"
color="gray.500"
display={{ base: "none", md: "flex" }}
>
CONFIGURATION
</Text>
<IconLink icon={BsGearFill} label="Project Settings" href="/project/settings" />
</VStack>
</> </>
)} )}
{user === null && ( {user === null && (
@@ -120,7 +87,20 @@ const NavSidebar = () => {
</NavSidebarOption> </NavSidebarOption>
)} )}
</VStack> </VStack>
<VStack w="full" alignItems="flex-start" spacing={0}>
<Text
pl={2}
pb={2}
fontSize="xs"
fontWeight="bold"
color="gray.500"
display={{ base: "none", md: "flex" }}
>
CONFIGURATION
</Text>
<IconLink icon={BsGearFill} label="Project Settings" href="/project/settings" />
</VStack>
{user && <UserMenu user={user} borderColor={"gray.200"} />}
<Divider /> <Divider />
<VStack spacing={0} align="center"> <VStack spacing={0} align="center">
<ChakraLink <ChakraLink
@@ -180,7 +160,7 @@ export default function AppShell({
<title>{title ? `${title} | OpenPipe` : "OpenPipe"}</title> <title>{title ? `${title} | OpenPipe` : "OpenPipe"}</title>
</Head> </Head>
<NavSidebar /> <NavSidebar />
<Box h="100%" flex={1} overflowY="auto" bgColor="gray.50"> <Box h="100%" flex={1} overflowY="auto">
{children} {children}
</Box> </Box>
</Flex> </Flex>

View File

@@ -6,18 +6,16 @@ import {
PopoverTrigger, PopoverTrigger,
PopoverContent, PopoverContent,
Flex, Flex,
IconButton,
Icon, Icon,
Divider, Divider,
Button, Button,
useDisclosure, useDisclosure,
Spinner, Spinner,
Link as ChakraLink,
Image,
Box,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { BsPlus, BsPersonCircle } from "react-icons/bs"; import { BsChevronRight, BsGear, BsPlus } from "react-icons/bs";
import { type Project } from "@prisma/client"; import { type Project } from "@prisma/client";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
@@ -25,14 +23,13 @@ import { api } from "~/utils/api";
import NavSidebarOption from "./NavSidebarOption"; import NavSidebarOption from "./NavSidebarOption";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks"; import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession, signOut } from "next-auth/react";
export default function ProjectMenu() { export default function ProjectMenu() {
const router = useRouter(); const router = useRouter();
const utils = api.useContext(); const utils = api.useContext();
const selectedProjectId = useAppStore((s) => s.selectedProjectId); const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId); const setselectedProjectId = useAppStore((s) => s.setselectedProjectId);
const { data: projects } = api.projects.list.useQuery(); const { data: projects } = api.projects.list.useQuery();
@@ -42,9 +39,9 @@ export default function ProjectMenu() {
projects[0] && projects[0] &&
(!selectedProjectId || !projects.find((proj) => proj.id === selectedProjectId)) (!selectedProjectId || !projects.find((proj) => proj.id === selectedProjectId))
) { ) {
setSelectedProjectId(projects[0].id); setselectedProjectId(projects[0].id);
} }
}, [selectedProjectId, setSelectedProjectId, projects]); }, [selectedProjectId, setselectedProjectId, projects]);
const { data: selectedProject } = useSelectedProject(); const { data: selectedProject } = useSelectedProject();
@@ -52,32 +49,28 @@ export default function ProjectMenu() {
const createMutation = api.projects.create.useMutation(); const createMutation = api.projects.create.useMutation();
const [createProject, isLoading] = useHandledAsyncCallback(async () => { const [createProject, isLoading] = useHandledAsyncCallback(async () => {
const newProj = await createMutation.mutateAsync({ name: "Untitled Project" }); const newProj = await createMutation.mutateAsync({ name: "New Project" });
await utils.projects.list.invalidate(); await utils.projects.list.invalidate();
setSelectedProjectId(newProj.id); setselectedProjectId(newProj.id);
await router.push({ pathname: "/project/settings" }); await router.push({ pathname: "/project/settings" });
}, [createMutation, router]); }, [createMutation, router]);
const user = useSession().data;
const profileImage = user?.user.image ? (
<Image src={user.user.image} alt="profile picture" boxSize={6} borderRadius="50%" />
) : (
<Icon as={BsPersonCircle} boxSize={6} />
);
return ( return (
<VStack w="full" alignItems="flex-start" spacing={0} py={1}> <VStack w="full" alignItems="flex-start" spacing={0}>
<Popover <Text
placement="bottom" pl={2}
isOpen={popover.isOpen} pb={2}
onOpen={popover.onOpen} fontSize="xs"
onClose={popover.onClose} fontWeight="bold"
closeOnBlur color="gray.500"
display={{ base: "none", md: "flex" }}
> >
PROJECT
</Text>
<Popover placement="right-end" isOpen={popover.isOpen} onClose={popover.onClose} closeOnBlur>
<PopoverTrigger> <PopoverTrigger>
<NavSidebarOption> <NavSidebarOption>
<HStack w="full"> <HStack w="full" onClick={popover.onToggle}>
<Flex <Flex
p={1} p={1}
borderRadius={4} borderRadius={4}
@@ -90,35 +83,20 @@ export default function ProjectMenu() {
> >
<Text>{selectedProject?.name[0]?.toUpperCase()}</Text> <Text>{selectedProject?.name[0]?.toUpperCase()}</Text>
</Flex> </Flex>
<Text <Text fontSize="sm" display={{ base: "none", md: "block" }} py={1} flex={1}>
fontSize="sm"
display={{ base: "none", md: "block" }}
py={1}
flex={1}
fontWeight="bold"
>
{selectedProject?.name} {selectedProject?.name}
</Text> </Text>
<Box mr={2}>{profileImage}</Box> <Icon as={BsChevronRight} boxSize={4} color="gray.500" />
</HStack> </HStack>
</NavSidebarOption> </NavSidebarOption>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent _focusVisible={{ outline: "unset" }} ml={-1} w="auto" minW={100} maxW={280}>
_focusVisible={{ outline: "unset" }} <VStack alignItems="flex-start" spacing={2} py={4} px={2}>
ml={-1} <Text color="gray.500" fontSize="xs" fontWeight="bold" pb={1}>
w={224} PROJECTS
boxShadow="0 0 40px 4px rgba(0, 0, 0, 0.1);"
fontSize="sm"
>
<VStack alignItems="flex-start" spacing={1} py={1}>
<Text px={3} py={2}>
{user?.user.email}
</Text> </Text>
<Divider /> <Divider />
<Text alignSelf="flex-start" fontWeight="bold" px={3} pt={2}> <VStack spacing={0} w="full">
Your Projects
</Text>
<VStack spacing={0} w="full" px={1}>
{projects?.map((proj) => ( {projects?.map((proj) => (
<ProjectOption <ProjectOption
key={proj.id} key={proj.id}
@@ -127,38 +105,19 @@ export default function ProjectMenu() {
onClose={popover.onClose} onClose={popover.onClose}
/> />
))} ))}
<HStack
as={Button}
variant="ghost"
colorScheme="blue"
color="blue.400"
fontSize="sm"
justifyContent="flex-start"
onClick={createProject}
w="full"
borderRadius={4}
spacing={0}
>
<Text>Add project</Text>
<Icon as={isLoading ? Spinner : BsPlus} boxSize={4} strokeWidth={0.5} />
</HStack>
</VStack>
<Divider />
<VStack w="full" px={1}>
<ChakraLink
onClick={() => {
signOut().catch(console.error);
}}
_hover={{ bgColor: "gray.200", textDecoration: "none" }}
w="full"
py={2}
px={2}
borderRadius={4}
>
<Text>Sign out</Text>
</ChakraLink>
</VStack> </VStack>
<HStack
as={Button}
variant="ghost"
colorScheme="blue"
color="blue.400"
pr={8}
w="full"
onClick={createProject}
>
<Icon as={isLoading ? Spinner : BsPlus} boxSize={6} />
<Text>New project</Text>
</HStack>
</VStack> </VStack>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -175,27 +134,38 @@ const ProjectOption = ({
isActive: boolean; isActive: boolean;
onClose: () => void; onClose: () => void;
}) => { }) => {
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId); const setselectedProjectId = useAppStore((s) => s.setselectedProjectId);
const [gearHovered, setGearHovered] = useState(false); const [gearHovered, setGearHovered] = useState(false);
return ( return (
<HStack <HStack
as={Link} as={Link}
href="/experiments" href="/experiments"
onClick={() => { onClick={() => {
setSelectedProjectId(proj.id); setselectedProjectId(proj.id);
onClose(); onClose();
}} }}
w="full" w="full"
justifyContent="space-between" justifyContent="space-between"
bgColor={isActive ? "gray.100" : "transparent"}
_hover={gearHovered ? undefined : { bgColor: "gray.200", textDecoration: "none" }} _hover={gearHovered ? undefined : { bgColor: "gray.200", textDecoration: "none" }}
color={isActive ? "blue.400" : undefined} p={2}
py={2}
px={4}
borderRadius={4} borderRadius={4}
spacing={4} spacing={4}
> >
<Text>{proj.name}</Text> <Text>{proj.name}</Text>
<IconButton
as={Link}
href="/project/settings"
aria-label={`Open ${proj.name} settings`}
icon={<Icon as={BsGear} boxSize={5} strokeWidth={0.5} color="gray.500" />}
variant="ghost"
size="xs"
p={0}
onMouseEnter={() => setGearHovered(true)}
onMouseLeave={() => setGearHovered(false)}
_hover={{ bgColor: isActive ? "gray.300" : "gray.100", transitionDelay: 0 }}
borderRadius={4}
/>
</HStack> </HStack>
); );
}; };

View File

@@ -1,30 +0,0 @@
import { Button, HStack, type ButtonProps, Icon, Text } from "@chakra-ui/react";
import { type IconType } from "react-icons";
const ActionButton = ({
icon,
label,
...buttonProps
}: { icon: IconType; label: string } & ButtonProps) => {
return (
<Button
colorScheme="blue"
color="black"
bgColor="white"
borderColor="gray.300"
borderRadius={4}
variant="outline"
size="sm"
fontSize="sm"
fontWeight="normal"
{...buttonProps}
>
<HStack spacing={1}>
{icon && <Icon as={icon} />}
<Text>{label}</Text>
</HStack>
</Button>
);
};
export default ActionButton;

View File

@@ -1,55 +0,0 @@
import { Box, IconButton, useToast } from "@chakra-ui/react";
import { CopyIcon } from "lucide-react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atelierCaveLight } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import stringify from "json-stringify-pretty-compact";
const FormattedJson = ({ json }: { json: any }) => {
const jsonString = stringify(json, { maxLength: 40 });
const toast = useToast();
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({
title: "Copied to clipboard",
status: "success",
duration: 2000,
});
} catch (err) {
toast({
title: "Failed to copy to clipboard",
status: "error",
duration: 2000,
});
}
};
return (
<Box position="relative" fontSize="sm" borderRadius="md" overflow="hidden">
<SyntaxHighlighter
customStyle={{ overflowX: "unset" }}
language="json"
style={atelierCaveLight}
lineProps={{
style: { wordBreak: "break-all", whiteSpace: "pre-wrap" },
}}
wrapLines
>
{jsonString}
</SyntaxHighlighter>
<IconButton
aria-label="Copy"
icon={<CopyIcon />}
position="absolute"
top={1}
right={1}
size="xs"
variant="ghost"
onClick={() => void copyToClipboard(jsonString)}
/>
</Box>
);
};
export { FormattedJson };

View File

@@ -1,16 +0,0 @@
import { type StackProps } from "@chakra-ui/react";
import { useLoggedCalls } from "~/utils/hooks";
import Paginator from "../Paginator";
const LoggedCallsPaginator = (props: StackProps) => {
const { data } = useLoggedCalls();
if (!data) return null;
const { count } = data;
return <Paginator count={count} {...props} />;
};
export default LoggedCallsPaginator;

View File

@@ -1,36 +0,0 @@
import { Card, Table, Tbody } from "@chakra-ui/react";
import { useState } from "react";
import { useLoggedCalls } from "~/utils/hooks";
import { TableHeader, TableRow } from "./TableRow";
export default function LoggedCallsTable() {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const { data: loggedCalls } = useLoggedCalls();
return (
<Card width="100%" overflow="hidden">
<Table>
<TableHeader showCheckbox />
<Tbody>
{loggedCalls?.calls.map((loggedCall) => {
return (
<TableRow
key={loggedCall.id}
loggedCall={loggedCall}
isExpanded={loggedCall.id === expandedRow}
onToggle={() => {
if (loggedCall.id === expandedRow) {
setExpandedRow(null);
} else {
setExpandedRow(loggedCall.id);
}
}}
showCheckbox
/>
);
})}
</Tbody>
</Table>
</Card>
);
}

View File

@@ -1,164 +0,0 @@
import {
Box,
Heading,
Td,
Tr,
Thead,
Th,
Tooltip,
Collapse,
HStack,
VStack,
Button,
ButtonGroup,
Text,
Checkbox,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import Link from "next/link";
import { type RouterOutputs } from "~/utils/api";
import { FormattedJson } from "./FormattedJson";
import { useAppStore } from "~/state/store";
import { useLoggedCalls } from "~/utils/hooks";
import { useMemo } from "react";
dayjs.extend(relativeTime);
type LoggedCall = RouterOutputs["loggedCalls"]["list"]["calls"][0];
export const TableHeader = ({ showCheckbox }: { showCheckbox?: boolean }) => {
const matchingLogIds = useLoggedCalls().data?.matchingLogIds;
const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds);
const addAll = useAppStore((s) => s.selectedLogs.addSelectedLogIds);
const clearAll = useAppStore((s) => s.selectedLogs.clearSelectedLogIds);
const allSelected = useMemo(() => {
if (!matchingLogIds) return false;
return matchingLogIds.every((id) => selectedLogIds.has(id));
}, [selectedLogIds, matchingLogIds]);
return (
<Thead>
<Tr>
{showCheckbox && (
<Th>
<HStack w={8}>
<Checkbox
isChecked={allSelected}
onChange={() => {
allSelected ? clearAll() : addAll(matchingLogIds || []);
}}
/>
<Text>({selectedLogIds.size})</Text>
</HStack>
</Th>
)}
<Th>Time</Th>
<Th>Model</Th>
<Th isNumeric>Duration</Th>
<Th isNumeric>Input tokens</Th>
<Th isNumeric>Output tokens</Th>
<Th isNumeric>Status</Th>
</Tr>
</Thead>
);
};
export const TableRow = ({
loggedCall,
isExpanded,
onToggle,
showCheckbox,
}: {
loggedCall: LoggedCall;
isExpanded: boolean;
onToggle: () => void;
showCheckbox?: boolean;
}) => {
const isError = loggedCall.modelResponse?.statusCode !== 200;
const timeAgo = dayjs(loggedCall.requestedAt).fromNow();
const fullTime = dayjs(loggedCall.requestedAt).toString();
const durationCell = (
<Td isNumeric>
{loggedCall.cacheHit ? (
<Text color="gray.500">Cached</Text>
) : (
((loggedCall.modelResponse?.durationMs ?? 0) / 1000).toFixed(2) + "s"
)}
</Td>
);
const isChecked = useAppStore((s) => s.selectedLogs.selectedLogIds.has(loggedCall.id));
const toggleChecked = useAppStore((s) => s.selectedLogs.toggleSelectedLogId);
return (
<>
<Tr
onClick={onToggle}
key={loggedCall.id}
_hover={{ bgColor: "gray.50", cursor: "pointer" }}
sx={{
"> td": { borderBottom: "none" },
}}
>
{showCheckbox && (
<Td>
<Checkbox isChecked={isChecked} onChange={() => toggleChecked(loggedCall.id)} />
</Td>
)}
<Td>
<Tooltip label={fullTime} placement="top">
<Box whiteSpace="nowrap" minW="120px">
{timeAgo}
</Box>
</Tooltip>
</Td>
<Td width="100%">
<HStack justifyContent="flex-start">
<Text
colorScheme="purple"
color="purple.500"
borderColor="purple.500"
px={1}
borderRadius={4}
borderWidth={1}
fontSize="xs"
>
{loggedCall.model}
</Text>
</HStack>
</Td>
{durationCell}
<Td isNumeric>{loggedCall.modelResponse?.inputTokens}</Td>
<Td isNumeric>{loggedCall.modelResponse?.outputTokens}</Td>
<Td sx={{ color: isError ? "red.500" : "green.500", fontWeight: "semibold" }} isNumeric>
{loggedCall.modelResponse?.statusCode ?? "No response"}
</Td>
</Tr>
<Tr>
<Td colSpan={8} p={0}>
<Collapse in={isExpanded} unmountOnExit={true}>
<VStack p={4} align="stretch">
<HStack align="stretch">
<VStack flex={1} align="stretch">
<Heading size="sm">Input</Heading>
<FormattedJson json={loggedCall.modelResponse?.reqPayload} />
</VStack>
<VStack flex={1} align="stretch">
<Heading size="sm">Output</Heading>
<FormattedJson json={loggedCall.modelResponse?.respPayload} />
</VStack>
</HStack>
<ButtonGroup alignSelf="flex-end">
<Button as={Link} colorScheme="blue" href={{ pathname: "/experiments" }}>
Experiments
</Button>
</ButtonGroup>
</VStack>
</Collapse>
</Td>
</Tr>
</>
);
};

View File

@@ -8,6 +8,8 @@ import { type CompletionResponse } from "../types";
import { isArray, isString, omit } from "lodash-es"; import { isArray, isString, omit } from "lodash-es";
import { openai } from "~/server/utils/openai"; import { openai } from "~/server/utils/openai";
import { APIError } from "openai"; import { APIError } from "openai";
import frontendModelProvider from "./frontend";
import modelProvider, { type SupportedModel } from ".";
const mergeStreamedChunks = ( const mergeStreamedChunks = (
base: ChatCompletion | null, base: ChatCompletion | null,

View File

@@ -8,7 +8,7 @@ import { ChakraThemeProvider } from "~/theme/ChakraThemeProvider";
import { SyncAppStore } from "~/state/sync"; import { SyncAppStore } from "~/state/sync";
import NextAdapterApp from "next-query-params/app"; import NextAdapterApp from "next-query-params/app";
import { QueryParamProvider } from "use-query-params"; import { QueryParamProvider } from "use-query-params";
import { PosthogAppProvider } from "~/utils/analytics/posthog"; import { SessionIdentifier } from "~/utils/analytics/clientAnalytics";
const MyApp: AppType<{ session: Session | null }> = ({ const MyApp: AppType<{ session: Session | null }> = ({
Component, Component,
@@ -34,15 +34,14 @@ const MyApp: AppType<{ session: Session | null }> = ({
<meta name="twitter:image" content="/og.png" /> <meta name="twitter:image" content="/og.png" />
</Head> </Head>
<SessionProvider session={session}> <SessionProvider session={session}>
<PosthogAppProvider> <SyncAppStore />
<SyncAppStore /> <Favicon />
<Favicon /> <SessionIdentifier />
<ChakraThemeProvider> <ChakraThemeProvider>
<QueryParamProvider adapter={NextAdapterApp}> <QueryParamProvider adapter={NextAdapterApp}>
<Component {...pageProps} /> <Component {...pageProps} />
</QueryParamProvider> </QueryParamProvider>
</ChakraThemeProvider> </ChakraThemeProvider>
</PosthogAppProvider>
</SessionProvider> </SessionProvider>
</> </>
); );

View File

@@ -62,7 +62,7 @@ export default function Experiment() {
useEffect(() => { useEffect(() => {
useAppStore.getState().sharedVariantEditor.loadMonaco().catch(console.error); useAppStore.getState().sharedVariantEditor.loadMonaco().catch(console.error);
}, []); });
const [label, setLabel] = useState(experiment.data?.label || ""); const [label, setLabel] = useState(experiment.data?.label || "");
useEffect(() => { useEffect(() => {

View File

@@ -15,16 +15,31 @@ import {
Tr, Tr,
Td, Td,
Divider, Divider,
Breadcrumb,
BreadcrumbItem,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Ban, DollarSign, Hash } from "lucide-react"; import { Ban, DollarSign, Hash } from "lucide-react";
import { useMemo } from "react";
import AppShell from "~/components/nav/AppShell"; import AppShell from "~/components/nav/AppShell";
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
import { useSelectedProject } from "~/utils/hooks"; import { useSelectedProject } from "~/utils/hooks";
import dayjs from "~/utils/dayjs";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import LoggedCallsTable from "~/components/dashboard/LoggedCallsTable"; import LoggedCallTable from "~/components/dashboard/LoggedCallTable";
import UsageGraph from "~/components/dashboard/UsageGraph";
export default function Dashboard() { export default function LoggedCalls() {
const { data: selectedProject } = useSelectedProject(); const { data: selectedProject } = useSelectedProject();
const stats = api.dashboard.stats.useQuery( const stats = api.dashboard.stats.useQuery(
@@ -32,27 +47,79 @@ export default function Dashboard() {
{ enabled: !!selectedProject }, { enabled: !!selectedProject },
); );
const data = useMemo(() => {
return (
stats.data?.periods.map(({ period, numQueries, totalCost }) => ({
period,
Requests: numQueries,
"Total Spent (USD)": parseFloat(totalCost.toString()),
})) || []
);
}, [stats.data]);
return ( return (
<AppShell title="Dashboard" requireAuth> <AppShell requireAuth>
<VStack px={8} py={8} alignItems="flex-start" spacing={4}> <PageHeaderContainer>
<Breadcrumb>
<BreadcrumbItem>
<ProjectBreadcrumbContents />
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<Text>Logged Calls</Text>
</BreadcrumbItem>
</Breadcrumb>
</PageHeaderContainer>
<VStack px={8} pt={4} alignItems="flex-start" spacing={4}>
<Text fontSize="2xl" fontWeight="bold"> <Text fontSize="2xl" fontWeight="bold">
Dashboard {selectedProject?.name}
</Text> </Text>
<Divider /> <Divider />
<VStack margin="auto" spacing={4} align="stretch" w="full"> <VStack margin="auto" spacing={4} align="stretch" w="full">
<HStack gap={4} align="start"> <HStack gap={4} align="start">
<Card flex={1}> <Card variant="outline" flex={1}>
<CardHeader> <CardHeader>
<Heading as="h3" size="sm"> <Heading as="h3" size="sm">
Usage Statistics Usage Statistics
</Heading> </Heading>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<UsageGraph /> <ResponsiveContainer width="100%" height={400}>
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<XAxis
dataKey="period"
tickFormatter={(str: string) => dayjs(str).format("MMM D")}
/>
<YAxis yAxisId="left" dataKey="Requests" orientation="left" stroke="#8884d8" />
<YAxis
yAxisId="right"
dataKey="Total Spent (USD)"
orientation="right"
unit="$"
stroke="#82ca9d"
/>
<Tooltip />
<Legend />
<CartesianGrid stroke="#f5f5f5" />
<Line
dataKey="Requests"
stroke="#8884d8"
yAxisId="left"
dot={false}
strokeWidth={2}
/>
<Line
dataKey="Total Spent (USD)"
stroke="#82ca9d"
yAxisId="right"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</CardBody> </CardBody>
</Card> </Card>
<VStack spacing="4" width="300px" align="stretch"> <VStack spacing="4" width="300px" align="stretch">
<Card> <Card variant="outline">
<CardBody> <CardBody>
<Stat> <Stat>
<HStack> <HStack>
@@ -60,12 +127,12 @@ export default function Dashboard() {
<Icon as={DollarSign} boxSize={4} color="gray.500" /> <Icon as={DollarSign} boxSize={4} color="gray.500" />
</HStack> </HStack>
<StatNumber> <StatNumber>
${parseFloat(stats.data?.totals?.cost?.toString() ?? "0").toFixed(3)} ${parseFloat(stats.data?.totals?.totalCost?.toString() ?? "0").toFixed(2)}
</StatNumber> </StatNumber>
</Stat> </Stat>
</CardBody> </CardBody>
</Card> </Card>
<Card> <Card variant="outline">
<CardBody> <CardBody>
<Stat> <Stat>
<HStack> <HStack>
@@ -80,7 +147,7 @@ export default function Dashboard() {
</Stat> </Stat>
</CardBody> </CardBody>
</Card> </Card>
<Card overflow="hidden"> <Card variant="outline" overflow="hidden">
<Stat> <Stat>
<CardHeader> <CardHeader>
<HStack> <HStack>
@@ -106,7 +173,7 @@ export default function Dashboard() {
</Card> </Card>
</VStack> </VStack>
</HStack> </HStack>
<LoggedCallsTable /> <LoggedCallTable />
</VStack> </VStack>
</VStack> </VStack>
</AppShell> </AppShell>

View File

@@ -38,10 +38,7 @@ export default function Settings() {
id: selectedProject.id, id: selectedProject.id,
updates: { name }, updates: { name },
}); });
await Promise.all([ await Promise.all([utils.projects.get.invalidate({ id: selectedProject.id })]);
utils.projects.get.invalidate({ id: selectedProject.id }),
utils.projects.list.invalidate(),
]);
} }
}, [updateMutation, selectedProject]); }, [updateMutation, selectedProject]);
@@ -65,7 +62,7 @@ export default function Settings() {
</BreadcrumbItem> </BreadcrumbItem>
</Breadcrumb> </Breadcrumb>
</PageHeaderContainer> </PageHeaderContainer>
<VStack px={8} py={4} alignItems="flex-start" spacing={4}> <VStack px={8} pt={4} alignItems="flex-start" spacing={4}>
<VStack spacing={0} alignItems="flex-start"> <VStack spacing={0} alignItems="flex-start">
<Text fontSize="2xl" fontWeight="bold"> <Text fontSize="2xl" fontWeight="bold">
Project Settings Project Settings
@@ -80,7 +77,6 @@ export default function Settings() {
borderWidth={1} borderWidth={1}
borderRadius={4} borderRadius={4}
borderColor="gray.300" borderColor="gray.300"
bgColor="white"
p={6} p={6}
spacing={6} spacing={6}
> >

View File

@@ -1,34 +0,0 @@
import { Text, VStack, Divider, HStack } from "@chakra-ui/react";
import AppShell from "~/components/nav/AppShell";
import LoggedCallTable from "~/components/requestLogs/LoggedCallsTable";
import LoggedCallsPaginator from "~/components/requestLogs/LoggedCallsPaginator";
import ActionButton from "~/components/requestLogs/ActionButton";
import { useAppStore } from "~/state/store";
import { RiFlaskLine } from "react-icons/ri";
export default function LoggedCalls() {
const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds);
return (
<AppShell title="Request Logs" requireAuth>
<VStack px={8} py={8} alignItems="flex-start" spacing={4} w="full">
<Text fontSize="2xl" fontWeight="bold">
Request Logs
</Text>
<Divider />
<HStack w="full" justifyContent="flex-end">
<ActionButton
onClick={() => {
console.log("experimenting with these ids", selectedLogIds);
}}
label="Experiment"
icon={RiFlaskLine}
isDisabled={selectedLogIds.size === 0}
/>
</HStack>
<LoggedCallTable />
<LoggedCallsPaginator />
</VStack>
</AppShell>
);
}

View File

@@ -4,15 +4,11 @@ import parserTypescript from "prettier/plugins/typescript";
// @ts-expect-error for some reason missing from types // @ts-expect-error for some reason missing from types
import parserEstree from "prettier/plugins/estree"; import parserEstree from "prettier/plugins/estree";
// This emits a warning in the browser "Critical dependency: the request of a
// dependency is an expression". Unfortunately doesn't seem to be a way to get
// around it if we want to use Babel client-side for now. One solution would be
// to just do the formatting server-side in a trpc call.
// https://github.com/babel/babel/issues/14301
import * as babel from "@babel/standalone"; import * as babel from "@babel/standalone";
export function stripTypes(tsCode: string): string { export function stripTypes(tsCode: string): string {
const options = { const options = {
presets: ["typescript"],
filename: "file.ts", filename: "file.ts",
}; };

View File

@@ -11,7 +11,6 @@ import { datasetEntries } from "./routers/datasetEntries.router";
import { externalApiRouter } from "./routers/externalApi.router"; import { externalApiRouter } from "./routers/externalApi.router";
import { projectsRouter } from "./routers/projects.router"; import { projectsRouter } from "./routers/projects.router";
import { dashboardRouter } from "./routers/dashboard.router"; import { dashboardRouter } from "./routers/dashboard.router";
import { loggedCallsRouter } from "./routers/loggedCalls.router";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -30,7 +29,6 @@ export const appRouter = createTRPCRouter({
datasetEntries: datasetEntries, datasetEntries: datasetEntries,
projects: projectsRouter, projects: projectsRouter,
dashboard: dashboardRouter, dashboard: dashboardRouter,
loggedCalls: loggedCallsRouter,
externalApi: externalApiRouter, externalApi: externalApiRouter,
}); });

View File

@@ -1,12 +1,11 @@
import { sql } from "kysely"; import { sql } from "kysely";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { kysely } from "~/server/db"; import { kysely, prisma } from "~/server/db";
import { requireCanViewProject } from "~/utils/accessControl";
import dayjs from "~/utils/dayjs"; import dayjs from "~/utils/dayjs";
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
stats: protectedProcedure stats: publicProcedure
.input( .input(
z.object({ z.object({
// TODO: actually take startDate into account // TODO: actually take startDate into account
@@ -14,8 +13,7 @@ export const dashboardRouter = createTRPCRouter({
projectId: z.string(), projectId: z.string(),
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
await requireCanViewProject(input.projectId, ctx);
// Return the stats group by hour // Return the stats group by hour
const periods = await kysely const periods = await kysely
.selectFrom("LoggedCall") .selectFrom("LoggedCall")
@@ -26,9 +24,9 @@ export const dashboardRouter = createTRPCRouter({
) )
.where("projectId", "=", input.projectId) .where("projectId", "=", input.projectId)
.select(({ fn }) => [ .select(({ fn }) => [
sql<Date>`date_trunc('day', "LoggedCallModelResponse"."requestedAt")`.as("period"), sql<Date>`date_trunc('day', "LoggedCallModelResponse"."startTime")`.as("period"),
sql<number>`count("LoggedCall"."id")::int`.as("numQueries"), sql<number>`count("LoggedCall"."id")::int`.as("numQueries"),
fn.sum(fn.coalesce("LoggedCallModelResponse.cost", sql<number>`0`)).as("cost"), fn.sum(fn.coalesce("LoggedCallModelResponse.totalCost", sql<number>`0`)).as("totalCost"),
]) ])
.groupBy("period") .groupBy("period")
.orderBy("period") .orderBy("period")
@@ -59,7 +57,7 @@ export const dashboardRouter = createTRPCRouter({
backfilledPeriods.unshift({ backfilledPeriods.unshift({
period: dayjs(dayToMatch).toDate(), period: dayjs(dayToMatch).toDate(),
numQueries: 0, numQueries: 0,
cost: 0, totalCost: 0,
}); });
} }
dayToMatch = dayToMatch.subtract(1, "day"); dayToMatch = dayToMatch.subtract(1, "day");
@@ -74,7 +72,7 @@ export const dashboardRouter = createTRPCRouter({
) )
.where("projectId", "=", input.projectId) .where("projectId", "=", input.projectId)
.select(({ fn }) => [ .select(({ fn }) => [
fn.sum(fn.coalesce("LoggedCallModelResponse.cost", sql<number>`0`)).as("cost"), fn.sum(fn.coalesce("LoggedCallModelResponse.totalCost", sql<number>`0`)).as("totalCost"),
fn.count("LoggedCall.id").as("numQueries"), fn.count("LoggedCall.id").as("numQueries"),
]) ])
.executeTakeFirst(); .executeTakeFirst();
@@ -87,8 +85,8 @@ export const dashboardRouter = createTRPCRouter({
"LoggedCall.id", "LoggedCall.id",
"LoggedCallModelResponse.originalLoggedCallId", "LoggedCallModelResponse.originalLoggedCallId",
) )
.select(({ fn }) => [fn.count("LoggedCall.id").as("count"), "statusCode as code"]) .select(({ fn }) => [fn.count("LoggedCall.id").as("count"), "respStatus as code"])
.where("statusCode", ">", 200) .where("respStatus", ">", 200)
.groupBy("code") .groupBy("code")
.orderBy("count", "desc") .orderBy("count", "desc")
.execute(); .execute();
@@ -105,4 +103,16 @@ export const dashboardRouter = createTRPCRouter({
return { periods: backfilledPeriods, totals, errors: namedErrors }; return { periods: backfilledPeriods, totals, errors: namedErrors };
}), }),
// TODO useInfiniteQuery
// https://discord.com/channels/966627436387266600/1122258443886153758/1122258443886153758
loggedCalls: publicProcedure.input(z.object({})).query(async ({ input }) => {
const loggedCalls = await prisma.loggedCall.findMany({
orderBy: { startTime: "desc" },
include: { tags: true, modelResponse: true },
take: 20,
});
return loggedCalls;
}),
}); });

View File

@@ -4,21 +4,23 @@ import { prisma } from "~/server/db";
import { requireCanModifyDataset, requireCanViewDataset } from "~/utils/accessControl"; import { requireCanModifyDataset, requireCanViewDataset } from "~/utils/accessControl";
import { autogenerateDatasetEntries } from "../autogenerate/autogenerateDatasetEntries"; import { autogenerateDatasetEntries } from "../autogenerate/autogenerateDatasetEntries";
const PAGE_SIZE = 10;
export const datasetEntries = createTRPCRouter({ export const datasetEntries = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input(z.object({ datasetId: z.string(), page: z.number(), pageSize: z.number() })) .input(z.object({ datasetId: z.string(), page: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await requireCanViewDataset(input.datasetId, ctx); await requireCanViewDataset(input.datasetId, ctx);
const { datasetId, page, pageSize } = input; const { datasetId, page } = input;
const entries = await prisma.datasetEntry.findMany({ const entries = await prisma.datasetEntry.findMany({
where: { where: {
datasetId, datasetId,
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize, skip: (page - 1) * PAGE_SIZE,
take: pageSize, take: PAGE_SIZE,
}); });
const count = await prisma.datasetEntry.count({ const count = await prisma.datasetEntry.count({
@@ -29,6 +31,8 @@ export const datasetEntries = createTRPCRouter({
return { return {
entries, entries,
startIndex: (page - 1) * PAGE_SIZE + 1,
lastPage: Math.ceil(count / PAGE_SIZE),
count, count,
}; };
}), }),

View File

@@ -227,7 +227,7 @@ export const experimentsRouter = createTRPCRouter({
...modelResponseData, ...modelResponseData,
id: newModelResponseId, id: newModelResponseId,
scenarioVariantCellId: newCellId, scenarioVariantCellId: newCellId,
respPayload: (modelResponse.respPayload as Prisma.InputJsonValue) ?? undefined, output: (modelResponse.output as Prisma.InputJsonValue) ?? undefined,
}); });
for (const evaluation of outputEvaluations) { for (const evaluation of outputEvaluations) {
outputEvaluationsToCreate.push({ outputEvaluationsToCreate.push({

View File

@@ -7,11 +7,6 @@ import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db"; import { prisma } from "~/server/db";
import { hashRequest } from "~/server/utils/hashObject"; import { hashRequest } from "~/server/utils/hashObject";
import modelProvider from "~/modelProviders/openai-ChatCompletion";
import {
type ChatCompletion,
type CompletionCreateParams,
} from "openai/resources/chat/completions";
const reqValidator = z.object({ const reqValidator = z.object({
model: z.string(), model: z.string(),
@@ -21,6 +16,11 @@ const reqValidator = z.object({
const respValidator = z.object({ const respValidator = z.object({
id: z.string(), id: z.string(),
model: z.string(), model: z.string(),
usage: z.object({
total_tokens: z.number(),
prompt_tokens: z.number(),
completion_tokens: z.number(),
}),
choices: z.array( choices: z.array(
z.object({ z.object({
finish_reason: z.string(), finish_reason: z.string(),
@@ -35,12 +35,11 @@ export const externalApiRouter = createTRPCRouter({
method: "POST", method: "POST",
path: "/v1/check-cache", path: "/v1/check-cache",
description: "Check if a prompt is cached", description: "Check if a prompt is cached",
protect: true,
}, },
}) })
.input( .input(
z.object({ z.object({
requestedAt: z.number().describe("Unix timestamp in milliseconds"), startTime: z.number().describe("Unix timestamp in milliseconds"),
reqPayload: z.unknown().describe("JSON-encoded request payload"), reqPayload: z.unknown().describe("JSON-encoded request payload"),
tags: z tags: z
.record(z.string()) .record(z.string())
@@ -70,9 +69,15 @@ export const externalApiRouter = createTRPCRouter({
const cacheKey = hashRequest(key.projectId, reqPayload as JsonValue); const cacheKey = hashRequest(key.projectId, reqPayload as JsonValue);
const existingResponse = await prisma.loggedCallModelResponse.findFirst({ const existingResponse = await prisma.loggedCallModelResponse.findFirst({
where: { cacheKey }, where: {
include: { originalLoggedCall: true }, cacheKey,
orderBy: { requestedAt: "desc" }, },
include: {
originalLoggedCall: true,
},
orderBy: {
startTime: "desc",
},
}); });
if (!existingResponse) return { respPayload: null }; if (!existingResponse) return { respPayload: null };
@@ -80,7 +85,7 @@ export const externalApiRouter = createTRPCRouter({
await prisma.loggedCall.create({ await prisma.loggedCall.create({
data: { data: {
projectId: key.projectId, projectId: key.projectId,
requestedAt: new Date(input.requestedAt), startTime: new Date(input.startTime),
cacheHit: true, cacheHit: true,
modelResponseId: existingResponse.id, modelResponseId: existingResponse.id,
}, },
@@ -97,17 +102,16 @@ export const externalApiRouter = createTRPCRouter({
method: "POST", method: "POST",
path: "/v1/report", path: "/v1/report",
description: "Report an API call", description: "Report an API call",
protect: true,
}, },
}) })
.input( .input(
z.object({ z.object({
requestedAt: z.number().describe("Unix timestamp in milliseconds"), startTime: z.number().describe("Unix timestamp in milliseconds"),
receivedAt: z.number().describe("Unix timestamp in milliseconds"), endTime: z.number().describe("Unix timestamp in milliseconds"),
reqPayload: z.unknown().describe("JSON-encoded request payload"), reqPayload: z.unknown().describe("JSON-encoded request payload"),
respPayload: z.unknown().optional().describe("JSON-encoded response payload"), respPayload: z.unknown().optional().describe("JSON-encoded response payload"),
statusCode: z.number().optional().describe("HTTP status code of response"), respStatus: z.number().optional().describe("HTTP status code of response"),
errorMessage: z.string().optional().describe("User-friendly error message"), error: z.string().optional().describe("User-friendly error message"),
tags: z tags: z
.record(z.string()) .record(z.string())
.optional() .optional()
@@ -118,7 +122,6 @@ export const externalApiRouter = createTRPCRouter({
) )
.output(z.void()) .output(z.void())
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
console.log("GOT TAGS", input.tags);
const apiKey = ctx.apiKey; const apiKey = ctx.apiKey;
if (!apiKey) { if (!apiKey) {
throw new TRPCError({ code: "UNAUTHORIZED" }); throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -137,41 +140,35 @@ export const externalApiRouter = createTRPCRouter({
const newLoggedCallId = uuidv4(); const newLoggedCallId = uuidv4();
const newModelResponseId = uuidv4(); const newModelResponseId = uuidv4();
let usage; const usage = respPayload.success ? respPayload.data.usage : undefined;
let model;
if (reqPayload.success && respPayload.success) {
usage = modelProvider.getUsage(
input.reqPayload as CompletionCreateParams,
input.respPayload as ChatCompletion,
);
model = reqPayload.data.model;
}
await prisma.$transaction([ await prisma.$transaction([
prisma.loggedCall.create({ prisma.loggedCall.create({
data: { data: {
id: newLoggedCallId, id: newLoggedCallId,
projectId: key.projectId, projectId: key.projectId,
requestedAt: new Date(input.requestedAt), startTime: new Date(input.startTime),
cacheHit: false, cacheHit: false,
model,
}, },
}), }),
prisma.loggedCallModelResponse.create({ prisma.loggedCallModelResponse.create({
data: { data: {
id: newModelResponseId, id: newModelResponseId,
originalLoggedCallId: newLoggedCallId, originalLoggedCallId: newLoggedCallId,
requestedAt: new Date(input.requestedAt), startTime: new Date(input.startTime),
receivedAt: new Date(input.receivedAt), endTime: new Date(input.endTime),
reqPayload: input.reqPayload as Prisma.InputJsonValue, reqPayload: input.reqPayload as Prisma.InputJsonValue,
respPayload: input.respPayload as Prisma.InputJsonValue, respPayload: input.respPayload as Prisma.InputJsonValue,
statusCode: input.statusCode, respStatus: input.respStatus,
errorMessage: input.errorMessage, error: input.error,
durationMs: input.receivedAt - input.requestedAt, durationMs: input.endTime - input.startTime,
cacheKey: respPayload.success ? requestHash : null, ...(respPayload.success
inputTokens: usage?.inputTokens, ? {
outputTokens: usage?.outputTokens, cacheKey: requestHash,
cost: usage?.cost, inputTokens: usage ? usage.prompt_tokens : undefined,
outputTokens: usage ? usage.completion_tokens : undefined,
}
: null),
}, },
}), }),
// Avoid foreign key constraint error by updating the logged call after the model response is created // Avoid foreign key constraint error by updating the logged call after the model response is created
@@ -185,14 +182,24 @@ export const externalApiRouter = createTRPCRouter({
}), }),
]); ]);
const tagsToCreate = Object.entries(input.tags ?? {}).map(([name, value]) => ({ if (input.tags) {
loggedCallId: newLoggedCallId, const tagsToCreate = Object.entries(input.tags).map(([name, value]) => ({
// sanitize tags loggedCallId: newLoggedCallId,
name: name.replaceAll(/[^a-zA-Z0-9_]/g, "_"), // sanitize tags
value, name: name.replaceAll(/[^a-zA-Z0-9_]/g, "_"),
})); value,
await prisma.loggedCallTag.createMany({ }));
data: tagsToCreate,
}); if (reqPayload.success) {
tagsToCreate.push({
loggedCallId: newLoggedCallId,
name: "$model",
value: reqPayload.data.model,
});
}
await prisma.loggedCallTag.createMany({
data: tagsToCreate,
});
}
}), }),
}); });

View File

@@ -1,33 +0,0 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db";
import { requireCanViewProject } from "~/utils/accessControl";
export const loggedCallsRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({ projectId: z.string(), page: z.number(), pageSize: z.number() }))
.query(async ({ input, ctx }) => {
const { projectId, page, pageSize } = input;
await requireCanViewProject(projectId, ctx);
const calls = await prisma.loggedCall.findMany({
where: { projectId },
orderBy: { requestedAt: "desc" },
include: { tags: true, modelResponse: true },
skip: (page - 1) * pageSize,
take: pageSize,
});
const matchingLogs = await prisma.loggedCall.findMany({
where: { projectId },
select: { id: true },
});
const count = await prisma.loggedCall.count({
where: { projectId },
});
return { calls, count, matchingLogIds: matchingLogs.map((log) => log.id) };
}),
});

View File

@@ -3,7 +3,7 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/
import { prisma } from "~/server/db"; import { prisma } from "~/server/db";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { generateNewCell } from "~/server/utils/generateNewCell"; import { generateNewCell } from "~/server/utils/generateNewCell";
import { error, success } from "~/utils/errorHandling/standardResponses"; import { error, success } from "~/utils/standardResponses";
import { recordExperimentUpdated } from "~/server/utils/recordExperimentUpdated"; import { recordExperimentUpdated } from "~/server/utils/recordExperimentUpdated";
import { reorderPromptVariants } from "~/server/utils/reorderPromptVariants"; import { reorderPromptVariants } from "~/server/utils/reorderPromptVariants";
import { type PromptVariant } from "@prisma/client"; import { type PromptVariant } from "@prisma/client";
@@ -55,7 +55,7 @@ export const promptVariantsRouter = createTRPCRouter({
where: { where: {
modelResponse: { modelResponse: {
outdated: false, outdated: false,
respPayload: { not: Prisma.AnyNull }, output: { not: Prisma.AnyNull },
scenarioVariantCell: { scenarioVariantCell: {
promptVariant: { promptVariant: {
id: input.variantId, id: input.variantId,
@@ -100,7 +100,7 @@ export const promptVariantsRouter = createTRPCRouter({
modelResponses: { modelResponses: {
some: { some: {
outdated: false, outdated: false,
respPayload: { output: {
not: Prisma.AnyNull, not: Prisma.AnyNull,
}, },
}, },
@@ -111,7 +111,7 @@ export const promptVariantsRouter = createTRPCRouter({
const overallTokens = await prisma.modelResponse.aggregate({ const overallTokens = await prisma.modelResponse.aggregate({
where: { where: {
outdated: false, outdated: false,
respPayload: { output: {
not: Prisma.AnyNull, not: Prisma.AnyNull,
}, },
scenarioVariantCell: { scenarioVariantCell: {

View File

@@ -3,7 +3,7 @@ import { sql } from "kysely";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import { kysely, prisma } from "~/server/db"; import { kysely, prisma } from "~/server/db";
import { error, success } from "~/utils/errorHandling/standardResponses"; import { error, success } from "~/utils/standardResponses";
import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl"; import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl";
export const scenarioVarsRouter = createTRPCRouter({ export const scenarioVarsRouter = createTRPCRouter({

View File

@@ -7,13 +7,15 @@ import { runAllEvals } from "~/server/utils/evaluations";
import { generateNewCell } from "~/server/utils/generateNewCell"; import { generateNewCell } from "~/server/utils/generateNewCell";
import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl"; import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl";
const PAGE_SIZE = 10;
export const scenariosRouter = createTRPCRouter({ export const scenariosRouter = createTRPCRouter({
list: publicProcedure list: publicProcedure
.input(z.object({ experimentId: z.string(), page: z.number(), pageSize: z.number() })) .input(z.object({ experimentId: z.string(), page: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await requireCanViewExperiment(input.experimentId, ctx); await requireCanViewExperiment(input.experimentId, ctx);
const { experimentId, page, pageSize } = input; const { experimentId, page } = input;
const scenarios = await prisma.testScenario.findMany({ const scenarios = await prisma.testScenario.findMany({
where: { where: {
@@ -21,8 +23,8 @@ export const scenariosRouter = createTRPCRouter({
visible: true, visible: true,
}, },
orderBy: { sortIndex: "asc" }, orderBy: { sortIndex: "asc" },
skip: (page - 1) * pageSize, skip: (page - 1) * PAGE_SIZE,
take: pageSize, take: PAGE_SIZE,
}); });
const count = await prisma.testScenario.count({ const count = await prisma.testScenario.count({
@@ -34,6 +36,8 @@ export const scenariosRouter = createTRPCRouter({
return { return {
scenarios, scenarios,
startIndex: (page - 1) * PAGE_SIZE + 1,
lastPage: Math.ceil(count / PAGE_SIZE),
count, count,
}; };
}), }),

View File

@@ -64,7 +64,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
// Get the session from the server using the getServerSession wrapper function // Get the session from the server using the getServerSession wrapper function
const session = await getServerAuthSession({ req, res }); const session = await getServerAuthSession({ req, res });
const apiKey = req.headers.authorization?.split(" ")[1] as string | null; const apiKey = req.headers["x-openpipe-api-key"] as string | null;
return createInnerTRPCContext({ return createInnerTRPCContext({
session, session,

View File

@@ -4,18 +4,19 @@ import fs from "fs";
import path from "path"; import path from "path";
import { execSync } from "child_process"; import { execSync } from "child_process";
console.log("Exporting public OpenAPI schema to client-libs/schema.json");
const scriptPath = import.meta.url.replace("file://", ""); const scriptPath = import.meta.url.replace("file://", "");
const clientLibsPath = path.join(path.dirname(scriptPath), "../../../../client-libs"); const clientLibsPath = path.join(path.dirname(scriptPath), "../../../../client-libs");
const schemaPath = path.join(clientLibsPath, "openapi.json"); const schemaPath = path.join(clientLibsPath, "schema.json");
console.log(`Exporting public OpenAPI schema to ${schemaPath}`);
console.log("Exporting schema");
fs.writeFileSync(schemaPath, JSON.stringify(openApiDocument, null, 2), "utf-8"); fs.writeFileSync(schemaPath, JSON.stringify(openApiDocument, null, 2), "utf-8");
console.log("Generating TypeScript client"); console.log("Generating Typescript client");
const tsClientPath = path.join(clientLibsPath, "typescript/src/codegen"); const tsClientPath = path.join(clientLibsPath, "typescript/codegen");
fs.rmSync(tsClientPath, { recursive: true, force: true }); fs.rmSync(tsClientPath, { recursive: true, force: true });
@@ -26,8 +27,6 @@ execSync(
}, },
); );
console.log("Generating Python client");
execSync(path.join(clientLibsPath, "python/codegen.sh"));
console.log("Done!"); console.log("Done!");
process.exit(0);

View File

@@ -99,11 +99,11 @@ export const queryModel = defineTask<QueryModelJob>("queryModel", async (task) =
} }
: null; : null;
const cacheKey = hashObject(prompt as JsonValue); const inputHash = hashObject(prompt as JsonValue);
let modelResponse = await prisma.modelResponse.create({ let modelResponse = await prisma.modelResponse.create({
data: { data: {
cacheKey, inputHash,
scenarioVariantCellId: cellId, scenarioVariantCellId: cellId,
requestedAt: new Date(), requestedAt: new Date(),
}, },
@@ -114,7 +114,7 @@ export const queryModel = defineTask<QueryModelJob>("queryModel", async (task) =
modelResponse = await prisma.modelResponse.update({ modelResponse = await prisma.modelResponse.update({
where: { id: modelResponse.id }, where: { id: modelResponse.id },
data: { data: {
respPayload: response.value as Prisma.InputJsonObject, output: response.value as Prisma.InputJsonObject,
statusCode: response.statusCode, statusCode: response.statusCode,
receivedAt: new Date(), receivedAt: new Date(),
inputTokens: usage?.inputTokens, inputTokens: usage?.inputTokens,

View File

@@ -51,7 +51,7 @@ export const runAllEvals = async (experimentId: string) => {
const outputs = await prisma.modelResponse.findMany({ const outputs = await prisma.modelResponse.findMany({
where: { where: {
outdated: false, outdated: false,
respPayload: { output: {
not: Prisma.AnyNull, not: Prisma.AnyNull,
}, },
scenarioVariantCell: { scenarioVariantCell: {

View File

@@ -57,7 +57,7 @@ export const generateNewCell = async (
return; return;
} }
const cacheKey = hashObject(parsedConstructFn); const inputHash = hashObject(parsedConstructFn);
cell = await prisma.scenarioVariantCell.create({ cell = await prisma.scenarioVariantCell.create({
data: { data: {
@@ -73,8 +73,8 @@ export const generateNewCell = async (
const matchingModelResponse = await prisma.modelResponse.findFirst({ const matchingModelResponse = await prisma.modelResponse.findFirst({
where: { where: {
cacheKey, inputHash,
respPayload: { output: {
not: Prisma.AnyNull, not: Prisma.AnyNull,
}, },
}, },
@@ -92,7 +92,7 @@ export const generateNewCell = async (
data: { data: {
...omit(matchingModelResponse, ["id", "scenarioVariantCell"]), ...omit(matchingModelResponse, ["id", "scenarioVariantCell"]),
scenarioVariantCellId: cell.id, scenarioVariantCellId: cell.id,
respPayload: matchingModelResponse.respPayload as Prisma.InputJsonValue, output: matchingModelResponse.output as Prisma.InputJsonValue,
}, },
}); });

View File

@@ -1,24 +1,13 @@
import { type ClientOptions } from "openai";
import fs from "fs";
import path from "path";
import OpenAI from "openpipe/src/openai";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
let config: ClientOptions; import { default as OriginalOpenAI } from "openai";
// import { OpenAI } from "openpipe";
try { const openAIConfig = { apiKey: env.OPENAI_API_KEY ?? "dummy-key" };
// Allow developers to override the config with a local file
const jsonData = fs.readFileSync(
path.join(path.dirname(import.meta.url).replace("file://", ""), "./openaiCustomConfig.json"),
"utf8",
);
config = JSON.parse(jsonData.toString());
} catch (error) {
// Set a dummy key so it doesn't fail at build time
config = { apiKey: env.OPENAI_API_KEY ?? "dummy-key" };
}
// export const openai = env.OPENPIPE_API_KEY ? new OpenAI.OpenAI(config) : new OriginalOpenAI(config); // Set a dummy key so it doesn't fail at build time
// export const openai = env.OPENPIPE_API_KEY
// ? new OpenAI.OpenAI(openAIConfig)
// : new OriginalOpenAI(openAIConfig);
export const openai = new OpenAI(config); export const openai = new OriginalOpenAI(openAIConfig);

View File

@@ -71,7 +71,7 @@ export const runOneEval = async (
provider: SupportedProvider, provider: SupportedProvider,
): Promise<{ result: number; details?: string }> => { ): Promise<{ result: number; details?: string }> => {
const modelProvider = modelProviders[provider]; const modelProvider = modelProviders[provider];
const message = modelProvider.normalizeOutput(modelResponse.respPayload); const message = modelProvider.normalizeOutput(modelResponse.output);
if (!message) return { result: 0 }; if (!message) return { result: 0 };

View File

@@ -1,30 +0,0 @@
import { type SliceCreator } from "./store";
export const editorBackground = "#fafafa";
export type SelectedLogsSlice = {
selectedLogIds: Set<string>;
toggleSelectedLogId: (id: string) => void;
addSelectedLogIds: (ids: string[]) => void;
clearSelectedLogIds: () => void;
};
export const createSelectedLogsSlice: SliceCreator<SelectedLogsSlice> = (set, get) => ({
selectedLogIds: new Set(),
toggleSelectedLogId: (id: string) =>
set((state) => {
if (state.selectedLogs.selectedLogIds.has(id)) {
state.selectedLogs.selectedLogIds.delete(id);
} else {
state.selectedLogs.selectedLogIds.add(id);
}
}),
addSelectedLogIds: (ids: string[]) =>
set((state) => {
state.selectedLogs.selectedLogIds = new Set([...state.selectedLogs.selectedLogIds, ...ids]);
}),
clearSelectedLogIds: () =>
set((state) => {
state.selectedLogs.selectedLogIds = new Set();
}),
});

View File

@@ -81,6 +81,8 @@ export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> =
}; };
`; `;
console.log(modelContents);
const scenariosModel = monaco.editor.getModel(monaco.Uri.parse("file:///scenarios.ts")); const scenariosModel = monaco.editor.getModel(monaco.Uri.parse("file:///scenarios.ts"));
if (scenariosModel) { if (scenariosModel) {

View File

@@ -1,6 +1,5 @@
import { type StateCreator, create } from "zustand"; import { type StateCreator, create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
import { enableMapSet } from "immer";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { createSelectors } from "./createSelectors"; import { createSelectors } from "./createSelectors";
import { import {
@@ -9,9 +8,6 @@ import {
} from "./sharedVariantEditor.slice"; } from "./sharedVariantEditor.slice";
import { type APIClient } from "~/utils/api"; import { type APIClient } from "~/utils/api";
import { persistOptions, type stateToPersist } from "./persist"; import { persistOptions, type stateToPersist } from "./persist";
import { type SelectedLogsSlice, createSelectedLogsSlice } from "./selectedLogsSlice";
enableMapSet();
export type State = { export type State = {
drawerOpen: boolean; drawerOpen: boolean;
@@ -21,8 +17,7 @@ export type State = {
setApi: (api: APIClient) => void; setApi: (api: APIClient) => void;
sharedVariantEditor: SharedVariantEditorSlice; sharedVariantEditor: SharedVariantEditorSlice;
selectedProjectId: string | null; selectedProjectId: string | null;
setSelectedProjectId: (id: string) => void; setselectedProjectId: (id: string) => void;
selectedLogs: SelectedLogsSlice;
}; };
export type SliceCreator<T> = StateCreator<State, [["zustand/immer", never]], [], T>; export type SliceCreator<T> = StateCreator<State, [["zustand/immer", never]], [], T>;
@@ -53,11 +48,10 @@ const useBaseStore = create<
}), }),
sharedVariantEditor: createVariantEditorSlice(set, get, ...rest), sharedVariantEditor: createVariantEditorSlice(set, get, ...rest),
selectedProjectId: null, selectedProjectId: null,
setSelectedProjectId: (id: string) => setselectedProjectId: (id: string) =>
set((state) => { set((state) => {
state.selectedProjectId = id; state.selectedProjectId = id;
}), }),
selectedLogs: createSelectedLogsSlice(set, get, ...rest),
})), })),
persistOptions, persistOptions,
), ),

View File

@@ -0,0 +1,31 @@
import { type Session } from "next-auth";
import { useSession } from "next-auth/react";
import { useEffect } from "react";
import posthog from "posthog-js";
import { env } from "~/env.mjs";
// Make sure we're in the browser
const enableBrowserAnalytics = typeof window !== "undefined";
if (env.NEXT_PUBLIC_POSTHOG_KEY && enableBrowserAnalytics) {
posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: `${env.NEXT_PUBLIC_HOST}/ingest`,
});
}
export const identifySession = (session: Session) => {
if (!session.user) return;
posthog.identify(session.user.id, {
name: session.user.name,
email: session.user.email,
});
};
export const SessionIdentifier = () => {
const session = useSession().data;
useEffect(() => {
if (session && enableBrowserAnalytics) identifySession(session);
}, [session]);
return null;
};

View File

@@ -1,41 +0,0 @@
"use client";
import { useSession } from "next-auth/react";
import React, { type ReactNode, useEffect } from "react";
import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js";
import { env } from "~/env.mjs";
import { useRouter } from "next/router";
// Make sure we're in the browser
const inBrowser = typeof window !== "undefined";
export const PosthogAppProvider = ({ children }: { children: ReactNode }) => {
const session = useSession().data;
const router = useRouter();
useEffect(() => {
// Track page views
const handleRouteChange = () => posthog?.capture("$pageview");
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}, [router.events]);
useEffect(() => {
if (env.NEXT_PUBLIC_POSTHOG_KEY && inBrowser && session && session.user) {
posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: `${env.NEXT_PUBLIC_HOST}/ingest`,
});
posthog.identify(session.user.id, {
name: session.user.name,
email: session.user.email,
});
}
}, [session]);
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
};

View File

@@ -1,11 +0,0 @@
export function error(message: string): { status: "error"; message: string } {
return {
status: "error",
message,
};
}
export function success<T>(payload: T): { status: "success"; payload: T };
export function success(payload?: undefined): { status: "success"; payload: undefined };
export function success<T>(payload?: T) {
return { status: "success", payload };
}

View File

@@ -1,7 +1,7 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { NumberParam, useQueryParams } from "use-query-params"; import { NumberParam, useQueryParam, withDefault } from "use-query-params";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
export const useExperiments = () => { export const useExperiments = () => {
@@ -46,10 +46,10 @@ export const useDataset = () => {
export const useDatasetEntries = () => { export const useDatasetEntries = () => {
const dataset = useDataset(); const dataset = useDataset();
const { page, pageSize } = usePageParams(); const [page] = usePage();
return api.datasetEntries.list.useQuery( return api.datasetEntries.list.useQuery(
{ datasetId: dataset.data?.id ?? "", page, pageSize }, { datasetId: dataset.data?.id ?? "", page },
{ enabled: dataset.data?.id != null }, { enabled: dataset.data?.id != null },
); );
}; };
@@ -132,23 +132,14 @@ export const useElementDimensions = (): [RefObject<HTMLElement>, Dimensions | un
return [ref, dimensions]; return [ref, dimensions];
}; };
export const usePageParams = () => { export const usePage = () => useQueryParam("page", withDefault(NumberParam, 1));
const [pageParams, setPageParams] = useQueryParams({
page: NumberParam,
pageSize: NumberParam,
});
const { page, pageSize } = pageParams;
return { page: page || 1, pageSize: pageSize || 10, setPageParams };
};
export const useScenarios = () => { export const useScenarios = () => {
const experiment = useExperiment(); const experiment = useExperiment();
const { page, pageSize } = usePageParams(); const [page] = usePage();
return api.scenarios.list.useQuery( return api.scenarios.list.useQuery(
{ experimentId: experiment.data?.id ?? "", page, pageSize }, { experimentId: experiment.data?.id ?? "", page },
{ enabled: experiment.data?.id != null }, { enabled: experiment.data?.id != null },
); );
}; };
@@ -175,13 +166,3 @@ export const useScenarioVars = () => {
{ enabled: experiment.data?.id != null }, { enabled: experiment.data?.id != null },
); );
}; };
export const useLoggedCalls = () => {
const selectedProjectId = useAppStore((state) => state.selectedProjectId);
const { page, pageSize } = usePageParams();
return api.loggedCalls.list.useQuery(
{ projectId: selectedProjectId ?? "", page, pageSize },
{ enabled: !!selectedProjectId },
);
};

View File

@@ -1,5 +1,16 @@
import { toast } from "~/theme/ChakraThemeProvider"; import { toast } from "~/theme/ChakraThemeProvider";
import { type error, type success } from "./standardResponses";
export function error(message: string): { status: "error"; message: string } {
return {
status: "error",
message,
};
}
export function success<T>(payload: T): { status: "success"; payload: T };
export function success(payload?: undefined): { status: "success"; payload: undefined };
export function success<T>(payload?: T) {
return { status: "success", payload };
}
type SuccessType<T> = ReturnType<typeof success<T>>; type SuccessType<T> = ReturnType<typeof success<T>>;
type ErrorType = ReturnType<typeof error>; type ErrorType = ReturnType<typeof error>;

View File

@@ -1,9 +0,0 @@
#! /bin/bash
set -e
cd "$(dirname "$0")/.."
source app/.env
docker build . --file app/Dockerfile

View File

@@ -25,11 +25,11 @@
".eslintrc.cjs", ".eslintrc.cjs",
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.mts",
"**/*.tsx", "**/*.tsx",
"**/*.cjs", "**/*.cjs",
"**/*.mjs", "**/*.mjs",
"**/*.js" "**/*.js",
"src/pages/api/sentry-example-api.js"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +0,0 @@
#! /bin/bash
set -e
cd "$(dirname "$0")"
poetry run openapi-python-client generate --path ../openapi.json
rm -rf openpipe/api_client
mv open-pipe-api-client/open_pipe_api_client openpipe/api_client
rm -rf open-pipe-api-client

View File

@@ -1,10 +0,0 @@
from .openai import OpenAIWrapper
from .shared import configured_client
openai = OpenAIWrapper()
def configure_openpipe(base_url=None, api_key=None):
if base_url is not None:
configured_client._base_url = base_url
if api_key is not None:
configured_client.token = api_key

View File

@@ -1,7 +0,0 @@
""" A client library for accessing OpenPipe API """
from .client import AuthenticatedClient, Client
__all__ = (
"AuthenticatedClient",
"Client",
)

View File

@@ -1 +0,0 @@
""" Contains methods for accessing the API """

View File

@@ -1,155 +0,0 @@
from http import HTTPStatus
from typing import Any, Dict, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.external_api_check_cache_json_body import ExternalApiCheckCacheJsonBody
from ...models.external_api_check_cache_response_200 import ExternalApiCheckCacheResponse200
from ...types import Response
def _get_kwargs(
*,
json_body: ExternalApiCheckCacheJsonBody,
) -> Dict[str, Any]:
pass
json_json_body = json_body.to_dict()
return {
"method": "post",
"url": "/v1/check-cache",
"json": json_json_body,
}
def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[ExternalApiCheckCacheResponse200]:
if response.status_code == HTTPStatus.OK:
response_200 = ExternalApiCheckCacheResponse200.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Response[ExternalApiCheckCacheResponse200]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Response[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[ExternalApiCheckCacheResponse200]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Optional[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
ExternalApiCheckCacheResponse200
"""
return sync_detailed(
client=client,
json_body=json_body,
).parsed
async def asyncio_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Response[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[ExternalApiCheckCacheResponse200]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
*,
client: AuthenticatedClient,
json_body: ExternalApiCheckCacheJsonBody,
) -> Optional[ExternalApiCheckCacheResponse200]:
"""Check if a prompt is cached
Args:
json_body (ExternalApiCheckCacheJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
ExternalApiCheckCacheResponse200
"""
return (
await asyncio_detailed(
client=client,
json_body=json_body,
)
).parsed

View File

@@ -1,98 +0,0 @@
from http import HTTPStatus
from typing import Any, Dict, Optional, Union
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.external_api_report_json_body import ExternalApiReportJsonBody
from ...types import Response
def _get_kwargs(
*,
json_body: ExternalApiReportJsonBody,
) -> Dict[str, Any]:
pass
json_json_body = json_body.to_dict()
return {
"method": "post",
"url": "/v1/report",
"json": json_json_body,
}
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
if response.status_code == HTTPStatus.OK:
return None
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiReportJsonBody,
) -> Response[Any]:
"""Report an API call
Args:
json_body (ExternalApiReportJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Any]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
async def asyncio_detailed(
*,
client: AuthenticatedClient,
json_body: ExternalApiReportJsonBody,
) -> Response[Any]:
"""Report an API call
Args:
json_body (ExternalApiReportJsonBody):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Any]
"""
kwargs = _get_kwargs(
json_body=json_body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)

View File

@@ -1,268 +0,0 @@
import ssl
from typing import Any, Dict, Optional, Union
import httpx
from attrs import define, evolve, field
@define
class Client:
"""A class for keeping track of data related to the API
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str
_cookies: Dict[str, str] = field(factory=dict, kw_only=True)
_headers: Dict[str, str] = field(factory=dict, kw_only=True)
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True)
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True)
_follow_redirects: bool = field(default=False, kw_only=True)
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True)
_client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
def with_headers(self, headers: Dict[str, str]) -> "Client":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: Dict[str, str]) -> "Client":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
"""Get a new client matching this one with a new timeout (in seconds)"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "Client":
"""Manually the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "Client":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
"""Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "Client":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
@define
class AuthenticatedClient:
"""A Client which has been authenticated for use on secured endpoints
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
token: The token to use for authentication
prefix: The prefix to use for the Authorization header
auth_header_name: The name of the Authorization header
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str
_cookies: Dict[str, str] = field(factory=dict, kw_only=True)
_headers: Dict[str, str] = field(factory=dict, kw_only=True)
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True)
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True)
_follow_redirects: bool = field(default=False, kw_only=True)
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True)
_client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
token: str
prefix: str = "Bearer"
auth_header_name: str = "Authorization"
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: Dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
"""Get a new client matching this one with a new timeout (in seconds)"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
"""Manually the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "AuthenticatedClient":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
"""Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "AuthenticatedClient":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)

View File

@@ -1,14 +0,0 @@
""" Contains shared errors types that can be raised from API functions """
class UnexpectedStatus(Exception):
"""Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True"""
def __init__(self, status_code: int, content: bytes):
self.status_code = status_code
self.content = content
super().__init__(f"Unexpected status code: {status_code}")
__all__ = ["UnexpectedStatus"]

View File

@@ -1,15 +0,0 @@
""" Contains all the data models used in inputs/outputs """
from .external_api_check_cache_json_body import ExternalApiCheckCacheJsonBody
from .external_api_check_cache_json_body_tags import ExternalApiCheckCacheJsonBodyTags
from .external_api_check_cache_response_200 import ExternalApiCheckCacheResponse200
from .external_api_report_json_body import ExternalApiReportJsonBody
from .external_api_report_json_body_tags import ExternalApiReportJsonBodyTags
__all__ = (
"ExternalApiCheckCacheJsonBody",
"ExternalApiCheckCacheJsonBodyTags",
"ExternalApiCheckCacheResponse200",
"ExternalApiReportJsonBody",
"ExternalApiReportJsonBodyTags",
)

View File

@@ -1,70 +0,0 @@
from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar, Union
from attrs import define
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.external_api_check_cache_json_body_tags import ExternalApiCheckCacheJsonBodyTags
T = TypeVar("T", bound="ExternalApiCheckCacheJsonBody")
@define
class ExternalApiCheckCacheJsonBody:
"""
Attributes:
requested_at (float): Unix timestamp in milliseconds
req_payload (Union[Unset, Any]): JSON-encoded request payload
tags (Union[Unset, ExternalApiCheckCacheJsonBodyTags]): Extra tags to attach to the call for filtering. Eg {
"userId": "123", "promptId": "populate-title" }
"""
requested_at: float
req_payload: Union[Unset, Any] = UNSET
tags: Union[Unset, "ExternalApiCheckCacheJsonBodyTags"] = UNSET
def to_dict(self) -> Dict[str, Any]:
requested_at = self.requested_at
req_payload = self.req_payload
tags: Union[Unset, Dict[str, Any]] = UNSET
if not isinstance(self.tags, Unset):
tags = self.tags.to_dict()
field_dict: Dict[str, Any] = {}
field_dict.update(
{
"requestedAt": requested_at,
}
)
if req_payload is not UNSET:
field_dict["reqPayload"] = req_payload
if tags is not UNSET:
field_dict["tags"] = tags
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
from ..models.external_api_check_cache_json_body_tags import ExternalApiCheckCacheJsonBodyTags
d = src_dict.copy()
requested_at = d.pop("requestedAt")
req_payload = d.pop("reqPayload", UNSET)
_tags = d.pop("tags", UNSET)
tags: Union[Unset, ExternalApiCheckCacheJsonBodyTags]
if isinstance(_tags, Unset):
tags = UNSET
else:
tags = ExternalApiCheckCacheJsonBodyTags.from_dict(_tags)
external_api_check_cache_json_body = cls(
requested_at=requested_at,
req_payload=req_payload,
tags=tags,
)
return external_api_check_cache_json_body

View File

@@ -1,43 +0,0 @@
from typing import Any, Dict, List, Type, TypeVar
from attrs import define, field
T = TypeVar("T", bound="ExternalApiCheckCacheJsonBodyTags")
@define
class ExternalApiCheckCacheJsonBodyTags:
"""Extra tags to attach to the call for filtering. Eg { "userId": "123", "promptId": "populate-title" }"""
additional_properties: Dict[str, str] = field(init=False, factory=dict)
def to_dict(self) -> Dict[str, Any]:
field_dict: Dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update({})
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
external_api_check_cache_json_body_tags = cls()
external_api_check_cache_json_body_tags.additional_properties = d
return external_api_check_cache_json_body_tags
@property
def additional_keys(self) -> List[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> str:
return self.additional_properties[key]
def __setitem__(self, key: str, value: str) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View File

@@ -1,38 +0,0 @@
from typing import Any, Dict, Type, TypeVar, Union
from attrs import define
from ..types import UNSET, Unset
T = TypeVar("T", bound="ExternalApiCheckCacheResponse200")
@define
class ExternalApiCheckCacheResponse200:
"""
Attributes:
resp_payload (Union[Unset, Any]): JSON-encoded response payload
"""
resp_payload: Union[Unset, Any] = UNSET
def to_dict(self) -> Dict[str, Any]:
resp_payload = self.resp_payload
field_dict: Dict[str, Any] = {}
field_dict.update({})
if resp_payload is not UNSET:
field_dict["respPayload"] = resp_payload
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
resp_payload = d.pop("respPayload", UNSET)
external_api_check_cache_response_200 = cls(
resp_payload=resp_payload,
)
return external_api_check_cache_response_200

View File

@@ -1,101 +0,0 @@
from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar, Union
from attrs import define
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.external_api_report_json_body_tags import ExternalApiReportJsonBodyTags
T = TypeVar("T", bound="ExternalApiReportJsonBody")
@define
class ExternalApiReportJsonBody:
"""
Attributes:
requested_at (float): Unix timestamp in milliseconds
received_at (float): Unix timestamp in milliseconds
req_payload (Union[Unset, Any]): JSON-encoded request payload
resp_payload (Union[Unset, Any]): JSON-encoded response payload
status_code (Union[Unset, float]): HTTP status code of response
error_message (Union[Unset, str]): User-friendly error message
tags (Union[Unset, ExternalApiReportJsonBodyTags]): Extra tags to attach to the call for filtering. Eg {
"userId": "123", "promptId": "populate-title" }
"""
requested_at: float
received_at: float
req_payload: Union[Unset, Any] = UNSET
resp_payload: Union[Unset, Any] = UNSET
status_code: Union[Unset, float] = UNSET
error_message: Union[Unset, str] = UNSET
tags: Union[Unset, "ExternalApiReportJsonBodyTags"] = UNSET
def to_dict(self) -> Dict[str, Any]:
requested_at = self.requested_at
received_at = self.received_at
req_payload = self.req_payload
resp_payload = self.resp_payload
status_code = self.status_code
error_message = self.error_message
tags: Union[Unset, Dict[str, Any]] = UNSET
if not isinstance(self.tags, Unset):
tags = self.tags.to_dict()
field_dict: Dict[str, Any] = {}
field_dict.update(
{
"requestedAt": requested_at,
"receivedAt": received_at,
}
)
if req_payload is not UNSET:
field_dict["reqPayload"] = req_payload
if resp_payload is not UNSET:
field_dict["respPayload"] = resp_payload
if status_code is not UNSET:
field_dict["statusCode"] = status_code
if error_message is not UNSET:
field_dict["errorMessage"] = error_message
if tags is not UNSET:
field_dict["tags"] = tags
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
from ..models.external_api_report_json_body_tags import ExternalApiReportJsonBodyTags
d = src_dict.copy()
requested_at = d.pop("requestedAt")
received_at = d.pop("receivedAt")
req_payload = d.pop("reqPayload", UNSET)
resp_payload = d.pop("respPayload", UNSET)
status_code = d.pop("statusCode", UNSET)
error_message = d.pop("errorMessage", UNSET)
_tags = d.pop("tags", UNSET)
tags: Union[Unset, ExternalApiReportJsonBodyTags]
if isinstance(_tags, Unset):
tags = UNSET
else:
tags = ExternalApiReportJsonBodyTags.from_dict(_tags)
external_api_report_json_body = cls(
requested_at=requested_at,
received_at=received_at,
req_payload=req_payload,
resp_payload=resp_payload,
status_code=status_code,
error_message=error_message,
tags=tags,
)
return external_api_report_json_body

View File

@@ -1,43 +0,0 @@
from typing import Any, Dict, List, Type, TypeVar
from attrs import define, field
T = TypeVar("T", bound="ExternalApiReportJsonBodyTags")
@define
class ExternalApiReportJsonBodyTags:
"""Extra tags to attach to the call for filtering. Eg { "userId": "123", "promptId": "populate-title" }"""
additional_properties: Dict[str, str] = field(init=False, factory=dict)
def to_dict(self) -> Dict[str, Any]:
field_dict: Dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update({})
return field_dict
@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
external_api_report_json_body_tags = cls()
external_api_report_json_body_tags.additional_properties = d
return external_api_report_json_body_tags
@property
def additional_keys(self) -> List[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> str:
return self.additional_properties[key]
def __setitem__(self, key: str, value: str) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View File

@@ -1 +0,0 @@
# Marker file for PEP 561

View File

@@ -1,44 +0,0 @@
""" Contains some shared types for properties """
from http import HTTPStatus
from typing import BinaryIO, Generic, Literal, MutableMapping, Optional, Tuple, TypeVar
from attrs import define
class Unset:
def __bool__(self) -> Literal[False]:
return False
UNSET: Unset = Unset()
FileJsonType = Tuple[Optional[str], BinaryIO, Optional[str]]
@define
class File:
"""Contains information for file uploads"""
payload: BinaryIO
file_name: Optional[str] = None
mime_type: Optional[str] = None
def to_tuple(self) -> FileJsonType:
"""Return a tuple representation that httpx will accept for multipart/form-data"""
return self.file_name, self.payload, self.mime_type
T = TypeVar("T")
@define
class Response(Generic[T]):
"""A response from an endpoint"""
status_code: HTTPStatus
content: bytes
headers: MutableMapping[str, str]
parsed: Optional[T]
__all__ = ["File", "Response", "FileJsonType"]

View File

@@ -1,42 +0,0 @@
from typing import Any, Optional
def merge_streamed_chunks(base: Optional[Any], chunk: Any) -> Any:
if base is None:
return merge_streamed_chunks({**chunk, "choices": []}, chunk)
choices = base["choices"].copy()
for choice in chunk["choices"]:
base_choice = next((c for c in choices if c["index"] == choice["index"]), None)
if base_choice:
base_choice["finish_reason"] = (
choice.get("finish_reason") or base_choice["finish_reason"]
)
base_choice["message"] = base_choice.get("message") or {"role": "assistant"}
if choice.get("delta") and choice["delta"].get("content"):
base_choice["message"]["content"] = (
base_choice["message"].get("content") or ""
) + (choice["delta"].get("content") or "")
if choice.get("delta") and choice["delta"].get("function_call"):
fn_call = base_choice["message"].get("function_call") or {}
fn_call["name"] = (fn_call.get("name") or "") + (
choice["delta"]["function_call"].get("name") or ""
)
fn_call["arguments"] = (fn_call.get("arguments") or "") + (
choice["delta"]["function_call"].get("arguments") or ""
)
else:
# Here, we'll have to handle the omitted property "delta" manually
new_choice = {k: v for k, v in choice.items() if k != "delta"}
choices.append(
{**new_choice, "message": {"role": "assistant", **choice["delta"]}}
)
merged = {
**base,
"choices": choices,
}
return merged

View File

@@ -1,168 +0,0 @@
import openai as original_openai
from openai.openai_object import OpenAIObject
import time
import inspect
from openpipe.merge_openai_chunks import merge_streamed_chunks
from .shared import maybe_check_cache, maybe_check_cache_async, report_async, report
class WrappedChatCompletion(original_openai.ChatCompletion):
@classmethod
def create(cls, *args, **kwargs):
openpipe_options = kwargs.pop("openpipe", {})
cached_response = maybe_check_cache(
openpipe_options=openpipe_options, req_payload=kwargs
)
if cached_response:
return OpenAIObject.construct_from(cached_response, api_key=None)
requested_at = int(time.time() * 1000)
try:
chat_completion = original_openai.ChatCompletion.create(*args, **kwargs)
if inspect.isgenerator(chat_completion):
def _gen():
assembled_completion = None
for chunk in chat_completion:
assembled_completion = merge_streamed_chunks(
assembled_completion, chunk
)
yield chunk
received_at = int(time.time() * 1000)
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=assembled_completion,
status_code=200,
)
return _gen()
else:
received_at = int(time.time() * 1000)
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=chat_completion,
status_code=200,
)
return chat_completion
except Exception as e:
received_at = int(time.time() * 1000)
if isinstance(e, original_openai.OpenAIError):
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=e.json_body,
error_message=str(e),
status_code=e.http_status,
)
else:
report(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
error_message=str(e),
)
raise e
@classmethod
async def acreate(cls, *args, **kwargs):
openpipe_options = kwargs.pop("openpipe", {})
cached_response = await maybe_check_cache_async(
openpipe_options=openpipe_options, req_payload=kwargs
)
if cached_response:
return OpenAIObject.construct_from(cached_response, api_key=None)
requested_at = int(time.time() * 1000)
try:
chat_completion = original_openai.ChatCompletion.acreate(*args, **kwargs)
if inspect.isgenerator(chat_completion):
def _gen():
assembled_completion = None
for chunk in chat_completion:
assembled_completion = merge_streamed_chunks(
assembled_completion, chunk
)
yield chunk
received_at = int(time.time() * 1000)
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=assembled_completion,
status_code=200,
)
return _gen()
else:
received_at = int(time.time() * 1000)
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=chat_completion,
status_code=200,
)
return chat_completion
except Exception as e:
received_at = int(time.time() * 1000)
if isinstance(e, original_openai.OpenAIError):
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
resp_payload=e.json_body,
error_message=str(e),
status_code=e.http_status,
)
else:
report_async(
openpipe_options=openpipe_options,
requested_at=requested_at,
received_at=received_at,
req_payload=kwargs,
error_message=str(e),
)
raise e
class OpenAIWrapper:
ChatCompletion = WrappedChatCompletion()
def __getattr__(self, name):
return getattr(original_openai, name)
def __setattr__(self, name, value):
return setattr(original_openai, name, value)

View File

@@ -1,125 +0,0 @@
from openpipe.api_client.api.default import (
external_api_report,
external_api_check_cache,
)
from openpipe.api_client.client import AuthenticatedClient
from openpipe.api_client.models.external_api_report_json_body_tags import (
ExternalApiReportJsonBodyTags,
)
import toml
import time
version = toml.load("pyproject.toml")["tool"]["poetry"]["version"]
configured_client = AuthenticatedClient(
base_url="https://app.openpipe.ai/api/v1", token=""
)
def _get_tags(openpipe_options):
tags = openpipe_options.get("tags") or {}
tags["$sdk"] = "python"
tags["$sdk_version"] = version
return ExternalApiReportJsonBodyTags.from_dict(tags)
def _should_check_cache(openpipe_options):
if configured_client.token == "":
return False
return openpipe_options.get("cache", False)
def _process_cache_payload(
payload: external_api_check_cache.ExternalApiCheckCacheResponse200,
):
if not payload or not payload.resp_payload:
return None
payload.resp_payload["openpipe"] = {"cache_status": "HIT"}
return payload.resp_payload
def maybe_check_cache(
openpipe_options={},
req_payload={},
):
if not _should_check_cache(openpipe_options):
return None
try:
payload = external_api_check_cache.sync(
client=configured_client,
json_body=external_api_check_cache.ExternalApiCheckCacheJsonBody(
req_payload=req_payload,
requested_at=int(time.time() * 1000),
tags=_get_tags(openpipe_options),
),
)
return _process_cache_payload(payload)
except Exception as e:
# We don't want to break client apps if our API is down for some reason
print(f"Error reporting to OpenPipe: {e}")
print(e)
return None
async def maybe_check_cache_async(
openpipe_options={},
req_payload={},
):
if not _should_check_cache(openpipe_options):
return None
try:
payload = await external_api_check_cache.asyncio(
client=configured_client,
json_body=external_api_check_cache.ExternalApiCheckCacheJsonBody(
req_payload=req_payload,
requested_at=int(time.time() * 1000),
tags=_get_tags(openpipe_options),
),
)
return _process_cache_payload(payload)
except Exception as e:
# We don't want to break client apps if our API is down for some reason
print(f"Error reporting to OpenPipe: {e}")
print(e)
return None
def report(
openpipe_options={},
**kwargs,
):
try:
external_api_report.sync_detailed(
client=configured_client,
json_body=external_api_report.ExternalApiReportJsonBody(
**kwargs,
tags=_get_tags(openpipe_options),
),
)
except Exception as e:
# We don't want to break client apps if our API is down for some reason
print(f"Error reporting to OpenPipe: {e}")
print(e)
async def report_async(
openpipe_options={},
**kwargs,
):
try:
await external_api_report.asyncio_detailed(
client=configured_client,
json_body=external_api_report.ExternalApiReportJsonBody(
**kwargs,
tags=_get_tags(openpipe_options),
),
)
except Exception as e:
# We don't want to break client apps if our API is down for some reason
print(f"Error reporting to OpenPipe: {e}")
print(e)

View File

@@ -1,88 +0,0 @@
from dotenv import load_dotenv
from . import openai, configure_openpipe
import os
import pytest
load_dotenv()
openai.api_key = os.getenv("OPENAI_API_KEY")
configure_openpipe(
base_url="http://localhost:3000/api", api_key=os.getenv("OPENPIPE_API_KEY")
)
def test_sync():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
)
print(completion.choices[0].message.content)
def test_streaming():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
stream=True,
)
for chunk in completion:
print(chunk)
async def test_async():
acompletion = await openai.ChatCompletion.acreate(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "count down from 5"}],
)
print(acompletion.choices[0].message.content)
async def test_async_streaming():
acompletion = await openai.ChatCompletion.acreate(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "count down from 5"}],
stream=True,
)
async for chunk in acompletion:
print(chunk)
def test_sync_with_tags():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
openpipe={"tags": {"promptId": "testprompt"}},
)
print("finished")
print(completion.choices[0].message.content)
def test_bad_call():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo-blaster",
messages=[{"role": "system", "content": "count to 10"}],
stream=True,
)
@pytest.mark.focus
async def test_caching():
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
openpipe={"cache": True},
)
completion2 = await openai.ChatCompletion.acreate(
model="gpt-3.5-turbo",
messages=[{"role": "system", "content": "count to 10"}],
openpipe={"cache": True},
)
print(completion2)

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
[tool.poetry]
name = "openpipe"
version = "0.1.0"
description = ""
authors = ["Kyle Corbitt <kyle@corbt.com>"]
license = "Apache-2.0"
[tool.poetry.dependencies]
python = "^3.9"
openai = "^0.27.8"
httpx = "^0.24.1"
attrs = "^23.1.0"
python-dateutil = "^2.8.2"
toml = "^0.10.2"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
openapi-python-client = "^0.15.0"
black = "^23.7.0"
isort = "^5.12.0"
autoflake = "^2.2.0"
pytest = "^7.4.0"
python-dotenv = "^1.0.0"
pytest-asyncio = "^0.21.1"
pytest-watch = "^4.2.0"
pytest-testmon = "^2.0.12"
[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = "focus"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View File

@@ -15,11 +15,6 @@
"post": { "post": {
"operationId": "externalApi-checkCache", "operationId": "externalApi-checkCache",
"description": "Check if a prompt is cached", "description": "Check if a prompt is cached",
"security": [
{
"Authorization": []
}
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -27,7 +22,7 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"requestedAt": { "startTime": {
"type": "number", "type": "number",
"description": "Unix timestamp in milliseconds" "description": "Unix timestamp in milliseconds"
}, },
@@ -43,7 +38,7 @@
} }
}, },
"required": [ "required": [
"requestedAt" "startTime"
], ],
"additionalProperties": false "additionalProperties": false
} }
@@ -78,11 +73,6 @@
"post": { "post": {
"operationId": "externalApi-report", "operationId": "externalApi-report",
"description": "Report an API call", "description": "Report an API call",
"security": [
{
"Authorization": []
}
],
"requestBody": { "requestBody": {
"required": true, "required": true,
"content": { "content": {
@@ -90,11 +80,11 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"requestedAt": { "startTime": {
"type": "number", "type": "number",
"description": "Unix timestamp in milliseconds" "description": "Unix timestamp in milliseconds"
}, },
"receivedAt": { "endTime": {
"type": "number", "type": "number",
"description": "Unix timestamp in milliseconds" "description": "Unix timestamp in milliseconds"
}, },
@@ -104,11 +94,11 @@
"respPayload": { "respPayload": {
"description": "JSON-encoded response payload" "description": "JSON-encoded response payload"
}, },
"statusCode": { "respStatus": {
"type": "number", "type": "number",
"description": "HTTP status code of response" "description": "HTTP status code of response"
}, },
"errorMessage": { "error": {
"type": "string", "type": "string",
"description": "User-friendly error message" "description": "User-friendly error message"
}, },
@@ -121,8 +111,8 @@
} }
}, },
"required": [ "required": [
"requestedAt", "startTime",
"receivedAt" "endTime"
], ],
"additionalProperties": false "additionalProperties": false
} }

View File

@@ -1,2 +1,2 @@
node_modules/ node_modules
dist/ dist

Some files were not shown because too many files have changed in this diff Show More