Commit 9c968969 authored by Travis Fischer's avatar Travis Fischer Committed by GitHub

Merge pull request #17 from transitive-bullshit/feature/undici-and-ava-unit-tests

parents af462067 26f10901
...@@ -48,4 +48,6 @@ jobs: ...@@ -48,4 +48,6 @@ jobs:
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run test - name: Run test
env:
SESSION_TOKEN: 'fake-session-token-for-CI'
run: pnpm run test run: pnpm run test
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
"prepare": "husky install", "prepare": "husky install",
"pre-commit": "lint-staged", "pre-commit": "lint-staged",
"test": "run-p test:*", "test": "run-p test:*",
"test:unit": "ava",
"test:prettier": "prettier '**/*.{js,jsx,ts,tsx}' --check" "test:prettier": "prettier '**/*.{js,jsx,ts,tsx}' --check"
}, },
"dependencies": { "dependencies": {
...@@ -47,6 +48,7 @@ ...@@ -47,6 +48,7 @@
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/node-fetch": "2", "@types/node-fetch": "2",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"ava": "^5.1.0",
"del-cli": "^5.0.0", "del-cli": "^5.0.0",
"dotenv-safe": "^8.2.0", "dotenv-safe": "^8.2.0",
"husky": "^8.0.2", "husky": "^8.0.2",
...@@ -65,6 +67,14 @@ ...@@ -65,6 +67,14 @@
"prettier --write" "prettier --write"
] ]
}, },
"ava": {
"extensions": {
"ts": "module"
},
"nodeArguments": [
"--loader=tsx"
]
},
"keywords": [ "keywords": [
"openai", "openai",
"chatgpt", "chatgpt",
......
This diff is collapsed.
...@@ -60,14 +60,14 @@ const api = new ChatGPTAPI({ ...@@ -60,14 +60,14 @@ const api = new ChatGPTAPI({
}) })
``` ```
A full [example](./src/example.ts) is included for testing purposes: A full [demo](./src/demo.ts) is included for testing purposes:
```bash ```bash
# 1. clone repo # 1. clone repo
# 2. install node deps # 2. install node deps
# 3. set `SESSION_TOKEN` in .env # 3. set `SESSION_TOKEN` in .env
# 4. run: # 4. run:
npx tsx src/example.ts npx tsx src/demo.ts
``` ```
## Docs ## Docs
......
import test from 'ava'
import dotenv from 'dotenv-safe'
import { ChatGPTAPI } from './chatgpt-api'
dotenv.config()
const isCI = !!process.env.CI
test('ChatGPTAPI invalid session token', async (t) => {
t.throws(() => new ChatGPTAPI({ sessionToken: null }), {
message: 'ChatGPT invalid session token'
})
await t.throwsAsync(
async () => {
const chatgpt = new ChatGPTAPI({ sessionToken: 'invalid' })
await chatgpt.ensureAuth()
},
{
message: 'ChatGPT failed to refresh auth token. Error: Unauthorized'
}
)
})
test('ChatGPTAPI valid session token', async (t) => {
if (!isCI) {
t.timeout(2 * 60 * 1000) // 2 minutes
}
t.notThrows(
() => new ChatGPTAPI({ sessionToken: 'fake valid session token' })
)
await t.notThrowsAsync(
(async () => {
const api = new ChatGPTAPI({ sessionToken: process.env.SESSION_TOKEN })
// Don't make any real API calls using our session token if we're running on CI
if (!isCI) {
await api.ensureAuth()
const response = await api.sendMessage('test')
console.log('chatgpt response', response)
t.truthy(response)
t.is(typeof response, 'string')
}
})()
)
})
if (!isCI) {
test('ChatGPTAPI expired session token', async (t) => {
const expiredSessionToken = process.env.TEST_EXPIRED_SESSION_TOKEN
await t.throwsAsync(
async () => {
const chatgpt = new ChatGPTAPI({ sessionToken: expiredSessionToken })
await chatgpt.ensureAuth()
},
{
message:
'ChatGPT failed to refresh auth token. Error: session token has expired'
}
)
})
}
import { createParser } from 'eventsource-parser'
import ExpiryMap from 'expiry-map' import ExpiryMap from 'expiry-map'
import fetch from 'node-fetch'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import * as types from './types' import * as types from './types'
import { fetch } from './fetch'
import { fetchSSE } from './fetch-sse'
import { markdownToText } from './utils' import { markdownToText } from './utils'
const KEY_ACCESS_TOKEN = 'accessToken' const KEY_ACCESS_TOKEN = 'accessToken'
...@@ -119,7 +119,7 @@ export class ChatGPTAPI { ...@@ -119,7 +119,7 @@ export class ChatGPTAPI {
let response = '' let response = ''
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._fetchSSE(url, { fetchSSE(url, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
...@@ -179,34 +179,22 @@ export class ChatGPTAPI { ...@@ -179,34 +179,22 @@ export class ChatGPTAPI {
const accessToken = res?.accessToken const accessToken = res?.accessToken
if (!accessToken) { if (!accessToken) {
console.warn('no auth token', res)
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
const error = res?.error
if (error) {
if (error === 'RefreshAccessTokenError') {
throw new Error('session token has expired')
} else {
throw new Error(error)
}
}
this._accessTokenCache.set(KEY_ACCESS_TOKEN, accessToken) this._accessTokenCache.set(KEY_ACCESS_TOKEN, accessToken)
return accessToken return accessToken
} catch (err: any) { } catch (err: any) {
throw new Error(`ChatGPT failed to refresh auth token: ${err.toString()}`) throw new Error(`ChatGPT failed to refresh auth token. ${err.toString()}`)
} }
} }
protected async _fetchSSE(
url: string,
options: Parameters<typeof fetch>[1] & { onMessage: (data: string) => void }
) {
const { onMessage, ...fetchOptions } = options
const resp = await fetch(url, fetchOptions)
const parser = createParser((event) => {
if (event.type === 'event') {
onMessage(event.data)
}
})
resp.body.on('readable', () => {
let chunk: string | Buffer
while (null !== (chunk = resp.body.read())) {
parser.feed(chunk.toString())
}
})
}
} }
import dotenv from 'dotenv-safe' import dotenv from 'dotenv-safe'
import { oraPromise } from 'ora' import { oraPromise } from 'ora'
import { ChatGPTAPI } from './chatgpt-api' import { ChatGPTAPI } from '.'
dotenv.config() dotenv.config()
/** /**
* Example CLI for testing functionality. * Example CLI for testing functionality.
*
* ```
* npx tsx src/demo.ts
* ```
*/ */
async function main() { async function main() {
const api = new ChatGPTAPI({ sessionToken: process.env.SESSION_TOKEN }) const api = new ChatGPTAPI({ sessionToken: process.env.SESSION_TOKEN })
......
import { createParser } from 'eventsource-parser'
import { fetch } from './fetch'
// import { streamAsyncIterable } from './stream-async-iterable'
export async function fetchSSE(
url: string,
options: Parameters<typeof fetch>[1] & { onMessage: (data: string) => void }
) {
const { onMessage, ...fetchOptions } = options
const resp = await fetch(url, fetchOptions)
const parser = createParser((event) => {
if (event.type === 'event') {
onMessage(event.data)
}
})
resp.body.on('readable', () => {
let chunk: string | Buffer
while (null !== (chunk = resp.body.read())) {
parser.feed(chunk.toString())
}
})
// TODO: add support for web-compatible `fetch`
// for await (const chunk of streamAsyncIterable(resp.body)) {
// const str = new TextDecoder().decode(chunk)
// parser.feed(str)
// }
}
import fetch from 'node-fetch'
export { fetch }
import { type ReadableStream } from 'stream/web'
export async function* streamAsyncIterable(stream: ReadableStream) {
const reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return
}
yield value
}
} finally {
reader.releaseLock()
}
}
...@@ -7,7 +7,7 @@ export type Role = 'user' | 'assistant' ...@@ -7,7 +7,7 @@ export type Role = 'user' | 'assistant'
*/ */
export type SessionResult = { export type SessionResult = {
/** /**
* Object of the current user * Authenticated user
*/ */
user: User user: User
...@@ -20,6 +20,11 @@ export type SessionResult = { ...@@ -20,6 +20,11 @@ export type SessionResult = {
* The access token * The access token
*/ */
accessToken: string accessToken: string
/**
* If there was an error associated with this request
*/
error?: string | null
} }
export type User = { export type User = {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment