diff --git a/app/@types/nextjs-routes.d.ts b/app/@types/nextjs-routes.d.ts index 63efc55..32c34bd 100644 --- a/app/@types/nextjs-routes.d.ts +++ b/app/@types/nextjs-routes.d.ts @@ -12,8 +12,10 @@ declare module "nextjs-routes" { export type Route = | StaticRoute<"/account/signin"> + | DynamicRoute<"/api/[...trpc]", { "trpc": string[] }> | DynamicRoute<"/api/auth/[...nextauth]", { "nextauth": string[] }> | StaticRoute<"/api/experiments/og-image"> + | StaticRoute<"/api/openapi"> | StaticRoute<"/api/sentry-example-api"> | DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }> | DynamicRoute<"/data/[id]", { "id": string }> diff --git a/app/package.json b/app/package.json index 1855f60..3a0dcaa 100644 --- a/app/package.json +++ b/app/package.json @@ -66,6 +66,7 @@ "next": "^13.4.2", "next-auth": "^4.22.1", "next-query-params": "^4.2.3", + "nextjs-cors": "^2.1.2", "nextjs-routes": "^2.0.1", "openai": "4.0.0-beta.7", "pluralize": "^8.0.0", @@ -87,6 +88,7 @@ "socket.io": "^4.7.1", "socket.io-client": "^4.7.1", "superjson": "1.12.2", + "trpc-openapi": "^1.2.0", "tsx": "^3.12.7", "type-fest": "^4.0.0", "use-query-params": "^2.2.1", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 91855f5..0575dfe 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -137,6 +137,9 @@ dependencies: next-query-params: specifier: ^4.2.3 version: 4.2.3(next@13.4.2)(react@18.2.0)(use-query-params@2.2.1) + nextjs-cors: + specifier: ^2.1.2 + version: 2.1.2(next@13.4.2) nextjs-routes: specifier: ^2.0.1 version: 2.0.1(next@13.4.2) @@ -200,6 +203,9 @@ dependencies: superjson: specifier: 1.12.2 version: 1.12.2 + trpc-openapi: + specifier: ^1.2.0 + version: 1.2.0(@trpc/server@10.26.0)(zod@3.21.4) tsx: specifier: ^3.12.7 version: 3.12.7 @@ -4144,6 +4150,15 @@ packages: wrap-ansi: 7.0.0 dev: false + /co-body@6.1.0: + resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==} + dependencies: + inflation: 2.0.0 + qs: 6.11.0 + raw-body: 2.5.1 + type-is: 1.6.18 + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -4235,6 +4250,10 @@ packages: /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + /cookie-es@1.0.0: + resolution: {integrity: sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ==} + dev: false + /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: false @@ -4462,11 +4481,20 @@ packages: has-property-descriptors: 1.0.0 object-keys: 1.1.1 + /defu@6.1.2: + resolution: {integrity: sha512-+uO4+qr7msjNNWKYPHqN/3+Dx3NFkmIzayk2L1MyZQlvgZb/J1A0fo410dpKrN2SnqFjt8n4JL8fDJE0wIgjFQ==} + dev: false + /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} dev: false + /depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: false + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4477,6 +4505,10 @@ packages: engines: {node: '>=6'} dev: true + /destr@2.0.1: + resolution: {integrity: sha512-M1Ob1zPSIvlARiJUkKqvAZ3VAqQY6Jcuth/pBKQ2b1dX/Qx0OnJ8Vux6J2H5PTMQeRzWrrbTu70VxBfv/OPDJA==} + dev: false + /destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -5636,6 +5668,18 @@ packages: - pg-native dev: false + /h3@1.7.1: + resolution: {integrity: sha512-A9V2NEDNHet7v1gCg7CMwerSigLi0SRbhTy7C3lGb0N4YKIpPmLDjedTUopqp4dnn7COHfqUjjaz3zbtz4QduA==} + dependencies: + cookie-es: 1.0.0 + defu: 6.1.2 + destr: 2.0.1 + iron-webcrypto: 0.7.1 + radix3: 1.0.1 + ufo: 1.1.2 + uncrypto: 0.1.3 + dev: false + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -5782,6 +5826,11 @@ packages: engines: {node: '>=0.8.19'} dev: true + /inflation@2.0.0: + resolution: {integrity: sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==} + engines: {node: '>= 0.8.0'} + dev: false + /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} dependencies: @@ -5811,6 +5860,10 @@ packages: engines: {node: '>= 0.10'} dev: false + /iron-webcrypto@0.7.1: + resolution: {integrity: sha512-K/UmlEhPCPXEHV5hAtH5C0tI5JnFuOrv4yO/j7ODPl3HaiiHBLbOLTde+ieUaAyfCATe4LoAnclyF+hmSCOVmQ==} + dev: false + /is-alphabetical@1.0.4: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} dev: false @@ -6568,6 +6621,15 @@ packages: - babel-plugin-macros dev: false + /nextjs-cors@2.1.2(next@13.4.2): + resolution: {integrity: sha512-2yOVivaaf2ILe4f/qY32hnj3oC77VCOsUQJQfhVMGsXE/YMEWUY2zy78sH9FKUCM7eG42/l3pDofIzMD781XGA==} + peerDependencies: + next: ^8.1.1-canary.54 || ^9.0.0 || ^10.0.0-0 || ^11.0.0 || ^12.0.0 || ^13.0.0 + dependencies: + cors: 2.8.5 + next: 13.4.2(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) + dev: false + /nextjs-routes@2.0.1(next@13.4.2): resolution: {integrity: sha512-pBGRm6uR44zwUjWWYn6+gwz08BhBbqUYlIzsbNHAh1TWohHYKWFaa2YVsj8BxEo726MZYg87OJPnHpaaY1ia0w==} hasBin: true @@ -6595,6 +6657,22 @@ packages: whatwg-url: 5.0.0 dev: false + /node-mocks-http@1.12.2: + resolution: {integrity: sha512-xhWwC0dh35R9rf0j3bRZXuISXdHxxtMx0ywZQBwjrg3yl7KpRETzogfeCamUIjltpn0Fxvs/ZhGJul1vPLrdJQ==} + engines: {node: '>=0.6'} + dependencies: + accepts: 1.3.8 + content-disposition: 0.5.4 + depd: 1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.1 + methods: 1.1.2 + mime: 1.6.0 + parseurl: 1.3.3 + range-parser: 1.2.1 + type-is: 1.6.18 + dev: false + /node-releases@2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} @@ -6754,6 +6832,10 @@ packages: - supports-color dev: false + /openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + dev: false + /openapi-typescript@5.4.1: resolution: {integrity: sha512-AGB2QiZPz4rE7zIwV3dRHtoUC/CWHhUjuzGXvtmMQN2AFV8xCTLKcZUHLcdPQmt/83i22nRE7+TxXOXkK+gf4Q==} engines: {node: '>= 14.0.0'} @@ -7203,6 +7285,10 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /radix3@1.0.1: + resolution: {integrity: sha512-y+AcwZ3HcUIGc9zGsNVf5+BY/LxL+z+4h4J3/pp8jxSmy1STaCocPS3qrj4tA5ehUSzqtqK+0Aygvz/r/8vy4g==} + dev: false + /randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: @@ -8180,6 +8266,22 @@ packages: hasBin: true dev: false + /trpc-openapi@1.2.0(@trpc/server@10.26.0)(zod@3.21.4): + resolution: {integrity: sha512-pfYoCd/3KYXWXvUPZBKJw455OOwngKN/6SIcj7Yit19OMLJ+8yVZkEvGEeg5wUSwfsiTdRsKuvqkRPXVSwV7ew==} + peerDependencies: + '@trpc/server': ^10.0.0 + zod: ^3.14.4 + dependencies: + '@trpc/server': 10.26.0 + co-body: 6.1.0 + h3: 1.7.1 + lodash.clonedeep: 4.5.0 + node-mocks-http: 1.12.2 + openapi-types: 12.1.3 + zod: 3.21.4 + zod-to-json-schema: 3.21.4(zod@3.21.4) + dev: false + /tsconfck@2.1.2(typescript@5.0.4): resolution: {integrity: sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==} engines: {node: ^14.13.1 || ^16 || >=18} @@ -8322,7 +8424,6 @@ packages: /ufo@1.1.2: resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==} - dev: true /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -8333,6 +8434,10 @@ packages: which-boxed-primitive: 1.0.2 dev: true + /uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + dev: false + /undici@5.22.1: resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} engines: {node: '>=14.0'} @@ -8861,6 +8966,14 @@ packages: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} dev: false + /zod-to-json-schema@3.21.4(zod@3.21.4): + resolution: {integrity: sha512-fjUZh4nQ1s6HMccgIeE0VP4QG/YRGPmyjO9sAh890aQKPEk3nqbfUXhMFaC+Dr5KvYBm8BCyvfpZf2jY9aGSsw==} + peerDependencies: + zod: ^3.21.4 + dependencies: + zod: 3.21.4 + dev: false + /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} dev: false diff --git a/app/prisma/migrations/20230806024615_add_logged_calls_and_api_keys/migration.sql b/app/prisma/migrations/20230806024615_add_logged_calls_and_api_keys/migration.sql new file mode 100644 index 0000000..4611bab --- /dev/null +++ b/app/prisma/migrations/20230806024615_add_logged_calls_and_api_keys/migration.sql @@ -0,0 +1,90 @@ +-- CreateTable +CREATE TABLE "LoggedCall" ( + "id" UUID NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "cacheHit" BOOLEAN NOT NULL, + "modelResponseId" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LoggedCall_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LoggedCallModelResponse" ( + "id" UUID NOT NULL, + "reqPayload" JSONB NOT NULL, + "respStatus" INTEGER, + "respPayload" JSONB, + "error" TEXT, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3) NOT NULL, + "cacheKey" TEXT, + "durationMs" INTEGER, + "inputTokens" INTEGER, + "outputTokens" INTEGER, + "finishReason" TEXT, + "completionId" TEXT, + "totalCost" DECIMAL(18,12), + "originalLoggedCallId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LoggedCallModelResponse_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LoggedCallTag" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT, + "loggedCallId" UUID NOT NULL, + + CONSTRAINT "LoggedCallTag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LoggedCall_startTime_idx" ON "LoggedCall"("startTime"); + +-- CreateIndex +CREATE UNIQUE INDEX "LoggedCallModelResponse_originalLoggedCallId_key" ON "LoggedCallModelResponse"("originalLoggedCallId"); + +-- CreateIndex +CREATE INDEX "LoggedCallModelResponse_cacheKey_idx" ON "LoggedCallModelResponse"("cacheKey"); + +-- CreateIndex +CREATE INDEX "LoggedCallTag_name_idx" ON "LoggedCallTag"("name"); + +-- CreateIndex +CREATE INDEX "LoggedCallTag_name_value_idx" ON "LoggedCallTag"("name", "value"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_apiKey_key" ON "ApiKey"("apiKey"); + +-- AddForeignKey +ALTER TABLE "LoggedCall" ADD CONSTRAINT "LoggedCall_modelResponseId_fkey" FOREIGN KEY ("modelResponseId") REFERENCES "LoggedCallModelResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoggedCall" ADD CONSTRAINT "LoggedCall_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoggedCallModelResponse" ADD CONSTRAINT "LoggedCallModelResponse_originalLoggedCallId_fkey" FOREIGN KEY ("originalLoggedCallId") REFERENCES "LoggedCall"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoggedCallTag" ADD CONSTRAINT "LoggedCallTag_loggedCallId_fkey" FOREIGN KEY ("loggedCallId") REFERENCES "LoggedCall"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 9c5c92e..7ed4bfe 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -260,13 +260,13 @@ model LoggedCall { cacheHit Boolean // A LoggedCall is always associated with a LoggedCallModelResponse. If this - // is a cache miss, it's a new LoggedCallModelResponse we created for this. - // If it's a cache hit, it's the existing LoggedCallModelResponse we served. + // is a cache miss, we create a new LoggedCallModelResponse. + // If it's a cache hit, it's a pre-existing LoggedCallModelResponse. modelResponseId String @db.Uuid modelResponse LoggedCallModelResponse @relation(fields: [modelResponseId], references: [id], onDelete: Cascade) // The response created by this LoggedCall. Will be null if this LoggedCall is a cache hit. - createdResponse LoggedCallModelResponse[] @relation(name: "ModelResponseCreatedBy") + createdResponse LoggedCallModelResponse[] @relation(name: "ModelResponseOriginalCall") organizationId String @db.Uuid organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@ -309,8 +309,8 @@ model LoggedCallModelResponse { totalCost Decimal? @db.Decimal(18, 12) // The LoggedCall that created this LoggedCallModelResponse - createdById String @unique @db.Uuid - createdBy LoggedCall @relation(name: "ModelResponseCreatedBy", fields: [createdById], references: [id], onDelete: Cascade) + originalLoggedCallId String @unique @db.Uuid + originalLoggedCall LoggedCall @relation(name: "ModelResponseOriginalCall", fields: [originalLoggedCallId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -320,11 +320,11 @@ model LoggedCallModelResponse { } model LoggedCallTag { - id String @id @default(cuid()) + id String @id @default(uuid()) @db.Uuid name String value String? - loggedCallId String + loggedCallId String @db.Uuid loggedCall LoggedCall @relation(fields: [loggedCallId], references: [id], onDelete: Cascade) @@index([name]) diff --git a/app/src/pages/api/[...trpc].ts b/app/src/pages/api/[...trpc].ts new file mode 100644 index 0000000..4751e6c --- /dev/null +++ b/app/src/pages/api/[...trpc].ts @@ -0,0 +1,22 @@ +import { type NextApiRequest, type NextApiResponse } from "next"; +import cors from "nextjs-cors"; +import { createOpenApiNextHandler } from "trpc-openapi"; +import { createProcedureCache } from "trpc-openapi/dist/adapters/node-http/procedures"; +import { appRouter } from "~/server/api/root.router"; +import { createTRPCContext } from "~/server/api/trpc"; + +const openApiHandler = createOpenApiNextHandler({ + router: appRouter, + createContext: createTRPCContext, +}); + +const cache = createProcedureCache(appRouter); + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + // Setup CORS + await cors(req, res); + + return openApiHandler(req, res); +}; + +export default handler; diff --git a/app/src/pages/api/openapi.json.ts b/app/src/pages/api/openapi.json.ts new file mode 100644 index 0000000..c02ca8e --- /dev/null +++ b/app/src/pages/api/openapi.json.ts @@ -0,0 +1,16 @@ +import { type NextApiRequest, type NextApiResponse } from "next"; +import { generateOpenApiDocument } from "trpc-openapi"; +import { appRouter } from "~/server/api/root.router"; + +export const openApiDocument = generateOpenApiDocument(appRouter, { + title: "OpenPipe API", + description: "The public API for reporting API calls to OpenPipe", + version: "0.1.0", + baseUrl: "https://app.openpipe.ai/api", +}); +// Respond with our OpenAPI schema +const hander = (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).send(openApiDocument); +}; + +export default hander; diff --git a/app/src/server/api/routers/externalApi.router.ts b/app/src/server/api/routers/externalApi.router.ts new file mode 100644 index 0000000..d2bd363 --- /dev/null +++ b/app/src/server/api/routers/externalApi.router.ts @@ -0,0 +1,197 @@ +import { type Prisma } from "@prisma/client"; +import { type JsonValue } from "type-fest"; +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; + +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { prisma } from "~/server/db"; +import { hashRequest } from "~/server/utils/hashObject"; + +const reqValidator = z.object({ + model: z.string(), + messages: z.array(z.any()), +}); + +const respValidator = z.object({ + id: z.string(), + model: z.string(), + usage: z.object({ + total_tokens: z.number(), + prompt_tokens: z.number(), + completion_tokens: z.number(), + }), + choices: z.array( + z.object({ + finish_reason: z.string(), + }), + ), +}); + +export const externalApiRouter = createTRPCRouter({ + checkCache: publicProcedure + .meta({ + openapi: { + method: "POST", + path: "/v1/check-cache", + description: "Check if a prompt is cached", + }, + }) + .input( + z.object({ + startTime: z.number().describe("Unix timestamp in milliseconds"), + reqPayload: z.unknown().describe("JSON-encoded request payload"), + tags: z + .record(z.string()) + .optional() + .describe( + 'Extra tags to attach to the call for filtering. Eg { "userId": "123", "promptId": "populate-title" }', + ), + }), + ) + .output( + z.object({ + respPayload: z.unknown().optional().describe("JSON-encoded response payload"), + }), + ) + .mutation(async ({ input, ctx }) => { + const apiKey = ctx.apiKey; + if (!apiKey) { + throw new Error("Missing API key"); + } + const key = await prisma.apiKey.findUnique({ + where: { apiKey }, + }); + if (!key) { + throw new Error("Invalid API key"); + } + const reqPayload = await reqValidator.spa(input.reqPayload); + const cacheKey = hashRequest(key.organizationId, reqPayload as JsonValue); + + const existingResponse = await prisma.loggedCallModelResponse.findFirst({ + where: { + cacheKey, + }, + include: { + originalLoggedCall: true, + }, + orderBy: { + startTime: "desc", + } + }); + + if (!existingResponse) return { respPayload: null }; + + await prisma.loggedCall.create({ + data: { + organizationId: key.organizationId, + startTime: new Date(input.startTime), + cacheHit: false, + modelResponseId: existingResponse.id, + } + }) + + return { + respPayload: existingResponse.respPayload, + }; + }), + + report: publicProcedure + .meta({ + openapi: { + method: "POST", + path: "/v1/report", + description: "Report an API call", + }, + }) + .input( + z.object({ + startTime: z.number().describe("Unix timestamp in milliseconds"), + endTime: z.number().describe("Unix timestamp in milliseconds"), + reqPayload: z.unknown().describe("JSON-encoded request payload"), + respPayload: z.unknown().optional().describe("JSON-encoded response payload"), + respStatus: z.number().optional().describe("HTTP status code of response"), + error: z.string().optional().describe("User-friendly error message"), + tags: z + .record(z.string()) + .optional() + .describe( + 'Extra tags to attach to the call for filtering. Eg { "userId": "123", "promptId": "populate-title" }', + ), + }), + ) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + const apiKey = ctx.apiKey; + if (!apiKey) { + throw new Error("Missing API key"); + } + const key = await prisma.apiKey.findUnique({ + where: { apiKey }, + }); + if (!key) { + throw new Error("Invalid API key"); + } + const reqPayload = await reqValidator.spa(input.reqPayload); + const respPayload = await respValidator.spa(input.respPayload); + + const requestHash = hashRequest(key.organizationId, reqPayload as JsonValue); + + const newLoggedCallId = uuidv4(); + const newModelResponseId = uuidv4(); + + const usage = respPayload.success ? respPayload.data.usage : undefined; + + await prisma.$transaction([ + prisma.loggedCall.create({ + data: { + id: newLoggedCallId, + organizationId: key.organizationId, + startTime: new Date(input.startTime), + cacheHit: false, + modelResponseId: newModelResponseId, + }, + }), + prisma.loggedCallModelResponse.create({ + data: { + id: newModelResponseId, + originalLoggedCallId: newLoggedCallId, + startTime: new Date(input.startTime), + endTime: new Date(input.endTime), + reqPayload: input.reqPayload as Prisma.InputJsonValue, + respPayload: input.respPayload as Prisma.InputJsonValue, + respStatus: input.respStatus, + error: input.error, + durationMs: input.endTime - input.startTime, + ...(respPayload.success + ? { + cacheKey: requestHash, + inputTokens: usage ? usage.prompt_tokens : undefined, + outputTokens: usage ? usage.completion_tokens : undefined, + model: respPayload.data.model, + } + : null), + }, + }), + ]); + + if (input.tags) { + const tagsToCreate = Object.entries(input.tags).map(([name, value]) => ({ + loggedCallId: newLoggedCallId, + // sanitize tags + name: name.replaceAll(/[^a-zA-Z0-9_]/g, "_"), + value, + })); + + if (reqPayload.success) { + tagsToCreate.push({ + loggedCallId: newLoggedCallId, + name: "$model", + value: reqPayload.data.model, + }); + } + await prisma.loggedCallTag.createMany({ + data: tagsToCreate, + }); + } + }), +}); diff --git a/app/src/server/api/trpc.ts b/app/src/server/api/trpc.ts index dc67814..2f6c8b9 100644 --- a/app/src/server/api/trpc.ts +++ b/app/src/server/api/trpc.ts @@ -11,6 +11,7 @@ import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { type Session } from "next-auth"; import superjson from "superjson"; +import { type OpenApiMeta } from "trpc-openapi"; import { ZodError } from "zod"; import { getServerAuthSession } from "~/server/auth"; import { prisma } from "~/server/db"; @@ -26,6 +27,7 @@ import { capturePath } from "~/utils/analytics/serverAnalytics"; type CreateContextOptions = { session: Session | null; + apiKey: string | null; }; // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -44,6 +46,7 @@ const noOp = () => {}; export const createInnerTRPCContext = (opts: CreateContextOptions) => { return { session: opts.session, + apiKey: opts.apiKey, prisma, markAccessControlRun: noOp, }; @@ -61,8 +64,13 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { // Get the session from the server using the getServerSession wrapper function const session = await getServerAuthSession({ req, res }); + const apiKey = req.headers["x-openpipe-api-key"] as string | null; + + console.log('api key is', apiKey) + return createInnerTRPCContext({ session, + apiKey, }); }; @@ -76,18 +84,21 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { export type TRPCContext = Awaited>; -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }; - }, -}); +const t = initTRPC + .context() + .meta() + .create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, + }); /** * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) diff --git a/app/src/server/tasks/queryModel.task.ts b/app/src/server/tasks/queryModel.task.ts index 6f9d08b..9b32d9d 100644 --- a/app/src/server/tasks/queryModel.task.ts +++ b/app/src/server/tasks/queryModel.task.ts @@ -1,10 +1,10 @@ import { type Prisma } from "@prisma/client"; -import { type JsonObject } from "type-fest"; +import { JsonValue, type JsonObject } from "type-fest"; import modelProviders from "~/modelProviders/modelProviders"; import { prisma } from "~/server/db"; import { wsConnection } from "~/utils/wsConnection"; import { runEvalsForOutput } from "../utils/evaluations"; -import hashPrompt from "../utils/hashPrompt"; +import hashObject from "../utils/hashObject"; import defineTask from "./defineTask"; import parsePromptConstructor from "~/promptConstructor/parse"; @@ -99,7 +99,7 @@ export const queryModel = defineTask("queryModel", async (task) = } : null; - const inputHash = hashPrompt(prompt); + const inputHash = hashObject(prompt as JsonValue); let modelResponse = await prisma.modelResponse.create({ data: { diff --git a/app/src/server/utils/generateNewCell.ts b/app/src/server/utils/generateNewCell.ts index 2f79693..858781e 100644 --- a/app/src/server/utils/generateNewCell.ts +++ b/app/src/server/utils/generateNewCell.ts @@ -1,7 +1,7 @@ import { Prisma } from "@prisma/client"; import { prisma } from "../db"; import { type JsonObject } from "type-fest"; -import hashPrompt from "./hashPrompt"; +import hashObject from "./hashObject"; import { omit } from "lodash-es"; import { queueQueryModel } from "../tasks/queryModel.task"; import parsePromptConstructor from "~/promptConstructor/parse"; @@ -57,7 +57,7 @@ export const generateNewCell = async ( return; } - const inputHash = hashPrompt(parsedConstructFn); + const inputHash = hashObject(parsedConstructFn); cell = await prisma.scenarioVariantCell.create({ data: { diff --git a/app/src/server/utils/hashPrompt.ts b/app/src/server/utils/hashObject.ts similarity index 75% rename from app/src/server/utils/hashPrompt.ts rename to app/src/server/utils/hashObject.ts index 7b1f347..562e7c9 100644 --- a/app/src/server/utils/hashPrompt.ts +++ b/app/src/server/utils/hashObject.ts @@ -1,6 +1,5 @@ import crypto from "crypto"; import { type JsonValue } from "type-fest"; -import { ParsedPromptConstructor } from "~/promptConstructor/parse"; function sortKeys(obj: JsonValue): JsonValue { if (typeof obj !== "object" || obj === null) { @@ -25,9 +24,17 @@ function sortKeys(obj: JsonValue): JsonValue { return sortedObj; } -export default function hashPrompt(prompt: ParsedPromptConstructor): string { +export function hashRequest(organizationId: string, reqPayload: JsonValue): string { + const obj = { + organizationId, + reqPayload, + }; + return hashObject(obj); +} + +export default function hashObject(obj: JsonValue): string { // Sort object keys recursively - const sortedObj = sortKeys(prompt as unknown as JsonValue); + const sortedObj = sortKeys(obj); // Convert to JSON and hash it const str = JSON.stringify(sortedObj);