Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: api run prompt endpoint #81

Merged
merged 1 commit into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/gateway/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"extends": ["./node_modules/@latitude-data/eslint-config/library.js"],
"env": {
"node": true
},
"rules": {
"no-constant-condition": "off"
}
}
7 changes: 5 additions & 2 deletions apps/gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"type": "module",
"scripts": {
"build": "tsc --build tsconfig.prod.json --verbose",
"dev": "tsx watch src",
"dev": "tsx watch src/server",
"lint": "eslint src/",
"prettier": "prettier --write \"**/*.{ts,tsx,md}\"",
"start": "node -r module-alias/register ./dist --env=production",
Expand All @@ -13,9 +13,12 @@
},
"dependencies": {
"@hono/node-server": "^1.12.0",
"@hono/zod-validator": "^0.2.2",
"@latitude-data/core": "workspace:^",
"@latitude-data/env": "workspace:^",
"hono": "^4.5.3"
"@t3-oss/env-core": "^0.10.1",
"hono": "^4.5.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@latitude-data/eslint-config": "workspace:^",
Expand Down
26 changes: 26 additions & 0 deletions apps/gateway/src/common/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import '@latitude-data/env'

import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod'

let env
if (process.env.NODE_ENV === 'development') {
env = await import('./env/development').then((r) => r.default)
} else if (process.env.NODE_ENV === 'test') {
env = await import('./env/test').then((r) => r.default)
} else {
env = process.env as {
GATEWAY_PORT: string
GATEWAY_HOST: string
}
}

export default createEnv({
skipValidation:
process.env.BUILDING_CONTAINER == 'true' || process.env.NODE_ENV === 'test',
server: {
GATEWAY_PORT: z.string().optional().default('8787'),
GATEWAY_HOST: z.string().optional().default('localhost'),
},
runtimeEnv: env,
})
4 changes: 4 additions & 0 deletions apps/gateway/src/common/env/development.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default {
GATEWAY_PORT: '8787',
GATEWAY_HOST: 'localhost',
}
6 changes: 6 additions & 0 deletions apps/gateway/src/common/env/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import devEnv from './development'

export default {
...devEnv,
GATEWAY_PORT: '8788',
}
1 change: 1 addition & 0 deletions apps/gateway/src/common/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const ROUTES = {
Documents: {
Base: '/projects/:projectId/commits/:commitUuid/documents',
Get: '/:documentPath{.+}',
Run: '/run',
},
},
},
Expand Down
15 changes: 0 additions & 15 deletions apps/gateway/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import '@latitude-data/env'

import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import jetPaths from 'jet-paths'
Expand All @@ -24,16 +21,4 @@ app.route(jetPaths(ROUTES).Api.V1.Documents.Base, documentsRouter)
// Must be the last one!
app.use(errorHandlerMiddleware())

serve(
{
fetch: app.fetch,
overrideGlobalObjects: undefined,
port: parseInt(process.env.GATEWAY_PORT || '4000', 10),
hostname: process.env.GATEWAY_HOSTNAME || 'localhost',
},
(info) => {
console.log(`Listening on http://localhost:${info.port}`)
},
)

export default app
3 changes: 1 addition & 2 deletions apps/gateway/src/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
unsafelyGetApiKeyByToken,
} from '@latitude-data/core'
import type { Workspace } from '@latitude-data/core/browser'
import { Context } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'

