Compare commits

..

39 Commits

Author SHA1 Message Date
David Corbitt
5e56c93c3f Remove scenarios header from output table card 2023-08-13 01:40:03 -07:00
David Corbitt
e423ad656a Fix ExperimentCard aspect ratio 2023-08-12 23:31:25 -07:00
Kyle Corbitt
7d0d94de3a Merge pull request #149 from OpenPipe/js-client
Load the JS client using pnpm workspaces
2023-08-12 22:56:17 -07:00
Kyle Corbitt
344b257db4 Load the JS client using pnpm workspaces
This makes it so we're using our own openpipe client for all OpenAI calls from the OpenPipe app.

The client doesn't do anything at the moment beyond proxying to the OpenAI lib. But this infra work should make it easier to quickly iterate on the client and test the changes in our own app.
2023-08-12 15:24:48 -07:00
Kyle Corbitt
28b43b6e6d Merge pull request #148 from OpenPipe/js-client
Fix client bugs
2023-08-12 10:38:49 -07:00
Kyle Corbitt
8d373ec9b5 remove unused imports 2023-08-12 10:02:23 -07:00
Kyle Corbitt
537525667d don't reload monaco every render cycle
oops
2023-08-12 09:59:07 -07:00
Kyle Corbitt
519367c553 Fix client bugs
1. PostHog can only be used client-side
2. Can't nest <a> tags in the ProjectMenu
2023-08-12 09:35:52 -07:00
Kyle Corbitt
1a338ec863 Merge pull request #147 from OpenPipe/logs-ui
Style overhaul, make logged calls selectable
2023-08-12 08:48:49 -07:00
David Corbitt
01d0b8f778 Resurrect UserMenu 2023-08-12 04:28:41 -07:00
David Corbitt
d99836ec30 Add experiment button 2023-08-12 04:18:39 -07:00
David Corbitt
33751c12d2 Allow user to select logs 2023-08-12 04:07:58 -07:00
David Corbitt
89815e1f7f Add selectedLogs, rename setSelectedProjectId 2023-08-12 03:35:54 -07:00
David Corbitt
5fa5109f34 Make cache text gray 2023-08-12 03:06:19 -07:00
David Corbitt
b06ab2cbf9 Properly show model 2023-08-12 02:58:28 -07:00
David Corbitt
35fb554038 Center Add Variant button 2023-08-12 02:48:22 -07:00
David Corbitt
f238177277 Fix variant header top right border radius 2023-08-12 02:46:09 -07:00
David Corbitt
723c0f7505 Update colors throughout app 2023-08-12 02:32:09 -07:00
David Corbitt
ce6936f753 Change overall background color and menu 2023-08-12 02:31:52 -07:00
David Corbitt
2a80cbf74a Add relative time back in 2023-08-12 02:06:56 -07:00
David Corbitt
098805ef25 Create loggedCalls.router 2023-08-12 00:03:06 -07:00
David Corbitt
ed90bc5a99 Add dashboard page 2023-08-11 23:34:53 -07:00
arcticfly
de9be8c7ce Allow custom config file (#143)
* Allow custom config file

* Temporarily remove dependency on local openpipe
2023-08-11 23:07:04 -07:00
arcticfly
3e02bcf9b8 Update paginator styles (#142)
* Change paginator icons

* Remove horizontal spacing
2023-08-11 21:11:25 -07:00
Kyle Corbitt
cef2ee31fb Merge pull request #141 from OpenPipe/python-sdk
Add caching in Python
2023-08-11 19:04:18 -07:00
Kyle Corbitt
d7cff0f52e Add caching in Python
Still need it in JS
2023-08-11 19:02:35 -07:00
arcticfly
228c547839 Add logged calls pagination (#140)
* Store model on LoggedCall

* Allow mulitple page sizes

* Add logged calls pagination
2023-08-11 19:00:09 -07:00
Kyle Corbitt
e1fcc8fb38 Merge pull request #139 from OpenPipe/python-sdk
Add a python client library
2023-08-11 17:48:07 -07:00
Kyle Corbitt
8ed47eb4dd Add a python client library
We still don't have any documentation and things are in flux, but you can report your OpenAI API calls to OpenPipe now.
2023-08-11 16:54:50 -07:00
arcticfly
3a908d51aa Store model on LoggedCall (#138) 2023-08-11 16:39:04 -07:00
arcticfly
d9db6d80ea Update external types (#137)
* Separate server and frontend error logic

* Update types in external api
2023-08-11 15:02:14 -07:00
arcticfly
8d1ee62ff1 Record model and cost when reporting logs (#136)
* Rename prompt and completion tokens to input and output tokens

* Add getUsage function

* Record model and cost when reporting log

* Remove unused imports

* Move UsageGraph to its own component

* Standardize model response fields

* Fix types
2023-08-11 13:56:47 -07:00
arcticfly
f270579283 Auto-resize project menu width (#135) 2023-08-10 22:50:39 -07:00
arcticfly
81fbaeae44 Style project settings on mobile (#134)
* Style project settings on mobile

* Use auto-resize text area for display name

* Remove unused import
2023-08-10 22:15:45 -07:00
arcticfly
5277afa199 Change logo (#133)
* Change logo

* Add more vertical padding on desktop

* Fix prettier
2023-08-10 21:44:33 -07:00
arcticfly
76c34d64e6 Change menu styles (#132)
* Change ProjectMenu placement

* Reduce UserMenu width
2023-08-10 18:48:23 -07:00
Kyle Corbitt
454ac9a0d3 Merge pull request #131 from OpenPipe/better-template-vars
Better scenario variable editing
2023-08-10 12:25:54 -07:00
Kyle Corbitt
5ed7adadf9 Better scenario variable editing
Some users have gotten confused by the scenario variable editing interface. This change makes the interface easier to understand.
2023-08-10 12:08:17 -07:00
Kyle Corbitt
b8e0f392ab Merge pull request #130 from OpenPipe/output-wrapping
Preserve linebreaks in model output
2023-08-10 07:26:55 -07:00
151 changed files with 5350 additions and 3354 deletions

5
.gitignore vendored Normal file
View File

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

View File

@@ -65,7 +65,14 @@ OpenPipe includes a tool to generate new test scenarios based on your existing p
4. Clone this repository: `git clone https://github.com/openpipe/openpipe` 4. Clone this repository: `git clone https://github.com/openpipe/openpipe`
5. Install the dependencies: `cd openpipe && pnpm install` 5. Install the dependencies: `cd openpipe && pnpm install`
6. Create a `.env` file (`cp .env.example .env`) and enter your `OPENAI_API_KEY`. 6. Create a `.env` file (`cp .env.example .env`) and enter your `OPENAI_API_KEY`.
7. Update `DATABASE_URL` if necessary to point to your Postgres instance and run `pnpm prisma db push` to create the database. 7. Update `DATABASE_URL` if necessary to point to your Postgres instance and run `pnpm prisma migrate dev` to create the database.
8. Create a [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) and update the `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` values. (Note: a PR to make auth optional when running locally would be a great contribution!) 8. Create a [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) and update the `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` values. (Note: a PR to make auth optional when running locally would be a great contribution!)
9. Start the app: `pnpm dev`. 9. Start the app: `pnpm dev`.
10. Navigate to [http://localhost:3000](http://localhost:3000) 10. Navigate to [http://localhost:3000](http://localhost:3000)
## Testing Locally
1. Copy your `.env` file to `.env.test`.
2. Update the `DATABASE_URL` to have a different database name than your development one
3. Run `DATABASE_URL=[your new datatase url] pnpm prisma migrate dev --skip-seed --skip-generate`
4. Run `pnpm test`

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: ["*.ts", "*.tsx"], files: ["*.mts", "*.ts", "*.tsx"],
parserOptions: { parserOptions: {
project: path.join(__dirname, "tsconfig.json"), project: path.join(__dirname, "tsconfig.json"),
}, },

4
app/.gitignore vendored
View File

@@ -34,6 +34,7 @@ yarn-error.log*
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env .env
.env*.local .env*.local
.env.test
# vercel # vercel
.vercel .vercel
@@ -43,3 +44,6 @@ yarn-error.log*
# Sentry Auth Token # Sentry Auth Token
.sentryclirc .sentryclirc
# custom openai intialization
src/server/utils/openaiCustomConfig.json

View File

@@ -18,13 +18,14 @@ 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

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

View File

@@ -1,5 +1,6 @@
{ {
"name": "openpipe", "name": "openpipe-app",
"private": true,
"type": "module", "type": "module",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -16,15 +17,14 @@
"postinstall": "prisma generate", "postinstall": "prisma generate",
"lint": "next lint", "lint": "next lint",
"start": "next start", "start": "next start",
"codegen": "tsx src/server/scripts/client-codegen.ts", "codegen:clients": "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 --no-threads" "test": "pnpm vitest"
}, },
"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,7 +100,8 @@
"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",

View File

@@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

View File

@@ -0,0 +1,66 @@
/*
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

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

View File

@@ -112,13 +112,13 @@ model ScenarioVariantCell {
model ModelResponse { model ModelResponse {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
inputHash String cacheKey String
requestedAt DateTime? requestedAt DateTime?
receivedAt DateTime? receivedAt DateTime?
output Json? respPayload Json?
cost Float? cost Float?
promptTokens Int? inputTokens Int?
completionTokens Int? outputTokens Int?
statusCode Int? statusCode Int?
errorMessage String? errorMessage String?
retryTime DateTime? retryTime DateTime?
@@ -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([inputHash]) @@index([cacheKey])
} }
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
startTime DateTime requestedAt 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,12 +273,13 @@ 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)
tags LoggedCallTag[] model String?
tags LoggedCallTag[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([startTime]) @@index([requestedAt])
} }
model LoggedCallModelResponse { model LoggedCallModelResponse {
@@ -287,14 +288,14 @@ model LoggedCallModelResponse {
reqPayload Json reqPayload Json
// The HTTP status returned by the model provider // The HTTP status returned by the model provider
respStatus Int? statusCode 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.
error String? errorMessage String?
startTime DateTime requestedAt DateTime
endTime DateTime receivedAt 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
@@ -308,7 +309,7 @@ model LoggedCallModelResponse {
outputTokens Int? outputTokens Int?
finishReason String? finishReason String?
completionId String? completionId String?
totalCost Decimal? @db.Decimal(18, 12) cost 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 startTime = new Date(Date.now() - Math.pow(Math.random(), 2) * 1000 * 60 * 60 * 24 * 14); const requestedAt = 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 endTime = new Date(startTime.getTime() + delay); const receivedAt = new Date(requestedAt.getTime() + delay);
loggedCallsToCreate.push({ loggedCallsToCreate.push({
id: loggedCallId, id: loggedCallId,
cacheHit: false, cacheHit: false,
startTime, requestedAt,
projectId: project.id, projectId: project.id,
createdAt: startTime, createdAt: requestedAt,
}); });
const { promptTokenPrice, completionTokenPrice } = const { promptTokenPrice, completionTokenPrice } =
@@ -365,21 +365,20 @@ for (let i = 0; i < 1437; i++) {
loggedCallModelResponsesToCreate.push({ loggedCallModelResponsesToCreate.push({
id: loggedCallModelResponseId, id: loggedCallModelResponseId,
startTime, requestedAt,
endTime, receivedAt,
originalLoggedCallId: loggedCallId, originalLoggedCallId: loggedCallId,
reqPayload: template.reqPayload, reqPayload: template.reqPayload,
respPayload: template.respPayload, respPayload: template.respPayload,
respStatus: template.respStatus, statusCode: template.respStatus,
error: template.error, errorMessage: template.error,
createdAt: startTime, createdAt: requestedAt,
cacheKey: hashRequest(project.id, template.reqPayload as JsonValue), cacheKey: hashRequest(project.id, template.reqPayload as JsonValue),
durationMs: endTime.getTime() - startTime.getTime(), durationMs: receivedAt.getTime() - requestedAt.getTime(),
inputTokens: template.inputTokens, inputTokens: template.inputTokens,
outputTokens: template.outputTokens, outputTokens: template.outputTokens,
finishReason: template.finishReason, finishReason: template.finishReason,
totalCost: cost: template.inputTokens * promptTokenPrice + template.outputTokens * completionTokenPrice,
template.inputTokens * promptTokenPrice + template.outputTokens * completionTokenPrice,
}); });
loggedCallsToUpdate.push({ loggedCallsToUpdate.push({
where: { where: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -9,10 +9,9 @@ Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata> </metadata>
<g transform="translate(0.000000,550.000000) scale(0.100000,-0.100000)" <g transform="translate(0.000000,550.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none"> fill="#000000" stroke="none">
<path d="M813 5478 c-18 -13 -37 -36 -43 -52 -6 -19 -10 -236 -10 -603 0 -638 <path d="M785 5474 l-25 -27 0 -622 0 -622 25 -27 24 -26 171 0 170 0 0 -2050
-1 -626 65 -657 25 -12 67 -16 179 -16 l146 0 0 -2032 0 -2032 23 -33 c12 -18 0 -2051 25 -25 24 -24 1557 2 1556 3 19 24 c19 23 19 70 19 2072 l0 2049 169
35 -37 51 -43 19 -7 539 -10 1528 -10 1663 0 1549 -5 1582 65 14 30 16 235 16 0 c165 0 169 1 195 25 l26 24 0 626 0 626 -26 24 -27 25 -1939 0 -1939 0 -24
2059 l0 2026 156 0 156 0 39 39 39 39 0 587 c0 651 1 638 -65 669 -30 14 -223 -26z"/>
16 -1932 16 l-1898 0 -32 -22z"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 858 B

After

Width:  |  Height:  |  Size: 755 B

View File

@@ -1,5 +1,28 @@
<svg width="380" height="320" viewBox="0 0 380 320" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="398" height="550" viewBox="0 0 398 550" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M72 320L122.5 231L130.5 150.5L115 73L72 0H312L265 64.5L257 158.5L265 249L312 320H72Z" fill="#FF5733"/> <path d="M39 125H359V542C359 546.418 355.418 550 351 550H47C42.5817 550 39 546.418 39 542V125Z" fill="black"/>
<path d="M67.027 9.5C72.9909 9.5 79.5196 12.3449 86.3672 19.2588C93.2495 26.2075 99.8845 36.7468 105.66 50.5336C117.194 78.0671 124.554 116.764 124.554 160C124.554 203.236 117.194 241.933 105.66 269.466C99.8845 283.253 93.2495 293.793 86.3672 300.741C79.5196 307.655 72.9909 310.5 67.027 310.5C61.0632 310.5 54.5345 307.655 47.6868 300.741C40.8045 293.793 34.1695 283.253 28.394 269.466C16.8596 241.933 9.5 203.236 9.5 160C9.5 116.764 16.8596 78.0671 28.394 50.5336C34.1695 36.7468 40.8045 26.2075 47.6868 19.2588C54.5345 12.3449 61.0632 9.5 67.027 9.5Z" stroke="#FF5733" stroke-width="19"/> <path d="M0 8C0 3.58172 3.58172 0 8 0H390C394.418 0 398 3.58172 398 8V127C398 131.418 394.418 135 390 135H7.99999C3.58171 135 0 131.418 0 127V8Z" fill="black"/>
<path d="M312.027 9.5C317.991 9.5 324.52 12.3449 331.367 19.2588C338.25 26.2075 344.885 36.7468 350.66 50.5336C362.194 78.0671 369.554 116.764 369.554 160C369.554 203.236 362.194 241.933 350.66 269.466C344.885 283.253 338.25 293.793 331.367 300.741C324.52 307.655 317.991 310.5 312.027 310.5C306.063 310.5 299.534 307.655 292.687 300.741C285.805 293.793 279.17 283.253 273.394 269.466C261.86 241.933 254.5 203.236 254.5 160C254.5 116.764 261.86 78.0671 273.394 50.5336C279.17 36.7468 285.805 26.2075 292.687 19.2588C299.534 12.3449 306.063 9.5 312.027 9.5Z" stroke="#FF5733" stroke-width="19"/> <path d="M50 135H348V535C348 537.209 346.209 539 344 539H54C51.7909 539 50 537.209 50 535V135Z" fill="#FF5733"/>
<path d="M11 14.0001C11 11.791 12.7909 10.0001 15 10.0001H384C386.209 10.0001 388 11.791 388 14.0001V120C388 122.209 386.209 124 384 124H15C12.7909 124 11 122.209 11 120V14.0001Z" fill="#FF5733"/>
<path d="M11 14.0001C11 11.791 12.7909 10.0001 15 10.0001H384C386.209 10.0001 388 11.791 388 14.0001V120C388 122.209 386.209 124 384 124H15C12.7909 124 11 122.209 11 120V14.0001Z" fill="url(#paint0_linear_102_49)"/>
<path d="M50 134H348V535C348 537.209 346.209 539 344 539H54C51.7909 539 50 537.209 50 535V134Z" fill="url(#paint1_linear_102_49)"/>
<path d="M108 142H156V535H108V142Z" fill="white"/>
<path d="M300 135H348V535C348 537.209 346.209 539 344 539H300V135Z" fill="white" fill-opacity="0.25"/>
<path d="M96 142H108V535H96V142Z" fill="white" fill-opacity="0.5"/>
<path d="M84 10.0001H133V120H84V10.0001Z" fill="white"/>
<path d="M339 10.0001H384C386.209 10.0001 388 11.791 388 14.0001V120C388 122.209 386.209 124 384 124H339V10.0001Z" fill="white" fill-opacity="0.25"/>
<path d="M71.9995 10.0001H83.9995V120H71.9995V10.0001Z" fill="white" fill-opacity="0.5"/>
<path d="M108 534.529H156V539.019H108V534.529Z" fill="#AAAAAA"/>
<path opacity="0.5" d="M95.9927 534.529H107.982V539.019H95.9927V534.529Z" fill="#AAAAAA"/>
<path d="M84.0029 119.887H133.007V124.027H84.0029V119.887Z" fill="#AAAAAA"/>
<path opacity="0.5" d="M71.9883 119.887H83.978V124.027H71.9883V119.887Z" fill="#AAAAAA"/>
<defs>
<linearGradient id="paint0_linear_102_49" x1="335" y1="67.0001" x2="137" y2="67.0001" gradientUnits="userSpaceOnUse">
<stop stop-color="#D62600"/>
<stop offset="1" stop-color="#FF5733" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_102_49" x1="306.106" y1="336.5" x2="149.597" y2="336.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#D62600"/>
<stop offset="1" stop-color="#FF5733" stop-opacity="0"/>
</linearGradient>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -19,7 +19,7 @@ const CopiableCode = ({ code }: { code: string }) => {
w="full" w="full"
justifyContent="space-between" justifyContent="space-between"
> >
<Text fontFamily="inconsolata" fontWeight="bold" letterSpacing={0.5}> <Text fontFamily="inconsolata" fontWeight="bold" letterSpacing={0.5} overflowX="auto">
{code} {code}
</Text> </Text>
<Tooltip closeOnClick={false} label={copied ? "Copied!" : "Copy to clipboard"}> <Tooltip closeOnClick={false} label={copied ? "Copied!" : "Copy to clipboard"}>

View File

@@ -8,8 +8,8 @@ export default function Favicon() {
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png" />
<link rel="manifest" href="/favicons/site.webmanifest" /> <link rel="manifest" href="/favicons/site.webmanifest" />
<link rel="shortcut icon" href="/favicons/favicon.ico" /> <link rel="shortcut icon" href="/favicons/favicon.ico" />
<link rel="mask-icon" href="/favicons/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" /> <meta name="msapplication-TileColor" content="#da532c" />
<meta name="msapplication-config" content="/favicons/browserconfig.xml" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
</Head> </Head>
); );

View File

@@ -33,25 +33,11 @@ export default function AddVariantButton() {
<Flex w="100%" justifyContent="flex-end"> <Flex w="100%" justifyContent="flex-end">
<ActionButton <ActionButton
onClick={onClick} onClick={onClick}
py={5} py={7}
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

@@ -12,6 +12,7 @@ import {
Select, Select,
FormHelperText, FormHelperText,
Code, Code,
IconButton,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { type Evaluation, EvalType } from "@prisma/client"; import { type Evaluation, EvalType } from "@prisma/client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
@@ -183,46 +184,37 @@ export default function EditEvaluations() {
<Text flex={1}> <Text flex={1}>
{evaluation.evalType}: &quot;{evaluation.value}&quot; {evaluation.evalType}: &quot;{evaluation.value}&quot;
</Text> </Text>
<Button
<IconButton
aria-label="Edit"
variant="unstyled" variant="unstyled"
color="gray.400"
height="unset"
width="unset"
minW="unset" minW="unset"
color="gray.400"
onClick={() => setEditingId(evaluation.id)} onClick={() => setEditingId(evaluation.id)}
_hover={{ _hover={{ color: "gray.800", cursor: "pointer" }}
color: "gray.800", icon={<Icon as={BsPencil} />}
cursor: "pointer", />
}} <IconButton
> aria-label="Delete"
<Icon as={BsPencil} boxSize={4} />
</Button>
<Button
variant="unstyled" variant="unstyled"
color="gray.400"
height="unset"
width="unset"
minW="unset" minW="unset"
color="gray.400"
onClick={() => onDelete(evaluation.id)} onClick={() => onDelete(evaluation.id)}
_hover={{ _hover={{ color: "gray.800", cursor: "pointer" }}
color: "gray.800", icon={<Icon as={BsX} boxSize={6} />}
cursor: "pointer", />
}}
>
<Icon as={BsX} boxSize={6} />
</Button>
</HStack> </HStack>
), ),
)} )}
{editingId == null && ( {editingId == null && (
<Button <Button
onClick={() => setEditingId("new")} onClick={() => setEditingId("new")}
alignSelf="flex-start" alignSelf="end"
size="sm" size="sm"
mt={4} mt={4}
colorScheme="blue" colorScheme="blue"
> >
Add Evaluation New Evaluation
</Button> </Button>
)} )}
{editingId == "new" && ( {editingId == "new" && (

View File

@@ -1,103 +1,185 @@
import { Text, Button, HStack, Heading, Icon, Input, Stack } from "@chakra-ui/react"; import { Text, Button, HStack, Heading, Icon, IconButton, Stack, VStack } from "@chakra-ui/react";
import { useState } from "react"; import { type TemplateVariable } from "@prisma/client";
import { BsCheck, BsX } from "react-icons/bs"; import { useEffect, useState } from "react";
import { BsPencil, BsX } from "react-icons/bs";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useExperiment, useHandledAsyncCallback } from "~/utils/hooks"; import { useExperiment, useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError";
import { FloatingLabelInput } from "./FloatingLabelInput";
export const ScenarioVar = ({
variable,
isEditing,
setIsEditing,
}: {
variable: Pick<TemplateVariable, "id" | "label">;
isEditing: boolean;
setIsEditing: (isEditing: boolean) => void;
}) => {
const utils = api.useContext();
const [label, setLabel] = useState(variable.label);
useEffect(() => {
setLabel(variable.label);
}, [variable.label]);
const renameVarMutation = api.scenarioVars.rename.useMutation();
const [onRename] = useHandledAsyncCallback(async () => {
const resp = await renameVarMutation.mutateAsync({ id: variable.id, label });
if (maybeReportError(resp)) return;
setIsEditing(false);
await utils.scenarioVars.list.invalidate();
await utils.scenarios.list.invalidate();
}, [label, variable.id]);
const deleteMutation = api.scenarioVars.delete.useMutation();
const [onDeleteVar] = useHandledAsyncCallback(async () => {
await deleteMutation.mutateAsync({ id: variable.id });
await utils.scenarioVars.list.invalidate();
}, [variable.id]);
if (isEditing) {
return (
<HStack w="full">
<FloatingLabelInput
flex={1}
label="Renamed Variable"
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onRename();
}
// If the user types a space, replace it with an underscore
if (e.key === " ") {
e.preventDefault();
setLabel((label) => label && `${label}_`);
}
}}
/>
<Button size="sm" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button size="sm" colorScheme="blue" onClick={onRename}>
Save
</Button>
</HStack>
);
} else {
return (
<HStack w="full" borderTopWidth={1} borderColor="gray.200">
<Text flex={1}>{variable.label}</Text>
<IconButton
aria-label="Edit"
variant="unstyled"
minW="unset"
color="gray.400"
onClick={() => setIsEditing(true)}
_hover={{ color: "gray.800", cursor: "pointer" }}
icon={<Icon as={BsPencil} />}
/>
<IconButton
aria-label="Delete"
variant="unstyled"
minW="unset"
color="gray.400"
onClick={onDeleteVar}
_hover={{ color: "gray.800", cursor: "pointer" }}
icon={<Icon as={BsX} boxSize={6} />}
/>
</HStack>
);
}
};
export default function EditScenarioVars() { export default function EditScenarioVars() {
const experiment = useExperiment(); const experiment = useExperiment();
const vars = const vars = useScenarioVars();
api.templateVars.list.useQuery({ experimentId: experiment.data?.id ?? "" }).data ?? [];
const [currentlyEditingId, setCurrentlyEditingId] = useState<string | null>(null);
const [newVariable, setNewVariable] = useState<string>(""); const [newVariable, setNewVariable] = useState<string>("");
const newVarIsValid = newVariable.length > 0 && !vars.map((v) => v.label).includes(newVariable); const newVarIsValid = newVariable?.length ?? 0 > 0;
const utils = api.useContext(); const utils = api.useContext();
const addVarMutation = api.templateVars.create.useMutation(); const addVarMutation = api.scenarioVars.create.useMutation();
const [onAddVar] = useHandledAsyncCallback(async () => { const [onAddVar] = useHandledAsyncCallback(async () => {
if (!experiment.data?.id) return; if (!experiment.data?.id) return;
if (!newVarIsValid) return; if (!newVariable) return;
await addVarMutation.mutateAsync({ const resp = await addVarMutation.mutateAsync({
experimentId: experiment.data.id, experimentId: experiment.data.id,
label: newVariable, label: newVariable,
}); });
await utils.templateVars.list.invalidate(); if (maybeReportError(resp)) return;
await utils.scenarioVars.list.invalidate();
setNewVariable(""); setNewVariable("");
}, [addVarMutation, experiment.data?.id, newVarIsValid, newVariable]); }, [addVarMutation, experiment.data?.id, newVarIsValid, newVariable]);
const deleteMutation = api.templateVars.delete.useMutation();
const [onDeleteVar] = useHandledAsyncCallback(async (id: string) => {
await deleteMutation.mutateAsync({ id });
await utils.templateVars.list.invalidate();
}, []);
return ( return (
<Stack> <Stack>
<Heading size="sm">Scenario Variables</Heading> <Heading size="sm">Scenario Variables</Heading>
<Stack spacing={2}> <VStack spacing={4}>
<Text fontSize="sm"> <Text fontSize="sm">
Scenario variables can be used in your prompt variants as well as evaluations. Scenario variables can be used in your prompt variants as well as evaluations.
</Text> </Text>
<HStack spacing={0}> <VStack spacing={0} w="full">
<Input {vars.data?.map((variable) => (
placeholder="Add Scenario Variable" <ScenarioVar
size="sm" variable={variable}
borderTopRadius={0}
borderRightRadius={0}
value={newVariable}
onChange={(e) => setNewVariable(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onAddVar();
}
// If the user types a space, replace it with an underscore
if (e.key === " ") {
e.preventDefault();
setNewVariable((v) => v + "_");
}
}}
/>
<Button
size="xs"
height="100%"
borderLeftRadius={0}
isDisabled={!newVarIsValid}
onClick={onAddVar}
>
<Icon as={BsCheck} boxSize={8} />
</Button>
</HStack>
<HStack spacing={2} py={4} wrap="wrap">
{vars.map((variable) => (
<HStack
key={variable.id} key={variable.id}
spacing={0} isEditing={currentlyEditingId === variable.id}
bgColor="blue.100" setIsEditing={(isEditing) => {
color="blue.600" if (isEditing) {
pl={2} setCurrentlyEditingId(variable.id);
pr={0} } else {
fontWeight="bold" setCurrentlyEditingId(null);
> }
<Text fontSize="sm" flex={1}> }}
{variable.label} />
</Text>
<Button
size="xs"
variant="ghost"
colorScheme="blue"
p="unset"
minW="unset"
px="unset"
onClick={() => onDeleteVar(variable.id)}
>
<Icon as={BsX} boxSize={6} color="blue.800" />
</Button>
</HStack>
))} ))}
</HStack> </VStack>
</Stack> {currentlyEditingId !== "new" && (
<Button
colorScheme="blue"
size="sm"
onClick={() => setCurrentlyEditingId("new")}
alignSelf="end"
>
New Variable
</Button>
)}
{currentlyEditingId === "new" && (
<HStack w="full">
<FloatingLabelInput
flex={1}
label="New Variable"
value={newVariable}
onChange={(e) => setNewVariable(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onAddVar();
}
// If the user types a space, replace it with an underscore
if (e.key === " ") {
e.preventDefault();
setNewVariable((v) => v && `${v}_`);
}
}}
/>
<Button size="sm" onClick={() => setCurrentlyEditingId(null)}>
Cancel
</Button>
<Button size="sm" colorScheme="blue" onClick={onAddVar}>
Save
</Button>
</HStack>
)}
</VStack>
</Stack> </Stack>
); );
} }

View File

@@ -1,7 +1,7 @@
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { type PromptVariant, type Scenario } from "../types"; import { type PromptVariant, type Scenario } from "../types";
import { type StackProps, Text, VStack } from "@chakra-ui/react"; import { type StackProps, Text, VStack } from "@chakra-ui/react";
import { useExperiment, useHandledAsyncCallback } from "~/utils/hooks"; import { useScenarioVars, useHandledAsyncCallback } from "~/utils/hooks";
import SyntaxHighlighter from "react-syntax-highlighter"; import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/cjs/styles/hljs"; import { docco } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import stringify from "json-stringify-pretty-compact"; import stringify from "json-stringify-pretty-compact";
@@ -23,10 +23,7 @@ export default function OutputCell({
variant: PromptVariant; variant: PromptVariant;
}): ReactElement | null { }): ReactElement | null {
const utils = api.useContext(); const utils = api.useContext();
const experiment = useExperiment(); const vars = useScenarioVars().data;
const vars = api.templateVars.list.useQuery({
experimentId: experiment.data?.id ?? "",
}).data;
const scenarioVariables = scenario.variableValues as Record<string, string>; const scenarioVariables = scenario.variableValues as Record<string, string>;
const templateHasVariables = const templateHasVariables =
@@ -110,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?.output; const showLogs = !streamedMessage && !mostRecentResponse?.respPayload;
if (showLogs) if (showLogs)
return ( return (
@@ -163,13 +160,13 @@ export default function OutputCell({
</CellWrapper> </CellWrapper>
); );
const normalizedOutput = mostRecentResponse?.output const normalizedOutput = mostRecentResponse?.respPayload
? provider.normalizeOutput(mostRecentResponse?.output) ? provider.normalizeOutput(mostRecentResponse?.respPayload)
: streamedMessage : streamedMessage
? provider.normalizeOutput(streamedMessage) ? provider.normalizeOutput(streamedMessage)
: null; : null;
if (mostRecentResponse?.output && normalizedOutput?.type === "json") { if (mostRecentResponse?.respPayload && normalizedOutput?.type === "json") {
return ( return (
<CellWrapper> <CellWrapper>
<SyntaxHighlighter <SyntaxHighlighter

View File

@@ -19,8 +19,8 @@ export const OutputStats = ({
? modelResponse.receivedAt.getTime() - modelResponse.requestedAt.getTime() ? modelResponse.receivedAt.getTime() - modelResponse.requestedAt.getTime()
: 0; : 0;
const promptTokens = modelResponse.promptTokens; const inputTokens = modelResponse.inputTokens;
const completionTokens = modelResponse.completionTokens; const outputTokens = modelResponse.outputTokens;
return ( return (
<HStack <HStack
@@ -55,8 +55,8 @@ export const OutputStats = ({
</HStack> </HStack>
{modelResponse.cost && ( {modelResponse.cost && (
<CostTooltip <CostTooltip
promptTokens={promptTokens} inputTokens={inputTokens}
completionTokens={completionTokens} outputTokens={outputTokens}
cost={modelResponse.cost} cost={modelResponse.cost}
> >
<HStack spacing={0}> <HStack spacing={0}>

View File

@@ -1,7 +1,7 @@
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { useEffect, useState, type DragEvent } from "react"; import { useEffect, useState, type DragEvent } from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useExperiment, useExperimentAccess, useHandledAsyncCallback } from "~/utils/hooks"; import { useExperimentAccess, useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks";
import { type Scenario } from "./types"; import { type Scenario } from "./types";
import { import {
@@ -41,8 +41,7 @@ export default function ScenarioEditor({
if (savedValues) setValues(savedValues); if (savedValues) setValues(savedValues);
}, [savedValues]); }, [savedValues]);
const experiment = useExperiment(); const vars = useScenarioVars();
const vars = api.templateVars.list.useQuery({ experimentId: experiment.data?.id ?? "" });
const variableLabels = vars.data?.map((v) => v.label) ?? []; const variableLabels = vars.data?.map((v) => v.label) ?? [];

View File

@@ -58,7 +58,7 @@ export const ScenarioEditorModal = ({
await utils.scenarios.list.invalidate(); await utils.scenarios.list.invalidate();
}, [mutation, values]); }, [mutation, values]);
const vars = api.templateVars.list.useQuery({ experimentId: experiment.data?.id ?? "" }); const vars = api.scenarioVars.list.useQuery({ experimentId: experiment.data?.id ?? "" });
const variableLabels = vars.data?.map((v) => v.label) ?? []; const variableLabels = vars.data?.map((v) => v.label) ?? [];
return ( return (

View File

@@ -1,21 +1,16 @@
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 = () => { const ScenarioPaginator = (props: StackProps) => {
const { data } = useScenarios(); const { data } = useScenarios();
if (!data) return null; if (!data) return null;
const { scenarios, startIndex, lastPage, count } = data; const { count } = data;
return ( return <Paginator count={count} condense {...props} />;
<Paginator
numItemsLoaded={scenarios.length}
startIndex={startIndex}
lastPage={lastPage}
count={count}
/>
);
}; };
export default ScenarioPaginator; export default ScenarioPaginator;

View File

@@ -10,6 +10,8 @@ const ScenarioRow = (props: {
variants: PromptVariant[]; variants: PromptVariant[];
canHide: boolean; canHide: boolean;
rowStart: number; rowStart: number;
isFirst: boolean;
isLast: boolean;
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -21,10 +23,14 @@ const ScenarioRow = (props: {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
sx={isHovered ? highlightStyle : undefined} sx={isHovered ? highlightStyle : undefined}
borderLeftWidth={1} bgColor="white"
{...borders}
rowStart={props.rowStart} rowStart={props.rowStart}
colStart={1} colStart={1}
borderLeftWidth={1}
borderTopWidth={props.isFirst ? 1 : 0}
borderTopLeftRadius={props.isFirst ? 8 : 0}
borderBottomLeftRadius={props.isLast ? 8 : 0}
{...borders}
> >
<ScenarioEditor scenario={props.scenario} hovered={isHovered} canHide={props.canHide} /> <ScenarioEditor scenario={props.scenario} hovered={isHovered} canHide={props.canHide} />
</GridItem> </GridItem>
@@ -34,8 +40,12 @@ 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}
borderTopWidth={props.isFirst ? 1 : 0}
borderTopRightRadius={props.isFirst && i === props.variants.length - 1 ? 8 : 0}
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,7 +48,7 @@ export const ScenariosHeader = () => {
); );
return ( return (
<HStack w="100%" pb={cellPadding.y} pt={0} align="center" spacing={0}> <HStack w="100%" py={cellPadding.y} px={cellPadding.x} align="center" spacing={0}>
<Text fontSize={16} fontWeight="bold"> <Text fontSize={16} fontWeight="bold">
Scenarios ({scenarios.data?.count}) Scenarios ({scenarios.data?.count})
</Text> </Text>
@@ -57,11 +57,16 @@ 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={-3}> <MenuList fontSize="md" zIndex="dropdown" mt={-1}>
<MenuItem <MenuItem
icon={<Icon as={BsPlus} boxSize={6} mx="-5px" />} icon={<Icon as={BsPlus} boxSize={6} mx="-5px" />}
onClick={() => onAddScenario(false)} onClick={() => onAddScenario(false)}
@@ -72,7 +77,7 @@ export const ScenariosHeader = () => {
Autogenerate Scenario Autogenerate Scenario
</MenuItem> </MenuItem>
<MenuItem icon={<BsPencil />} onClick={openDrawer}> <MenuItem icon={<BsPencil />} onClick={openDrawer}>
Edit Vars Add or Remove Variables
</MenuItem> </MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>

View File

@@ -17,8 +17,8 @@ export default function VariantStats(props: { variant: PromptVariant }) {
initialData: { initialData: {
evalResults: [], evalResults: [],
overallCost: 0, overallCost: 0,
promptTokens: 0, inputTokens: 0,
completionTokens: 0, outputTokens: 0,
scenarioCount: 0, scenarioCount: 0,
outputCount: 0, outputCount: 0,
awaitingEvals: false, awaitingEvals: false,
@@ -68,8 +68,8 @@ export default function VariantStats(props: { variant: PromptVariant }) {
</HStack> </HStack>
{data.overallCost && ( {data.overallCost && (
<CostTooltip <CostTooltip
promptTokens={data.promptTokens} inputTokens={data.inputTokens}
completionTokens={data.completionTokens} outputTokens={data.outputTokens}
cost={data.overallCost} cost={data.overallCost}
> >
<HStack spacing={0} align="center" color="gray.500"> <HStack spacing={0} align="center" color="gray.500">

View File

@@ -53,20 +53,29 @@ 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: "gray.100", backgroundColor: "white",
}; };
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 rowStart={3} {...sharedProps}> <GridItem
rowStart={3}
{...sharedProps}
borderBottomLeftRadius={isFirst ? 8 : 0}
borderBottomRightRadius={isLast ? 8 : 0}
>
<VariantStats variant={variant} /> <VariantStats variant={variant} />
</GridItem> </GridItem>
</Fragment> </Fragment>
@@ -77,7 +86,6 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
colSpan={allCols - 1} colSpan={allCols - 1}
rowStart={variantHeaderRows + 1} rowStart={variantHeaderRows + 1}
colStart={1} colStart={1}
{...borders}
borderRightWidth={0} borderRightWidth={0}
> >
<ScenariosHeader /> <ScenariosHeader />
@@ -90,6 +98,8 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
scenario={scenario} scenario={scenario}
variants={variants.data} variants={variants.data}
canHide={visibleScenariosCount > 1} canHide={visibleScenariosCount > 1}
isFirst={i === 0}
isLast={i === visibleScenariosCount - 1}
/> />
))} ))}
<GridItem <GridItem

View File

@@ -1,77 +1,117 @@
import { Box, HStack, IconButton } from "@chakra-ui/react"; import { HStack, IconButton, Text, Select, type StackProps, Icon } from "@chakra-ui/react";
import { import React, { useCallback } from "react";
BsChevronDoubleLeft, import { FiChevronsLeft, FiChevronsRight, FiChevronLeft, FiChevronRight } from "react-icons/fi";
BsChevronDoubleRight, import { usePageParams } from "~/utils/hooks";
BsChevronLeft,
BsChevronRight, const pageSizeOptions = [10, 25, 50, 100];
} from "react-icons/bs";
import { usePage } from "~/utils/hooks";
const Paginator = ({ const Paginator = ({
numItemsLoaded,
startIndex,
lastPage,
count, count,
}: { condense,
numItemsLoaded: number; ...props
startIndex: number; }: { count: number; condense?: boolean } & StackProps) => {
lastPage: number; const { page, pageSize, setPageParams } = usePageParams();
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) {
setPage(page + 1, "replace"); setPageParams({ page: page + 1 }, "replace");
} }
}; };
const prevPage = () => { const prevPage = () => {
if (page > 1) { if (page > 1) {
setPage(page - 1, "replace"); setPageParams({ page: page - 1 }, "replace");
} }
}; };
const goToLastPage = () => setPage(lastPage, "replace"); const goToLastPage = () => setPageParams({ page: lastPage }, "replace");
const goToFirstPage = () => setPage(1, "replace"); const goToFirstPage = () => setPageParams({ page: 1 }, "replace");
return ( return (
<HStack pt={4}> <HStack
<IconButton pt={4}
variant="ghost" spacing={8}
size="sm" justifyContent={condense ? "flex-start" : "space-between"}
onClick={goToFirstPage} alignItems="center"
isDisabled={page === 1} w="full"
aria-label="Go to first page" {...props}
icon={<BsChevronDoubleLeft />} >
/> {!condense && (
<IconButton <>
variant="ghost" <HStack>
size="sm" <Text>Rows</Text>
onClick={prevPage} <Select
isDisabled={page === 1} value={pageSize}
aria-label="Previous page" onChange={(e) => updatePageSize(parseInt(e.target.value))}
icon={<BsChevronLeft />} w={20}
/> backgroundColor="white"
<Box> >
{startIndex}-{startIndex + numItemsLoaded - 1} / {count} {pageSizeOptions.map((option) => (
</Box> <option key={option} value={option}>
<IconButton {option}
variant="ghost" </option>
size="sm" ))}
onClick={nextPage} </Select>
isDisabled={page === lastPage} </HStack>
aria-label="Next page" <Text>
icon={<BsChevronRight />} Page {page} of {lastPage}
/> </Text>
<IconButton </>
variant="ghost" )}
size="sm"
onClick={goToLastPage} <HStack>
isDisabled={page === lastPage} <IconButton
aria-label="Go to last page" variant="outline"
icon={<BsChevronDoubleRight />} size="sm"
/> 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: "0", top: "-2",
// 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,6 +84,7 @@ 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}
@@ -102,7 +103,9 @@ export default function VariantHeader(
setIsDragTarget(false); setIsDragTarget(false);
}} }}
onDrop={onReorder} onDrop={onReorder}
backgroundColor={isDragTarget ? "gray.200" : "gray.100"} backgroundColor={isDragTarget ? "gray.200" : "white"}
borderTopLeftRadius={gridItemProps.borderTopLeftRadius}
borderTopRightRadius={gridItemProps.borderTopRightRadius}
h="full" h="full"
> >
<Icon <Icon

View File

@@ -1,201 +0,0 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,61 @@
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,21 +1,16 @@
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 = () => { const DatasetEntriesPaginator = (props: StackProps) => {
const { data } = useDatasetEntries(); const { data } = useDatasetEntries();
if (!data) return null; if (!data) return null;
const { entries, startIndex, lastPage, count } = data; const { count } = data;
return ( return <Paginator count={count} {...props} />;
<Paginator
numItemsLoaded={entries.length}
startIndex={startIndex}
lastPage={lastPage}
count={count}
/>
);
}; };
export default DatasetEntriesPaginator; export default DatasetEntriesPaginator;

View File

@@ -7,6 +7,7 @@ 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";
@@ -29,17 +30,22 @@ type ExperimentData = {
export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => { export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => {
return ( return (
<AspectRatio ratio={1.2} w="full"> <Card
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">
@@ -57,7 +63,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>
</AspectRatio> </Card>
); );
}; };
@@ -89,30 +95,30 @@ export const NewExperimentCard = () => {
}, [createMutation, router, selectedProjectId]); }, [createMutation, router, selectedProjectId]);
return ( return (
<AspectRatio ratio={1.2} w="full"> <Card
<VStack w="full"
align="center" h="full"
justify="center" cursor="pointer"
_hover={{ cursor: "pointer", bg: "gray.50" }} p={4}
transition="background 0.2s" bg="white"
cursor="pointer" borderRadius={4}
borderColor="gray.200" _hover={{ bg: "gray.100" }}
borderWidth={1} transition="background 0.2s"
p={4} aspectRatio={1.2}
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>
</AspectRatio> </Card>
); );
}; };
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="gray.50"> <VStack align="center" borderColor="gray.200" borderWidth={1} p={4} bg="white">
<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 } from "react"; import { useState, useEffect, useRef } 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 { RiDatabase2Line, RiFlaskLine } from "react-icons/ri"; import { RiHome3Line, 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,10 +27,16 @@ 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}
@@ -40,25 +46,59 @@ const NavSidebar = () => {
borderRightWidth={1} borderRightWidth={1}
borderColor="gray.300" borderColor="gray.300"
> >
<HStack as={Link} href="/" _hover={{ textDecoration: "none" }} spacing={0} px={2} py={2}> {displayLogo && (
<Image src="/logo.svg" alt="" boxSize={6} mr={4} /> <>
<Heading size="md" fontFamily="inconsolata, monospace"> <HStack
OpenPipe as={Link}
</Heading> href="/"
</HStack> _hover={{ textDecoration: "none" }}
<Divider /> spacing={{ base: 1, md: 0 }}
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">
OpenPipe
</Heading>
</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 && (
@@ -80,20 +120,7 @@ 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
@@ -153,7 +180,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"> <Box h="100%" flex={1} overflowY="auto" bgColor="gray.50">
{children} {children}
</Box> </Box>
</Flex> </Flex>

View File

@@ -6,16 +6,18 @@ 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 { BsChevronRight, BsGear, BsPlus } from "react-icons/bs"; import { BsPlus, BsPersonCircle } 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";
@@ -23,13 +25,14 @@ 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();
@@ -39,9 +42,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();
@@ -49,28 +52,32 @@ 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: "New Project" }); const newProj = await createMutation.mutateAsync({ name: "Untitled 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}> <VStack w="full" alignItems="flex-start" spacing={0} py={1}>
<Text <Popover
pl={2} placement="bottom"
pb={2} isOpen={popover.isOpen}
fontSize="xs" onOpen={popover.onOpen}
fontWeight="bold" onClose={popover.onClose}
color="gray.500" closeOnBlur
display={{ base: "none", md: "flex" }}
> >
PROJECT
</Text>
<Popover placement="right" isOpen={popover.isOpen} onClose={popover.onClose} closeOnBlur>
<PopoverTrigger> <PopoverTrigger>
<NavSidebarOption> <NavSidebarOption>
<HStack w="full" onClick={popover.onToggle}> <HStack w="full">
<Flex <Flex
p={1} p={1}
borderRadius={4} borderRadius={4}
@@ -83,20 +90,35 @@ export default function ProjectMenu() {
> >
<Text>{selectedProject?.name[0]?.toUpperCase()}</Text> <Text>{selectedProject?.name[0]?.toUpperCase()}</Text>
</Flex> </Flex>
<Text fontSize="sm" display={{ base: "none", md: "block" }} py={1} flex={1}> <Text
fontSize="sm"
display={{ base: "none", md: "block" }}
py={1}
flex={1}
fontWeight="bold"
>
{selectedProject?.name} {selectedProject?.name}
</Text> </Text>
<Icon as={BsChevronRight} boxSize={4} color="gray.500" /> <Box mr={2}>{profileImage}</Box>
</HStack> </HStack>
</NavSidebarOption> </NavSidebarOption>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent _focusVisible={{ outline: "unset" }} ml={-1}> <PopoverContent
<VStack alignItems="flex-start" spacing={2} py={4} px={2}> _focusVisible={{ outline: "unset" }}
<Text color="gray.500" fontSize="xs" fontWeight="bold" pb={1}> ml={-1}
PROJECTS w={224}
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 />
<VStack spacing={0} w="full"> <Text alignSelf="flex-start" fontWeight="bold" px={3} pt={2}>
Your Projects
</Text>
<VStack spacing={0} w="full" px={1}>
{projects?.map((proj) => ( {projects?.map((proj) => (
<ProjectOption <ProjectOption
key={proj.id} key={proj.id}
@@ -105,19 +127,38 @@ 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>
@@ -134,36 +175,27 @@ 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" }}
p={2} color={isActive ? "blue.400" : undefined}
py={2}
px={4}
borderRadius={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

@@ -47,7 +47,7 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro
</HStack> </HStack>
</NavSidebarOption> </NavSidebarOption>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent _focusVisible={{ outline: "unset" }} ml={-1}> <PopoverContent _focusVisible={{ outline: "unset" }} ml={-1} minW={48} w="full">
<VStack align="stretch" spacing={0}> <VStack align="stretch" spacing={0}>
{/* sign out */} {/* sign out */}
<HStack <HStack

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,164 @@
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

@@ -2,14 +2,14 @@ import { HStack, Icon, Text, Tooltip, type TooltipProps, VStack, Divider } from
import { BsCurrencyDollar } from "react-icons/bs"; import { BsCurrencyDollar } from "react-icons/bs";
type CostTooltipProps = { type CostTooltipProps = {
promptTokens: number | null; inputTokens: number | null;
completionTokens: number | null; outputTokens: number | null;
cost: number; cost: number;
} & TooltipProps; } & TooltipProps;
export const CostTooltip = ({ export const CostTooltip = ({
promptTokens, inputTokens,
completionTokens, outputTokens,
cost, cost,
children, children,
...props ...props
@@ -36,12 +36,12 @@ export const CostTooltip = ({
<HStack> <HStack>
<VStack w="28" spacing={1}> <VStack w="28" spacing={1}>
<Text>Prompt</Text> <Text>Prompt</Text>
<Text>{promptTokens ?? 0}</Text> <Text>{inputTokens ?? 0}</Text>
</VStack> </VStack>
<Divider borderColor="gray.200" h={8} orientation="vertical" /> <Divider borderColor="gray.200" h={8} orientation="vertical" />
<VStack w="28" spacing={1}> <VStack w="28" spacing={1}>
<Text whiteSpace="nowrap">Completion</Text> <Text whiteSpace="nowrap">Completion</Text>
<Text>{completionTokens ?? 0}</Text> <Text>{outputTokens ?? 0}</Text>
</VStack> </VStack>
</HStack> </HStack>
</VStack> </VStack>

View File

@@ -28,6 +28,10 @@ const modelProvider: AnthropicProvider = {
inputSchema: inputSchema as JSONSchema4, inputSchema: inputSchema as JSONSchema4,
canStream: true, canStream: true,
getCompletion, getCompletion,
getUsage: (input, output) => {
// TODO: add usage logic
return null;
},
...frontendModelProvider, ...frontendModelProvider,
}; };

View File

@@ -4,14 +4,10 @@ import {
type ChatCompletion, type ChatCompletion,
type CompletionCreateParams, type CompletionCreateParams,
} from "openai/resources/chat"; } from "openai/resources/chat";
import { countOpenAIChatTokens } from "~/utils/countTokens";
import { type CompletionResponse } from "../types"; 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 { truthyFilter } from "~/utils/utils";
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,
@@ -60,9 +56,6 @@ export async function getCompletion(
): Promise<CompletionResponse<ChatCompletion>> { ): Promise<CompletionResponse<ChatCompletion>> {
const start = Date.now(); const start = Date.now();
let finalCompletion: ChatCompletion | null = null; let finalCompletion: ChatCompletion | null = null;
let promptTokens: number | undefined = undefined;
let completionTokens: number | undefined = undefined;
const modelName = modelProvider.getModel(input) as SupportedModel;
try { try {
if (onStream) { if (onStream) {
@@ -86,16 +79,6 @@ export async function getCompletion(
autoRetry: false, autoRetry: false,
}; };
} }
try {
promptTokens = countOpenAIChatTokens(modelName, input.messages);
completionTokens = countOpenAIChatTokens(
modelName,
finalCompletion.choices.map((c) => c.message).filter(truthyFilter),
);
} catch (err) {
// TODO handle this, library seems like maybe it doesn't work with function calls?
console.error(err);
}
} else { } else {
const resp = await openai.chat.completions.create( const resp = await openai.chat.completions.create(
{ ...input, stream: false }, { ...input, stream: false },
@@ -104,25 +87,14 @@ export async function getCompletion(
}, },
); );
finalCompletion = resp; finalCompletion = resp;
promptTokens = resp.usage?.prompt_tokens ?? 0;
completionTokens = resp.usage?.completion_tokens ?? 0;
} }
const timeToComplete = Date.now() - start; const timeToComplete = Date.now() - start;
const { promptTokenPrice, completionTokenPrice } = frontendModelProvider.models[modelName];
let cost = undefined;
if (promptTokenPrice && completionTokenPrice && promptTokens && completionTokens) {
cost = promptTokens * promptTokenPrice + completionTokens * completionTokenPrice;
}
return { return {
type: "success", type: "success",
statusCode: 200, statusCode: 200,
value: finalCompletion, value: finalCompletion,
timeToComplete, timeToComplete,
promptTokens,
completionTokens,
cost,
}; };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof APIError) { if (error instanceof APIError) {

View File

@@ -4,6 +4,8 @@ import inputSchema from "./codegen/input.schema.json";
import { type ChatCompletion, type CompletionCreateParams } from "openai/resources/chat"; import { type ChatCompletion, type CompletionCreateParams } from "openai/resources/chat";
import { getCompletion } from "./getCompletion"; import { getCompletion } from "./getCompletion";
import frontendModelProvider from "./frontend"; import frontendModelProvider from "./frontend";
import { countOpenAIChatTokens } from "~/utils/countTokens";
import { truthyFilter } from "~/utils/utils";
const supportedModels = [ const supportedModels = [
"gpt-4-0613", "gpt-4-0613",
@@ -39,6 +41,41 @@ const modelProvider: OpenaiChatModelProvider = {
inputSchema: inputSchema as JSONSchema4, inputSchema: inputSchema as JSONSchema4,
canStream: true, canStream: true,
getCompletion, getCompletion,
getUsage: (input, output) => {
if (output.choices.length === 0) return null;
const model = modelProvider.getModel(input);
if (!model) return null;
let inputTokens: number;
let outputTokens: number;
if (output.usage) {
inputTokens = output.usage.prompt_tokens;
outputTokens = output.usage.completion_tokens;
} else {
try {
inputTokens = countOpenAIChatTokens(model, input.messages);
outputTokens = countOpenAIChatTokens(
model,
output.choices.map((c) => c.message).filter(truthyFilter),
);
} catch (err) {
inputTokens = 0;
outputTokens = 0;
// TODO handle this, library seems like maybe it doesn't work with function calls?
console.error(err);
}
}
const { promptTokenPrice, completionTokenPrice } = frontendModelProvider.models[model];
let cost = undefined;
if (promptTokenPrice && completionTokenPrice && inputTokens && outputTokens) {
cost = inputTokens * promptTokenPrice + outputTokens * completionTokenPrice;
}
return { inputTokens: inputTokens, outputTokens: outputTokens, cost };
},
...frontendModelProvider, ...frontendModelProvider,
}; };

View File

@@ -75,6 +75,10 @@ const modelProvider: ReplicateLlama2Provider = {
}, },
canStream: true, canStream: true,
getCompletion, getCompletion,
getUsage: (input, output) => {
// TODO: add usage logic
return null;
},
...frontendModelProvider, ...frontendModelProvider,
}; };

View File

@@ -43,9 +43,6 @@ export type CompletionResponse<T> =
value: T; value: T;
timeToComplete: number; timeToComplete: number;
statusCode: number; statusCode: number;
promptTokens?: number;
completionTokens?: number;
cost?: number;
}; };
export type ModelProvider<SupportedModels extends string, InputSchema, OutputSchema> = { export type ModelProvider<SupportedModels extends string, InputSchema, OutputSchema> = {
@@ -56,6 +53,10 @@ export type ModelProvider<SupportedModels extends string, InputSchema, OutputSch
input: InputSchema, input: InputSchema,
onStream: ((partialOutput: OutputSchema) => void) | null, onStream: ((partialOutput: OutputSchema) => void) | null,
) => Promise<CompletionResponse<OutputSchema>>; ) => Promise<CompletionResponse<OutputSchema>>;
getUsage: (
input: InputSchema,
output: OutputSchema,
) => { gpuRuntime?: number; inputTokens?: number; outputTokens?: number; cost?: number } | null;
// This is just a convenience for type inference, don't use it at runtime // This is just a convenience for type inference, don't use it at runtime
_outputSchema?: OutputSchema | null; _outputSchema?: OutputSchema | 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 { SessionIdentifier } from "~/utils/analytics/clientAnalytics"; import { PosthogAppProvider } from "~/utils/analytics/posthog";
const MyApp: AppType<{ session: Session | null }> = ({ const MyApp: AppType<{ session: Session | null }> = ({
Component, Component,
@@ -34,14 +34,15 @@ 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}>
<SyncAppStore /> <PosthogAppProvider>
<Favicon /> <SyncAppStore />
<SessionIdentifier /> <Favicon />
<ChakraThemeProvider> <ChakraThemeProvider>
<QueryParamProvider adapter={NextAdapterApp}> <QueryParamProvider adapter={NextAdapterApp}>
<Component {...pageProps} /> <Component {...pageProps} />
</QueryParamProvider> </QueryParamProvider>
</ChakraThemeProvider> </ChakraThemeProvider>
</PosthogAppProvider>
</SessionProvider> </SessionProvider>
</> </>
); );

View File

@@ -15,31 +15,16 @@ 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 LoggedCallTable from "~/components/dashboard/LoggedCallTable"; import LoggedCallsTable from "~/components/dashboard/LoggedCallsTable";
import UsageGraph from "~/components/dashboard/UsageGraph";
export default function LoggedCalls() { export default function Dashboard() {
const { data: selectedProject } = useSelectedProject(); const { data: selectedProject } = useSelectedProject();
const stats = api.dashboard.stats.useQuery( const stats = api.dashboard.stats.useQuery(
@@ -47,79 +32,27 @@ export default function LoggedCalls() {
{ 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 requireAuth> <AppShell title="Dashboard" requireAuth>
<PageHeaderContainer> <VStack px={8} py={8} alignItems="flex-start" spacing={4}>
<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">
{selectedProject?.name} Dashboard
</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 variant="outline" flex={1}> <Card flex={1}>
<CardHeader> <CardHeader>
<Heading as="h3" size="sm"> <Heading as="h3" size="sm">
Usage Statistics Usage Statistics
</Heading> </Heading>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<ResponsiveContainer width="100%" height={400}> <UsageGraph />
<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 variant="outline"> <Card>
<CardBody> <CardBody>
<Stat> <Stat>
<HStack> <HStack>
@@ -127,12 +60,12 @@ export default function LoggedCalls() {
<Icon as={DollarSign} boxSize={4} color="gray.500" /> <Icon as={DollarSign} boxSize={4} color="gray.500" />
</HStack> </HStack>
<StatNumber> <StatNumber>
${parseFloat(stats.data?.totals?.totalCost?.toString() ?? "0").toFixed(2)} ${parseFloat(stats.data?.totals?.cost?.toString() ?? "0").toFixed(3)}
</StatNumber> </StatNumber>
</Stat> </Stat>
</CardBody> </CardBody>
</Card> </Card>
<Card variant="outline"> <Card>
<CardBody> <CardBody>
<Stat> <Stat>
<HStack> <HStack>
@@ -147,7 +80,7 @@ export default function LoggedCalls() {
</Stat> </Stat>
</CardBody> </CardBody>
</Card> </Card>
<Card variant="outline" overflow="hidden"> <Card overflow="hidden">
<Stat> <Stat>
<CardHeader> <CardHeader>
<HStack> <HStack>
@@ -173,7 +106,7 @@ export default function LoggedCalls() {
</Card> </Card>
</VStack> </VStack>
</HStack> </HStack>
<LoggedCallTable /> <LoggedCallsTable />
</VStack> </VStack>
</VStack> </VStack>
</AppShell> </AppShell>

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

@@ -5,7 +5,6 @@ import {
type TextProps, type TextProps,
VStack, VStack,
HStack, HStack,
Input,
Button, Button,
Divider, Divider,
Icon, Icon,
@@ -21,6 +20,7 @@ import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents"; import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
import CopiableCode from "~/components/CopiableCode"; import CopiableCode from "~/components/CopiableCode";
import { DeleteProjectDialog } from "~/components/projectSettings/DeleteProjectDialog"; import { DeleteProjectDialog } from "~/components/projectSettings/DeleteProjectDialog";
import AutoResizeTextArea from "~/components/AutoResizeTextArea";
export default function Settings() { export default function Settings() {
const utils = api.useContext(); const utils = api.useContext();
@@ -38,7 +38,10 @@ export default function Settings() {
id: selectedProject.id, id: selectedProject.id,
updates: { name }, updates: { name },
}); });
await Promise.all([utils.projects.get.invalidate({ id: selectedProject.id })]); await Promise.all([
utils.projects.get.invalidate({ id: selectedProject.id }),
utils.projects.list.invalidate(),
]);
} }
}, [updateMutation, selectedProject]); }, [updateMutation, selectedProject]);
@@ -62,7 +65,7 @@ export default function Settings() {
</BreadcrumbItem> </BreadcrumbItem>
</Breadcrumb> </Breadcrumb>
</PageHeaderContainer> </PageHeaderContainer>
<VStack px={8} pt={4} alignItems="flex-start" spacing={4}> <VStack px={8} py={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
@@ -77,6 +80,7 @@ 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}
> >
@@ -84,7 +88,7 @@ export default function Settings() {
<Text fontWeight="bold" fontSize="xl"> <Text fontWeight="bold" fontSize="xl">
Display Name Display Name
</Text> </Text>
<Input <AutoResizeTextArea
w="full" w="full"
maxW={600} maxW={600}
value={name} value={name}
@@ -136,10 +140,13 @@ export default function Settings() {
variant="outline" variant="outline"
borderRadius={4} borderRadius={4}
mt={2} mt={2}
height="auto"
onClick={deleteProjectOpen.onOpen} onClick={deleteProjectOpen.onOpen}
> >
<Icon as={BsTrash} /> <Icon as={BsTrash} />
<Text>Delete {selectedProject?.name}</Text> <Text overflowWrap="break-word" whiteSpace="normal" py={2}>
Delete {selectedProject?.name}
</Text>
</HStack> </HStack>
</VStack> </VStack>
)} )}

View File

@@ -0,0 +1,34 @@
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,11 +4,15 @@ 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

@@ -3,7 +3,7 @@ import { createTRPCRouter } from "~/server/api/trpc";
import { experimentsRouter } from "./routers/experiments.router"; import { experimentsRouter } from "./routers/experiments.router";
import { scenariosRouter } from "./routers/scenarios.router"; import { scenariosRouter } from "./routers/scenarios.router";
import { scenarioVariantCellsRouter } from "./routers/scenarioVariantCells.router"; import { scenarioVariantCellsRouter } from "./routers/scenarioVariantCells.router";
import { templateVarsRouter } from "./routers/templateVariables.router"; import { scenarioVarsRouter } from "./routers/scenarioVariables.router";
import { evaluationsRouter } from "./routers/evaluations.router"; import { evaluationsRouter } from "./routers/evaluations.router";
import { worldChampsRouter } from "./routers/worldChamps.router"; import { worldChampsRouter } from "./routers/worldChamps.router";
import { datasetsRouter } from "./routers/datasets.router"; import { datasetsRouter } from "./routers/datasets.router";
@@ -11,6 +11,7 @@ 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.
@@ -22,13 +23,14 @@ export const appRouter = createTRPCRouter({
experiments: experimentsRouter, experiments: experimentsRouter,
scenarios: scenariosRouter, scenarios: scenariosRouter,
scenarioVariantCells: scenarioVariantCellsRouter, scenarioVariantCells: scenarioVariantCellsRouter,
templateVars: templateVarsRouter, scenarioVars: scenarioVarsRouter,
evaluations: evaluationsRouter, evaluations: evaluationsRouter,
worldChamps: worldChampsRouter, worldChamps: worldChampsRouter,
datasets: datasetsRouter, datasets: datasetsRouter,
datasetEntries: datasetEntries, datasetEntries: datasetEntries,
projects: projectsRouter, projects: projectsRouter,
dashboard: dashboardRouter, dashboard: dashboardRouter,
loggedCalls: loggedCallsRouter,
externalApi: externalApiRouter, externalApi: externalApiRouter,
}); });

View File

@@ -1,11 +1,12 @@
import { sql } from "kysely"; import { sql } from "kysely";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { kysely, prisma } from "~/server/db"; import { kysely } 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: publicProcedure stats: protectedProcedure
.input( .input(
z.object({ z.object({
// TODO: actually take startDate into account // TODO: actually take startDate into account
@@ -13,7 +14,8 @@ export const dashboardRouter = createTRPCRouter({
projectId: z.string(), projectId: z.string(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
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")
@@ -24,9 +26,9 @@ export const dashboardRouter = createTRPCRouter({
) )
.where("projectId", "=", input.projectId) .where("projectId", "=", input.projectId)
.select(({ fn }) => [ .select(({ fn }) => [
sql<Date>`date_trunc('day', "LoggedCallModelResponse"."startTime")`.as("period"), sql<Date>`date_trunc('day', "LoggedCallModelResponse"."requestedAt")`.as("period"),
sql<number>`count("LoggedCall"."id")::int`.as("numQueries"), sql<number>`count("LoggedCall"."id")::int`.as("numQueries"),
fn.sum(fn.coalesce("LoggedCallModelResponse.totalCost", sql<number>`0`)).as("totalCost"), fn.sum(fn.coalesce("LoggedCallModelResponse.cost", sql<number>`0`)).as("cost"),
]) ])
.groupBy("period") .groupBy("period")
.orderBy("period") .orderBy("period")
@@ -57,7 +59,7 @@ export const dashboardRouter = createTRPCRouter({
backfilledPeriods.unshift({ backfilledPeriods.unshift({
period: dayjs(dayToMatch).toDate(), period: dayjs(dayToMatch).toDate(),
numQueries: 0, numQueries: 0,
totalCost: 0, cost: 0,
}); });
} }
dayToMatch = dayToMatch.subtract(1, "day"); dayToMatch = dayToMatch.subtract(1, "day");
@@ -72,7 +74,7 @@ export const dashboardRouter = createTRPCRouter({
) )
.where("projectId", "=", input.projectId) .where("projectId", "=", input.projectId)
.select(({ fn }) => [ .select(({ fn }) => [
fn.sum(fn.coalesce("LoggedCallModelResponse.totalCost", sql<number>`0`)).as("totalCost"), fn.sum(fn.coalesce("LoggedCallModelResponse.cost", sql<number>`0`)).as("cost"),
fn.count("LoggedCall.id").as("numQueries"), fn.count("LoggedCall.id").as("numQueries"),
]) ])
.executeTakeFirst(); .executeTakeFirst();
@@ -85,8 +87,8 @@ export const dashboardRouter = createTRPCRouter({
"LoggedCall.id", "LoggedCall.id",
"LoggedCallModelResponse.originalLoggedCallId", "LoggedCallModelResponse.originalLoggedCallId",
) )
.select(({ fn }) => [fn.count("LoggedCall.id").as("count"), "respStatus as code"]) .select(({ fn }) => [fn.count("LoggedCall.id").as("count"), "statusCode as code"])
.where("respStatus", ">", 200) .where("statusCode", ">", 200)
.groupBy("code") .groupBy("code")
.orderBy("count", "desc") .orderBy("count", "desc")
.execute(); .execute();
@@ -103,16 +105,4 @@ 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,23 +4,21 @@ 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() })) .input(z.object({ datasetId: z.string(), page: z.number(), pageSize: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await requireCanViewDataset(input.datasetId, ctx); await requireCanViewDataset(input.datasetId, ctx);
const { datasetId, page } = input; const { datasetId, page, pageSize } = 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) * PAGE_SIZE, skip: (page - 1) * pageSize,
take: PAGE_SIZE, take: pageSize,
}); });
const count = await prisma.datasetEntry.count({ const count = await prisma.datasetEntry.count({
@@ -31,8 +29,6 @@ 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,
output: (modelResponse.output as Prisma.InputJsonValue) ?? undefined, respPayload: (modelResponse.respPayload as Prisma.InputJsonValue) ?? undefined,
}); });
for (const evaluation of outputEvaluations) { for (const evaluation of outputEvaluations) {
outputEvaluationsToCreate.push({ outputEvaluationsToCreate.push({

View File

@@ -7,6 +7,11 @@ 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(),
@@ -16,11 +21,6 @@ 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,11 +35,12 @@ 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({
startTime: z.number().describe("Unix timestamp in milliseconds"), requestedAt: 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())
@@ -69,15 +70,9 @@ 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: { where: { cacheKey },
cacheKey, include: { originalLoggedCall: true },
}, orderBy: { requestedAt: "desc" },
include: {
originalLoggedCall: true,
},
orderBy: {
startTime: "desc",
},
}); });
if (!existingResponse) return { respPayload: null }; if (!existingResponse) return { respPayload: null };
@@ -85,7 +80,7 @@ export const externalApiRouter = createTRPCRouter({
await prisma.loggedCall.create({ await prisma.loggedCall.create({
data: { data: {
projectId: key.projectId, projectId: key.projectId,
startTime: new Date(input.startTime), requestedAt: new Date(input.requestedAt),
cacheHit: true, cacheHit: true,
modelResponseId: existingResponse.id, modelResponseId: existingResponse.id,
}, },
@@ -102,16 +97,17 @@ 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({
startTime: z.number().describe("Unix timestamp in milliseconds"), requestedAt: z.number().describe("Unix timestamp in milliseconds"),
endTime: z.number().describe("Unix timestamp in milliseconds"), receivedAt: 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"),
respStatus: z.number().optional().describe("HTTP status code of response"), statusCode: z.number().optional().describe("HTTP status code of response"),
error: z.string().optional().describe("User-friendly error message"), errorMessage: z.string().optional().describe("User-friendly error message"),
tags: z tags: z
.record(z.string()) .record(z.string())
.optional() .optional()
@@ -122,6 +118,7 @@ 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" });
@@ -140,35 +137,41 @@ export const externalApiRouter = createTRPCRouter({
const newLoggedCallId = uuidv4(); const newLoggedCallId = uuidv4();
const newModelResponseId = uuidv4(); const newModelResponseId = uuidv4();
const usage = respPayload.success ? respPayload.data.usage : undefined; let usage;
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,
startTime: new Date(input.startTime), requestedAt: new Date(input.requestedAt),
cacheHit: false, cacheHit: false,
model,
}, },
}), }),
prisma.loggedCallModelResponse.create({ prisma.loggedCallModelResponse.create({
data: { data: {
id: newModelResponseId, id: newModelResponseId,
originalLoggedCallId: newLoggedCallId, originalLoggedCallId: newLoggedCallId,
startTime: new Date(input.startTime), requestedAt: new Date(input.requestedAt),
endTime: new Date(input.endTime), receivedAt: new Date(input.receivedAt),
reqPayload: input.reqPayload as Prisma.InputJsonValue, reqPayload: input.reqPayload as Prisma.InputJsonValue,
respPayload: input.respPayload as Prisma.InputJsonValue, respPayload: input.respPayload as Prisma.InputJsonValue,
respStatus: input.respStatus, statusCode: input.statusCode,
error: input.error, errorMessage: input.errorMessage,
durationMs: input.endTime - input.startTime, durationMs: input.receivedAt - input.requestedAt,
...(respPayload.success cacheKey: respPayload.success ? requestHash : null,
? { inputTokens: usage?.inputTokens,
cacheKey: requestHash, outputTokens: usage?.outputTokens,
inputTokens: usage ? usage.prompt_tokens : undefined, cost: usage?.cost,
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
@@ -182,24 +185,14 @@ export const externalApiRouter = createTRPCRouter({
}), }),
]); ]);
if (input.tags) { const tagsToCreate = Object.entries(input.tags ?? {}).map(([name, value]) => ({
const tagsToCreate = Object.entries(input.tags).map(([name, value]) => ({ loggedCallId: newLoggedCallId,
loggedCallId: newLoggedCallId, // sanitize tags
// sanitize tags name: name.replaceAll(/[^a-zA-Z0-9_]/g, "_"),
name: name.replaceAll(/[^a-zA-Z0-9_]/g, "_"), value,
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

@@ -0,0 +1,33 @@
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 userError from "~/server/utils/error"; import { error, success } from "~/utils/errorHandling/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,
output: { not: Prisma.AnyNull }, respPayload: { 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,
output: { respPayload: {
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,
output: { respPayload: {
not: Prisma.AnyNull, not: Prisma.AnyNull,
}, },
scenarioVariantCell: { scenarioVariantCell: {
@@ -123,13 +123,13 @@ export const promptVariantsRouter = createTRPCRouter({
}, },
_sum: { _sum: {
cost: true, cost: true,
promptTokens: true, inputTokens: true,
completionTokens: true, outputTokens: true,
}, },
}); });
const promptTokens = overallTokens._sum?.promptTokens ?? 0; const inputTokens = overallTokens._sum?.inputTokens ?? 0;
const completionTokens = overallTokens._sum?.completionTokens ?? 0; const outputTokens = overallTokens._sum?.outputTokens ?? 0;
const awaitingEvals = !!evalResults.find( const awaitingEvals = !!evalResults.find(
(result) => result.totalCount < scenarioCount * evals.length, (result) => result.totalCount < scenarioCount * evals.length,
@@ -137,8 +137,8 @@ export const promptVariantsRouter = createTRPCRouter({
return { return {
evalResults, evalResults,
promptTokens, inputTokens,
completionTokens, outputTokens,
overallCost: overallTokens._sum?.cost ?? 0, overallCost: overallTokens._sum?.cost ?? 0,
scenarioCount, scenarioCount,
outputCount, outputCount,
@@ -315,7 +315,7 @@ export const promptVariantsRouter = createTRPCRouter({
const constructedPrompt = await parsePromptConstructor(existing.promptConstructor); const constructedPrompt = await parsePromptConstructor(existing.promptConstructor);
if ("error" in constructedPrompt) { if ("error" in constructedPrompt) {
return userError(constructedPrompt.error); return error(constructedPrompt.error);
} }
const model = input.newModel const model = input.newModel
@@ -353,7 +353,7 @@ export const promptVariantsRouter = createTRPCRouter({
const parsedPrompt = await parsePromptConstructor(input.promptConstructor); const parsedPrompt = await parsePromptConstructor(input.promptConstructor);
if ("error" in parsedPrompt) { if ("error" in parsedPrompt) {
return userError(parsedPrompt.error); return error(parsedPrompt.error);
} }
// Create a duplicate with only the config changed // Create a duplicate with only the config changed
@@ -398,7 +398,7 @@ export const promptVariantsRouter = createTRPCRouter({
}); });
} }
return { status: "ok" } as const; return success();
}), }),
reorder: protectedProcedure reorder: protectedProcedure

View File

@@ -0,0 +1,143 @@
import { type TemplateVariable } from "@prisma/client";
import { sql } from "kysely";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import { kysely, prisma } from "~/server/db";
import { error, success } from "~/utils/errorHandling/standardResponses";
import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl";
export const scenarioVarsRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ experimentId: z.string(), label: z.string() }))
.mutation(async ({ input, ctx }) => {
await requireCanModifyExperiment(input.experimentId, ctx);
// Make sure there isn't an existing variable with the same name
const existingVariable = await prisma.templateVariable.findFirst({
where: {
experimentId: input.experimentId,
label: input.label,
},
});
if (existingVariable) {
return error(`A variable named ${input.label} already exists.`);
}
await prisma.templateVariable.create({
data: {
experimentId: input.experimentId,
label: input.label,
},
});
return success();
}),
rename: protectedProcedure
.input(z.object({ id: z.string(), label: z.string() }))
.mutation(async ({ input, ctx }) => {
const templateVariable = await prisma.templateVariable.findUniqueOrThrow({
where: { id: input.id },
});
await requireCanModifyExperiment(templateVariable.experimentId, ctx);
// Make sure there isn't an existing variable with the same name
const existingVariable = await prisma.templateVariable.findFirst({
where: {
experimentId: templateVariable.experimentId,
label: input.label,
},
});
if (existingVariable) {
return error(`A variable named ${input.label} already exists.`);
}
await renameTemplateVariable(templateVariable, input.label);
return success();
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const { experimentId } = await prisma.templateVariable.findUniqueOrThrow({
where: { id: input.id },
});
await requireCanModifyExperiment(experimentId, ctx);
await prisma.templateVariable.delete({ where: { id: input.id } });
}),
list: publicProcedure
.input(z.object({ experimentId: z.string() }))
.query(async ({ input, ctx }) => {
await requireCanViewExperiment(input.experimentId, ctx);
return await prisma.templateVariable.findMany({
where: {
experimentId: input.experimentId,
},
orderBy: {
createdAt: "asc",
},
select: {
id: true,
label: true,
},
});
}),
});
export const renameTemplateVariable = async (
templateVariable: TemplateVariable,
newLabel: string,
) => {
const { experimentId } = templateVariable;
await kysely.transaction().execute(async (trx) => {
await trx
.updateTable("TemplateVariable")
.set({
label: newLabel,
})
.where("id", "=", templateVariable.id)
.execute();
await sql`
CREATE TEMP TABLE "TempTestScenario" AS
SELECT *
FROM "TestScenario"
WHERE "experimentId" = ${experimentId}
-- Only copy the rows that actually have a value for the variable, no reason to churn the rest and simplifies the update.
AND "variableValues"->${templateVariable.label} IS NOT NULL
`.execute(trx);
await sql`
UPDATE "TempTestScenario"
SET "variableValues" = jsonb_set(
"variableValues",
${`{${newLabel}}`},
"variableValues"->${templateVariable.label}
) - ${templateVariable.label},
"updatedAt" = NOW(),
"id" = uuid_generate_v4()
`.execute(trx);
// Print the contents of the temp table
const results = await sql`SELECT * FROM "TempTestScenario"`.execute(trx);
console.log(results.rows);
await trx
.updateTable("TestScenario")
.set({
visible: false,
})
.where("experimentId", "=", experimentId)
.execute();
await sql`
INSERT INTO "TestScenario" (id, "variableValues", "uiId", visible, "sortIndex", "experimentId", "createdAt", "updatedAt")
SELECT * FROM "TempTestScenario";
`.execute(trx);
});
};

View File

@@ -7,15 +7,13 @@ 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() })) .input(z.object({ experimentId: z.string(), page: z.number(), pageSize: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await requireCanViewExperiment(input.experimentId, ctx); await requireCanViewExperiment(input.experimentId, ctx);
const { experimentId, page } = input; const { experimentId, page, pageSize } = input;
const scenarios = await prisma.testScenario.findMany({ const scenarios = await prisma.testScenario.findMany({
where: { where: {
@@ -23,8 +21,8 @@ export const scenariosRouter = createTRPCRouter({
visible: true, visible: true,
}, },
orderBy: { sortIndex: "asc" }, orderBy: { sortIndex: "asc" },
skip: (page - 1) * PAGE_SIZE, skip: (page - 1) * pageSize,
take: PAGE_SIZE, take: pageSize,
}); });
const count = await prisma.testScenario.count({ const count = await prisma.testScenario.count({
@@ -36,8 +34,6 @@ export const scenariosRouter = createTRPCRouter({
return { return {
scenarios, scenarios,
startIndex: (page - 1) * PAGE_SIZE + 1,
lastPage: Math.ceil(count / PAGE_SIZE),
count, count,
}; };
}), }),

View File

@@ -0,0 +1,110 @@
import { expect, it } from "vitest";
import { prisma } from "~/server/db";
import { renameTemplateVariable } from "./scenarioVariables.router";
const createExperiment = async () => {
return await prisma.experiment.create({
data: {
label: "Test Experiment",
project: {
create: {},
},
},
});
};
const createTemplateVar = async (experimentId: string, label: string) => {
return await prisma.templateVariable.create({
data: {
experimentId,
label,
},
});
};
it("renames templateVariables", async () => {
// Create experiments concurrently
const [exp1, exp2] = await Promise.all([createExperiment(), createExperiment()]);
// Create template variables concurrently
const [exp1Var, exp2Var1, exp2Var2] = await Promise.all([
createTemplateVar(exp1.id, "input1"),
createTemplateVar(exp2.id, "input1"),
createTemplateVar(exp2.id, "input2"),
]);
// Create test scenarios concurrently
const [exp1Scenario, exp2Scenario, exp2HiddenScenario] = await Promise.all([
prisma.testScenario.create({
data: {
experimentId: exp1.id,
visible: true,
variableValues: { input1: "test" },
},
}),
prisma.testScenario.create({
data: {
experimentId: exp2.id,
visible: true,
variableValues: { input1: "test1", otherInput: "otherTest" },
},
}),
prisma.testScenario.create({
data: {
experimentId: exp2.id,
visible: false,
variableValues: { otherInput: "otherTest2" },
},
}),
]);
await renameTemplateVariable(exp2Var1, "input1-renamed");
expect(await prisma.templateVariable.findUnique({ where: { id: exp2Var1.id } })).toMatchObject({
label: "input1-renamed",
});
// It shouldn't mess with unrelated experiments
expect(await prisma.testScenario.findUnique({ where: { id: exp1Scenario.id } })).toMatchObject({
visible: true,
variableValues: { input1: "test" },
});
// Make sure there are a total of 4 scenarios for exp2
expect(
await prisma.testScenario.count({
where: {
experimentId: exp2.id,
},
}),
).toBe(3);
// It shouldn't mess with the existing scenarios, except to hide them
expect(await prisma.testScenario.findUnique({ where: { id: exp2Scenario.id } })).toMatchObject({
visible: false,
variableValues: { input1: "test1", otherInput: "otherTest" },
});
// It should create a new scenario with the new variable name
const newScenario1 = await prisma.testScenario.findFirst({
where: {
experimentId: exp2.id,
variableValues: { equals: { "input1-renamed": "test1", otherInput: "otherTest" } },
},
});
expect(newScenario1).toMatchObject({
visible: true,
});
const newScenario2 = await prisma.testScenario.findFirst({
where: {
experimentId: exp2.id,
variableValues: { equals: { otherInput: "otherTest2" } },
},
});
expect(newScenario2).toMatchObject({
visible: false,
});
});

View File

@@ -1,49 +0,0 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db";
import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl";
export const templateVarsRouter = createTRPCRouter({
create: protectedProcedure
.input(z.object({ experimentId: z.string(), label: z.string() }))
.mutation(async ({ input, ctx }) => {
await requireCanModifyExperiment(input.experimentId, ctx);
await prisma.templateVariable.create({
data: {
experimentId: input.experimentId,
label: input.label,
},
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const { experimentId } = await prisma.templateVariable.findUniqueOrThrow({
where: { id: input.id },
});
await requireCanModifyExperiment(experimentId, ctx);
await prisma.templateVariable.delete({ where: { id: input.id } });
}),
list: publicProcedure
.input(z.object({ experimentId: z.string() }))
.query(async ({ input, ctx }) => {
await requireCanViewExperiment(input.experimentId, ctx);
return await prisma.templateVariable.findMany({
where: {
experimentId: input.experimentId,
},
orderBy: {
createdAt: "asc",
},
select: {
id: true,
label: true,
},
});
}),
});

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["x-openpipe-api-key"] as string | null; const apiKey = req.headers.authorization?.split(" ")[1] as string | null;
return createInnerTRPCContext({ return createInnerTRPCContext({
session, session,

View File

@@ -4,19 +4,18 @@ 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, "schema.json"); const schemaPath = path.join(clientLibsPath, "openapi.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/codegen"); const tsClientPath = path.join(clientLibsPath, "typescript/src/codegen");
fs.rmSync(tsClientPath, { recursive: true, force: true }); fs.rmSync(tsClientPath, { recursive: true, force: true });
@@ -27,6 +26,8 @@ execSync(
}, },
); );
console.log("Done!"); console.log("Generating Python client");
process.exit(0); execSync(path.join(clientLibsPath, "python/codegen.sh"));
console.log("Done!");

View File

@@ -1,63 +0,0 @@
import dayjs from "dayjs";
import { prisma } from "../db";
const projectId = "1234";
// Find all calls in the last 24 hours
const responses = await prisma.loggedCall.findMany({
where: {
projectId: projectId,
startTime: {
gt: dayjs()
.subtract(24 * 3600)
.toDate(),
},
},
include: {
modelResponse: true,
},
orderBy: {
startTime: "desc",
},
});
// Find all calls in the last 24 hours with promptId 'hello-world'
const helloWorld = await prisma.loggedCall.findMany({
where: {
projectId: projectId,
startTime: {
gt: dayjs()
.subtract(24 * 3600)
.toDate(),
},
tags: {
some: {
name: "promptId",
value: "hello-world",
},
},
},
include: {
modelResponse: true,
},
orderBy: {
startTime: "desc",
},
});
// Total spent on OpenAI in the last month
const totalSpent = await prisma.loggedCallModelResponse.aggregate({
_sum: {
totalCost: true,
},
where: {
originalLoggedCall: {
projectId: projectId,
},
startTime: {
gt: dayjs()
.subtract(30 * 24 * 3600)
.toDate(),
},
},
});

View File

@@ -99,26 +99,27 @@ export const queryModel = defineTask<QueryModelJob>("queryModel", async (task) =
} }
: null; : null;
const inputHash = hashObject(prompt as JsonValue); const cacheKey = hashObject(prompt as JsonValue);
let modelResponse = await prisma.modelResponse.create({ let modelResponse = await prisma.modelResponse.create({
data: { data: {
inputHash, cacheKey,
scenarioVariantCellId: cellId, scenarioVariantCellId: cellId,
requestedAt: new Date(), requestedAt: new Date(),
}, },
}); });
const response = await provider.getCompletion(prompt.modelInput, onStream); const response = await provider.getCompletion(prompt.modelInput, onStream);
if (response.type === "success") { if (response.type === "success") {
const usage = provider.getUsage(prompt.modelInput, response.value);
modelResponse = await prisma.modelResponse.update({ modelResponse = await prisma.modelResponse.update({
where: { id: modelResponse.id }, where: { id: modelResponse.id },
data: { data: {
output: response.value as Prisma.InputJsonObject, respPayload: response.value as Prisma.InputJsonObject,
statusCode: response.statusCode, statusCode: response.statusCode,
receivedAt: new Date(), receivedAt: new Date(),
promptTokens: response.promptTokens, inputTokens: usage?.inputTokens,
completionTokens: response.completionTokens, outputTokens: usage?.outputTokens,
cost: response.cost, cost: usage?.cost,
}, },
}); });

View File

@@ -1,6 +0,0 @@
export default function userError(message: string): { status: "error"; message: string } {
return {
status: "error",
message,
};
}

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,
output: { respPayload: {
not: Prisma.AnyNull, not: Prisma.AnyNull,
}, },
scenarioVariantCell: { scenarioVariantCell: {

View File

@@ -57,7 +57,7 @@ export const generateNewCell = async (
return; return;
} }
const inputHash = hashObject(parsedConstructFn); const cacheKey = 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: {
inputHash, cacheKey,
output: { respPayload: {
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,
output: matchingModelResponse.output as Prisma.InputJsonValue, respPayload: matchingModelResponse.respPayload as Prisma.InputJsonValue,
}, },
}); });

View File

@@ -1,13 +1,24 @@
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";
import { default as OriginalOpenAI } from "openai"; let config: ClientOptions;
// import { OpenAI } from "openpipe";
const openAIConfig = { apiKey: env.OPENAI_API_KEY ?? "dummy-key" }; try {
// 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" };
}
// Set a dummy key so it doesn't fail at build time // export const openai = env.OPENPIPE_API_KEY ? new OpenAI.OpenAI(config) : new OriginalOpenAI(config);
// export const openai = env.OPENPIPE_API_KEY
// ? new OpenAI.OpenAI(openAIConfig)
// : new OriginalOpenAI(openAIConfig);
export const openai = new OriginalOpenAI(openAIConfig); export const openai = new OpenAI(config);

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.output); const message = modelProvider.normalizeOutput(modelResponse.respPayload);
if (!message) return { result: 0 }; if (!message) return { result: 0 };

View File

@@ -1,5 +1,5 @@
import { PersistOptions } from "zustand/middleware/persist"; import { type PersistOptions } from "zustand/middleware/persist";
import { State } from "./store"; import { type State } from "./store";
export const stateToPersist = { export const stateToPersist = {
selectedProjectId: null as string | null, selectedProjectId: null as string | null,

View File

@@ -0,0 +1,30 @@
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

@@ -8,9 +8,9 @@ export const editorBackground = "#fafafa";
export type SharedVariantEditorSlice = { export type SharedVariantEditorSlice = {
monaco: null | ReturnType<typeof loader.__getMonacoInstance>; monaco: null | ReturnType<typeof loader.__getMonacoInstance>;
loadMonaco: () => Promise<void>; loadMonaco: () => Promise<void>;
scenarios: RouterOutputs["scenarios"]["list"]["scenarios"]; scenarioVars: RouterOutputs["scenarioVars"]["list"];
updateScenariosModel: () => void; updateScenariosModel: () => void;
setScenarios: (scenarios: RouterOutputs["scenarios"]["list"]["scenarios"]) => void; setScenarioVars: (scenarioVars: RouterOutputs["scenarioVars"]["list"]) => void;
}; };
export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> = (set, get) => ({ export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> = (set, get) => ({
@@ -60,10 +60,10 @@ export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> =
}); });
get().sharedVariantEditor.updateScenariosModel(); get().sharedVariantEditor.updateScenariosModel();
}, },
scenarios: [], scenarioVars: [],
setScenarios: (scenarios) => { setScenarioVars: (scenarios) => {
set((state) => { set((state) => {
state.sharedVariantEditor.scenarios = scenarios; state.sharedVariantEditor.scenarioVars = scenarios;
}); });
get().sharedVariantEditor.updateScenariosModel(); get().sharedVariantEditor.updateScenariosModel();
@@ -74,14 +74,11 @@ export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> =
if (!monaco) return; if (!monaco) return;
const modelContents = ` const modelContents = `
const scenarios = ${JSON.stringify( declare var scenario: {
get().sharedVariantEditor.scenarios.map((s) => s.variableValues), ${get()
null, .sharedVariantEditor.scenarioVars.map((s) => `${s.label}: string;`)
2, .join("\n")}
)} as const; };
type Scenario = typeof scenarios[number];
declare var scenario: Scenario | { [key: string]: string };
`; `;
const scenariosModel = monaco.editor.getModel(monaco.Uri.parse("file:///scenarios.ts")); const scenariosModel = monaco.editor.getModel(monaco.Uri.parse("file:///scenarios.ts"));

View File

@@ -1,5 +1,6 @@
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 {
@@ -7,7 +8,10 @@ import {
createVariantEditorSlice, createVariantEditorSlice,
} from "./sharedVariantEditor.slice"; } from "./sharedVariantEditor.slice";
import { type APIClient } from "~/utils/api"; import { type APIClient } from "~/utils/api";
import { persistOptions, 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;
@@ -17,7 +21,8 @@ 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>;
@@ -48,10 +53,11 @@ 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

@@ -1,16 +1,16 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useScenarios } from "~/utils/hooks"; import { useScenarioVars } from "~/utils/hooks";
import { useAppStore } from "./store"; import { useAppStore } from "./store";
export function useSyncVariantEditor() { export function useSyncVariantEditor() {
const scenarios = useScenarios(); const scenarioVars = useScenarioVars();
useEffect(() => { useEffect(() => {
if (scenarios.data) { if (scenarioVars.data) {
useAppStore.getState().sharedVariantEditor.setScenarios(scenarios.data.scenarios); useAppStore.getState().sharedVariantEditor.setScenarioVars(scenarioVars.data);
} }
}, [scenarios.data]); }, [scenarioVars.data]);
} }
export function SyncAppStore() { export function SyncAppStore() {

View File

@@ -0,0 +1,5 @@
import { configDotenv } from "dotenv";
configDotenv({
path: ".env.test",
});

View File

@@ -0,0 +1,13 @@
import "./loadEnv";
import { sql } from "kysely";
import { beforeEach } from "vitest";
import { kysely } from "~/server/db";
// Reset all Prisma data
const resetDb = async () => {
await sql`truncate "Experiment" cascade;`.execute(kysely);
};
beforeEach(async () => {
await resetDb();
});

View File

@@ -1,4 +1,9 @@
import { extendTheme, defineStyleConfig, ChakraProvider } from "@chakra-ui/react"; import {
extendTheme,
defineStyleConfig,
ChakraProvider,
createStandaloneToast,
} from "@chakra-ui/react";
import "@fontsource/inconsolata"; import "@fontsource/inconsolata";
import { modalAnatomy } from "@chakra-ui/anatomy"; import { modalAnatomy } from "@chakra-ui/anatomy";
import { createMultiStyleConfigHelpers } from "@chakra-ui/styled-system"; import { createMultiStyleConfigHelpers } from "@chakra-ui/styled-system";
@@ -63,6 +68,15 @@ const theme = extendTheme({
}, },
}); });
const { ToastContainer, toast } = createStandaloneToast(theme);
export { toast };
export const ChakraThemeProvider = ({ children }: { children: JSX.Element }) => { export const ChakraThemeProvider = ({ children }: { children: JSX.Element }) => {
return <ChakraProvider theme={theme}>{children}</ChakraProvider>; return (
<ChakraProvider theme={theme}>
<ToastContainer />
{children}
</ChakraProvider>
);
}; };

View File

@@ -1,31 +0,0 @@
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

@@ -0,0 +1,41 @@
"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

@@ -0,0 +1,20 @@
import { toast } from "~/theme/ChakraThemeProvider";
import { type error, type success } from "./standardResponses";
type SuccessType<T> = ReturnType<typeof success<T>>;
type ErrorType = ReturnType<typeof error>;
// Used client-side to report generic errors
export function maybeReportError<T>(response: SuccessType<T> | ErrorType): response is ErrorType {
if (response.status === "error") {
toast({
description: response.message,
status: "error",
duration: 5000,
isClosable: true,
});
return true;
}
return false;
}

View File

@@ -0,0 +1,11 @@
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, useQueryParam, withDefault } from "use-query-params"; import { NumberParam, useQueryParams } 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] = usePage(); const { page, pageSize } = usePageParams();
return api.datasetEntries.list.useQuery( return api.datasetEntries.list.useQuery(
{ datasetId: dataset.data?.id ?? "", page }, { datasetId: dataset.data?.id ?? "", page, pageSize },
{ enabled: dataset.data?.id != null }, { enabled: dataset.data?.id != null },
); );
}; };
@@ -132,14 +132,23 @@ export const useElementDimensions = (): [RefObject<HTMLElement>, Dimensions | un
return [ref, dimensions]; return [ref, dimensions];
}; };
export const usePage = () => useQueryParam("page", withDefault(NumberParam, 1)); export const usePageParams = () => {
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] = usePage(); const { page, pageSize } = usePageParams();
return api.scenarios.list.useQuery( return api.scenarios.list.useQuery(
{ experimentId: experiment.data?.id ?? "", page }, { experimentId: experiment.data?.id ?? "", page, pageSize },
{ enabled: experiment.data?.id != null }, { enabled: experiment.data?.id != null },
); );
}; };
@@ -157,3 +166,22 @@ export const useSelectedProject = () => {
{ enabled: !!selectedProjectId }, { enabled: !!selectedProjectId },
); );
}; };
export const useScenarioVars = () => {
const experiment = useExperiment();
return api.scenarioVars.list.useQuery(
{ experimentId: experiment.data?.id ?? "" },
{ 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

@@ -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"]
} }

View File

@@ -4,6 +4,10 @@ import { configDefaults, defineConfig, type UserConfig } from "vitest/config";
const config = defineConfig({ const config = defineConfig({
test: { test: {
...configDefaults, // Extending Vitest's default options ...configDefaults, // Extending Vitest's default options
setupFiles: ["./src/tests/helpers/setup.ts"],
// Unfortunately using threads seems to cause issues with isolated-vm
threads: false,
}, },
plugins: [tsconfigPaths()], plugins: [tsconfigPaths()],
}) as UserConfig; }) as UserConfig;

View File

@@ -15,6 +15,11 @@
"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": {
@@ -22,7 +27,7 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"startTime": { "requestedAt": {
"type": "number", "type": "number",
"description": "Unix timestamp in milliseconds" "description": "Unix timestamp in milliseconds"
}, },
@@ -38,7 +43,7 @@
} }
}, },
"required": [ "required": [
"startTime" "requestedAt"
], ],
"additionalProperties": false "additionalProperties": false
} }
@@ -73,6 +78,11 @@
"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": {
@@ -80,11 +90,11 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"startTime": { "requestedAt": {
"type": "number", "type": "number",
"description": "Unix timestamp in milliseconds" "description": "Unix timestamp in milliseconds"
}, },
"endTime": { "receivedAt": {
"type": "number", "type": "number",
"description": "Unix timestamp in milliseconds" "description": "Unix timestamp in milliseconds"
}, },
@@ -94,11 +104,11 @@
"respPayload": { "respPayload": {
"description": "JSON-encoded response payload" "description": "JSON-encoded response payload"
}, },
"respStatus": { "statusCode": {
"type": "number", "type": "number",
"description": "HTTP status code of response" "description": "HTTP status code of response"
}, },
"error": { "errorMessage": {
"type": "string", "type": "string",
"description": "User-friendly error message" "description": "User-friendly error message"
}, },
@@ -111,8 +121,8 @@
} }
}, },
"required": [ "required": [
"startTime", "requestedAt",
"endTime" "receivedAt"
], ],
"additionalProperties": false "additionalProperties": false
} }

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