declare module 'hono' {
Expand All @@ -14,7 +13,7 @@ declare module 'hono' {

const authMiddleware = () =>
bearerAuth({
verifyToken: async (token: string, c: Context) => {
verifyToken: async (token: string, c) => {
const apiKeyResult = await unsafelyGetApiKeyByToken({ token })
if (apiKeyResult.error) return false

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
CommitsRepository,
DocumentVersionsRepository,
ProjectsRepository,
} from '@latitude-data/core'
import type { Workspace } from '@latitude-data/core/browser'

const toDocumentPath = (path: string) => {
if (path.startsWith('/')) {
return path
}

return `/${path}`
}

export const getData = async ({
workspace,
projectId,
commitUuid,
documentPath,
}: {
workspace: Workspace
projectId: number
commitUuid: string
documentPath: string
}) => {
const projectsScope = new ProjectsRepository(workspace.id)
const commitsScope = new CommitsRepository(workspace.id)
const docsScope = new DocumentVersionsRepository(workspace.id)

const project = await projectsScope
.getProjectById(projectId)
.then((r) => r.unwrap())
const commit = await commitsScope
.getCommitByUuid({ project, uuid: commitUuid })
.then((r) => r.unwrap())
const document = await docsScope
.getDocumentByPath({
commit,
path: toDocumentPath(documentPath),
})
.then((r) => r.unwrap())

return { project, commit, document }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createFactory } from 'hono/factory'

import { getData } from './_shared'

const factory = createFactory()

export const getHandler = factory.createHandlers(async (c) => {
const workspace = c.get('workspace')
const { projectId, commitUuid, documentPath } = c.req.param()

const { document } = await getData({
workspace,
projectId: Number(projectId!),
commitUuid: commitUuid!,
documentPath: documentPath!,
})

return c.json(document)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
apiKeys,
ChainEventTypes,
database,
factories,
LATITUDE_EVENT,
mergeCommit,
Result,
} from '@latitude-data/core'
import app from '$/index'
import { eq } from 'drizzle-orm'
import { describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
streamText: vi.fn(),
}))

vi.mock('@latitude-data/core', async (importOriginal) => {
const original = (await importOriginal()) as typeof importOriginal

return {
...original,
streamText: mocks.streamText,
}
})

describe('POST /run', () => {
describe('unauthorized', () => {
it('fails', async () => {
const res = await app.request(
'/api/v1/projects/1/commits/asldkfjhsadl/documents/run',
{
method: 'POST',
body: JSON.stringify({
documentPath: '/path/to/document',
}),
},
)

expect(res.status).toBe(401)
})
})

describe('authorized', () => {
it('succeeds', async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue({
event: LATITUDE_EVENT,
data: {
type: ChainEventTypes.Complete,
response: {
text: 'Hello',
usage: {},
},
},
})

controller.close()
},
})

const response = new Promise((resolve) => {
resolve({ text: 'Hello', usage: {} })
})

mocks.streamText.mockReturnValue(
new Promise((resolve) => {
resolve(
Result.ok({
stream,
response,
}),
)
}),
)

const { workspace, user, project } = await factories.createProject()
const apikey = await database.query.apiKeys.findFirst({
where: eq(apiKeys.workspaceId, workspace.id),
})
geclos marked this conversation as resolved.
Show resolved Hide resolved
const path = '/path/to/document'
const { commit } = await factories.createDraft({
project,
user,
})
const document = await factories.createDocumentVersion({
commit,
path,
content: `
---
provider: openai
model: gpt-4o
---

Ignore all the rest and just return "Hello".
`,
})

await mergeCommit(commit).then((r) => r.unwrap())

const route = `/api/v1/projects/${project!.id}/commits/${commit!.uuid}/documents/run`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a routes.ts object like in web to avoid repeating all the paths

const body = JSON.stringify({
documentPath: document.documentVersion.path,
parameters: {},
})
const res = await app.request(route, {
method: 'POST',
body,
headers: {
Authorization: `Bearer ${apikey!.token}`,
'Content-Type': 'application/json',
},
})

expect(res.status).toBe(200)
expect(res.body).toBeInstanceOf(ReadableStream)

const responseStream = res.body as ReadableStream
const reader = responseStream.getReader()

let done = false
let value
while (!done) {
const { done: _done, value: _value } = await reader.read()
done = _done
if (_value) value = new TextDecoder().decode(_value)
}

expect(done).toBe(true)
expect(JSON.parse(value!)).toEqual({
event: LATITUDE_EVENT,
data: {
type: ChainEventTypes.Complete,
response: {
text: 'Hello',
usage: {},
},
},
id: '0',
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { zValidator } from '@hono/zod-validator'
import { streamText } from '@latitude-data/core'
import { Factory } from 'hono/factory'
import { SSEStreamingApi, streamSSE } from 'hono/streaming'
import { z } from 'zod'

import { getData } from './_shared'

const factory = new Factory()

const runSchema = z.object({
documentPath: z.string(),
parameters: z.record(z.any()).optional().default({}),
})

export const runHandler = factory.createHandlers(
zValidator('json', runSchema),
async (c) => {
return streamSSE(c, async (stream) => {
const { projectId, commitUuid } = c.req.param()
const { documentPath, parameters } = c.req.valid('json')

const workspace = c.get('workspace')

const { document } = await getData({
workspace,
projectId: Number(projectId!),
commitUuid: commitUuid!,
documentPath: documentPath!,
})

const result = await streamText({
document,
parameters,
}).then((r) => r.unwrap())

await pipeToStream(stream, result.stream)
})
},
)

async function pipeToStream(
stream: SSEStreamingApi,
readableStream: ReadableStream,
) {
let id = 0
const reader = readableStream.getReader()

while (true) {
const { done, value } = await reader.read()
if (done) break

stream.write(
JSON.stringify({
...value,
id: String(id++),
}),
)
}
}
Loading
Loading