feat: redesign main sendMessage, initSession, closeSession API

remotes/origin/feature/api-redesign
Travis Fischer 2022-12-16 22:48:42 -06:00
rodzic 24f51ac360
commit 1af5db2471
10 zmienionych plików z 282 dodań i 224 usunięć

Wyświetl plik

@ -16,40 +16,63 @@ async function main() {
const email = process.env.OPENAI_EMAIL
const password = process.env.OPENAI_PASSWORD
const api = new ChatGPTAPIBrowser({ email, password, debug: true })
await api.init()
const api = new ChatGPTAPIBrowser({
email,
password,
debug: false,
minimize: true
})
await api.initSession()
const prompt = 'What is OpenAI?'
const prompt = 'Write a poem about cats.'
const response = await oraPromise(api.sendMessage(prompt), {
let res = await oraPromise(api.sendMessage(prompt), {
text: prompt
})
console.log(response)
console.log('\n' + res.response + '\n')
const prompt2 = 'Did they made OpenGPT?'
const prompt2 = 'Can you make it cuter and shorter?'
console.log(
await oraPromise(api.sendMessage(prompt2), {
res = await oraPromise(
api.sendMessage(prompt2, {
conversationId: res.conversationId,
parentMessageId: res.messageId
}),
{
text: prompt2
})
}
)
console.log('\n' + res.response + '\n')
const prompt3 = 'Who founded this institute?'
const prompt3 = 'Now write it in French.'
console.log(
await oraPromise(api.sendMessage(prompt3), {
res = await oraPromise(
api.sendMessage(prompt3, {
conversationId: res.conversationId,
parentMessageId: res.messageId
}),
{
text: prompt3
})
}
)
console.log('\n' + res.response + '\n')
const prompt4 = 'Who is that?'
const prompt4 = 'What were we talking about again?'
console.log(
await oraPromise(api.sendMessage(prompt4), {
res = await oraPromise(
api.sendMessage(prompt4, {
conversationId: res.conversationId,
parentMessageId: res.messageId
}),
{
text: prompt4
})
}
)
console.log('\n' + res.response + '\n')
// close the browser at the end
await api.closeSession()
}
main().catch((err) => {

Wyświetl plik

@ -22,41 +22,56 @@ async function main() {
})
const api = new ChatGPTAPI({ ...authInfo })
await api.ensureAuth()
await api.initSession()
const conversation = api.getConversation()
const prompt = 'Write a poem about cats.'
const prompt = 'What is OpenAI?'
const response = await oraPromise(conversation.sendMessage(prompt), {
let res = await oraPromise(api.sendMessage(prompt), {
text: prompt
})
console.log(response)
console.log('\n' + res.response + '\n')
const prompt2 = 'Did they made OpenGPT?'
const prompt2 = 'Can you make it cuter and shorter?'
console.log(
await oraPromise(conversation.sendMessage(prompt2), {
res = await oraPromise(
api.sendMessage(prompt2, {
conversationId: res.conversationId,
parentMessageId: res.messageId
}),
{
text: prompt2
})
}
)
console.log('\n' + res.response + '\n')
const prompt3 = 'Who founded this institute?'
const prompt3 = 'Now write it in French.'
console.log(
await oraPromise(conversation.sendMessage(prompt3), {
res = await oraPromise(
api.sendMessage(prompt3, {
conversationId: res.conversationId,
parentMessageId: res.messageId
}),
{
text: prompt3
})
}
)
console.log('\n' + res.response + '\n')
const prompt4 = 'Who is that?'
const prompt4 = 'What were we talking about again?'
console.log(
await oraPromise(conversation.sendMessage(prompt4), {
res = await oraPromise(
api.sendMessage(prompt4, {
conversationId: res.conversationId,
parentMessageId: res.messageId
}),
{
text: prompt4
})
}
)
console.log('\n' + res.response + '\n')
await api.closeSession()
}
main().catch((err) => {

Wyświetl plik

@ -23,24 +23,21 @@ async function main() {
debug: false,
minimize: true
})
await api.init()
await api.initSession()
const prompt =
'Write a python version of bubble sort. Do not include example usage.'
const response = await oraPromise(api.sendMessage(prompt), {
const res = await oraPromise(api.sendMessage(prompt), {
text: prompt
})
console.log(res.response)
await api.close()
return response
// close the browser at the end
await api.closeSession()
}
main()
.then((res) => {
console.log(res)
})
.catch((err) => {
console.error(err)
process.exit(1)
})
main().catch((err) => {
console.error(err)
process.exit(1)
})

Wyświetl plik

@ -0,0 +1,66 @@
import * as types from './types'
export abstract class AChatGPTAPI {
/**
* Performs any async initialization work required to ensure that this API is
* properly authenticated.
*
* @throws An error if the session failed to initialize properly.
*/
abstract initSession(): Promise<void>
/**
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
*
* If you want to receive a stream of partial responses, use `opts.onProgress`.
*
* @param message - The prompt message to send
* @param opts.conversationId - Optional ID of a conversation to continue
* @param opts.parentMessageId - Optional ID of the previous message in the conversation
* @param opts.messageId - Optional ID of the message to send (defaults to a random UUID)
* @param opts.action - Optional ChatGPT `action` (either `next` or `variant`)
* @param opts.timeoutMs - Optional timeout in milliseconds (defaults to no timeout)
* @param opts.onProgress - Optional callback which will be invoked every time the partial response is updated
* @param opts.abortSignal - Optional callback used to abort the underlying `fetch` call using an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
*
* @returns The response from ChatGPT, including `conversationId`, `messageId`, and
* the `response` text.
*/
abstract sendMessage(
message: string,
opts?: types.SendMessageOptions
): Promise<types.ChatResponse>
/**
* @returns `true` if the client is authenticated with a valid session or `false`
* otherwise.
*/
abstract getIsAuthenticated(): Promise<boolean>
/**
* Refreshes the current ChatGPT session.
*
* @returns Access credentials for the new session.
* @throws An error if it fails.
*/
abstract refreshSession(): Promise<any>
/**
* Closes the current ChatGPT session and starts a new one.
*
* @returns Access credentials for the new session.
* @throws An error if it fails.
*/
async resetSession(): Promise<any> {
await this.closeSession()
return this.initSession()
}
/**
* Closes the active session.
*
* @throws An error if it fails.
*/
abstract closeSession(): Promise<void>
}

Wyświetl plik

@ -3,6 +3,7 @@ import type { Browser, HTTPRequest, HTTPResponse, Page } from 'puppeteer'
import { v4 as uuidv4 } from 'uuid'
import * as types from './types'
import { AChatGPTAPI } from './abstract-chatgpt-api'
import { getBrowser, getOpenAIAuth } from './openai-auth'
import {
browserPostEventStream,
@ -11,7 +12,7 @@ import {
minimizePage
} from './utils'
export class ChatGPTAPIBrowser {
export class ChatGPTAPIBrowser extends AChatGPTAPI {
protected _markdown: boolean
protected _debug: boolean
protected _minimize: boolean
@ -27,7 +28,7 @@ export class ChatGPTAPIBrowser {
protected _page: Page
/**
* Creates a new client wrapper for automating the ChatGPT webapp.
* Creates a new client for automating the ChatGPT webapp.
*/
constructor(opts: {
email: string
@ -51,6 +52,8 @@ export class ChatGPTAPIBrowser {
/** @defaultValue `undefined` **/
executablePath?: string
}) {
super()
const {
email,
password,
@ -71,14 +74,23 @@ export class ChatGPTAPIBrowser {
this._minimize = !!minimize
this._captchaToken = captchaToken
this._executablePath = executablePath
if (!this._email) {
const error = new types.ChatGPTError('ChatGPT invalid email')
error.statusCode = 401
throw error
}
if (!this._password) {
const error = new types.ChatGPTError('ChatGPT invalid password')
error.statusCode = 401
throw error
}
}
async init() {
override async initSession() {
if (this._browser) {
await this._browser.close()
this._page = null
this._browser = null
this._accessToken = null
await this.closeSession()
}
try {
@ -89,6 +101,15 @@ export class ChatGPTAPIBrowser {
this._page =
(await this._browser.pages())[0] || (await this._browser.newPage())
// bypass annoying popup modals
this._page.evaluateOnNewDocument(() => {
window.localStorage.setItem('oai/apps/hasSeenOnboarding/chat', 'true')
window.localStorage.setItem(
'oai/apps/hasSeenReleaseAnnouncement/2022-12-15',
'true'
)
})
await maximizePage(this._page)
this._page.on('request', this._onRequest.bind(this))
@ -140,15 +161,13 @@ export class ChatGPTAPIBrowser {
await delay(300)
} while (true)
if (!this.getIsAuthenticated()) {
return false
if (!(await this.getIsAuthenticated())) {
throw new types.ChatGPTError('Failed to authenticate session')
}
if (this._minimize) {
await minimizePage(this._page)
return minimizePage(this._page)
}
return true
}
_onRequest = (request: HTTPRequest) => {
@ -221,11 +240,13 @@ export class ChatGPTAPIBrowser {
if (url.endsWith('/conversation')) {
if (status === 403) {
await this.handle403Error()
await this.refreshSession()
}
} else if (url.endsWith('api/auth/session')) {
if (status === 403) {
await this.handle403Error()
if (status === 401) {
await this.resetSession()
} else if (status === 403) {
await this.refreshSession()
} else {
const session: types.SessionResult = body
@ -236,8 +257,30 @@ export class ChatGPTAPIBrowser {
}
}
async handle403Error() {
console.log(`ChatGPT "${this._email}" session expired; refreshing...`)
/**
* Attempts to handle 401 errors by re-authenticating.
*/
async resetSession() {
console.log(
`ChatGPT "${this._email}" session expired; re-authenticating...`
)
try {
await this.closeSession()
await this.initSession()
console.log(`ChatGPT "${this._email}" re-authenticated successfully`)
} catch (err) {
console.error(
`ChatGPT "${this._email}" error re-authenticating`,
err.toString()
)
}
}
/**
* Attempts to handle 403 errors by refreshing the page.
*/
async refreshSession() {
console.log(`ChatGPT "${this._email}" session expired (403); refreshing...`)
try {
await maximizePage(this._page)
await this._page.reload({
@ -247,6 +290,7 @@ export class ChatGPTAPIBrowser {
if (this._minimize) {
await minimizePage(this._page)
}
console.log(`ChatGPT "${this._email}" refreshed session successfully`)
} catch (err) {
console.error(
`ChatGPT "${this._email}" error refreshing session`,
@ -257,6 +301,10 @@ export class ChatGPTAPIBrowser {
async getIsAuthenticated() {
try {
if (!this._accessToken) {
return false
}
const inputBox = await this._getInputBox()
return !!inputBox
} catch (err) {
@ -328,28 +376,25 @@ export class ChatGPTAPIBrowser {
// }
// }
async sendMessage(
override async sendMessage(
message: string,
opts: types.SendMessageOptions = {}
): Promise<string> {
): Promise<types.ChatResponse> {
const {
conversationId,
parentMessageId = uuidv4(),
messageId = uuidv4(),
action = 'next',
timeoutMs
// TODO
timeoutMs,
// onProgress,
onConversationResponse
// onProgress
} = opts
const inputBox = await this._getInputBox()
if (!inputBox || !this._accessToken) {
if (!(await this.getIsAuthenticated())) {
console.log(`chatgpt re-authenticating ${this._email}`)
let isAuthenticated = false
try {
isAuthenticated = await this.init()
await this.resetSession()
} catch (err) {
console.warn(
`chatgpt error re-authenticating ${this._email}`,
@ -357,7 +402,7 @@ export class ChatGPTAPIBrowser {
)
}
if (!isAuthenticated || !this._accessToken) {
if (!(await this.getIsAuthenticated())) {
const error = new types.ChatGPTError('Not signed in')
error.statusCode = 401
throw error
@ -395,25 +440,20 @@ export class ChatGPTAPIBrowser {
)
// console.log('<<< EVALUATE', result)
if (result.error) {
if ('error' in result) {
const error = new types.ChatGPTError(result.error.message)
error.statusCode = result.error.statusCode
error.statusText = result.error.statusText
if (error.statusCode === 403) {
await this.handle403Error()
await this.refreshSession()
}
throw error
} else {
return result
}
// TODO: support sending partial response events
if (onConversationResponse) {
onConversationResponse(result.conversationResponse)
}
return result.response
// const lastMessage = await this.getLastMessage()
// await inputBox.focus()
@ -465,10 +505,11 @@ export class ChatGPTAPIBrowser {
}
}
async close() {
override async closeSession() {
await this._browser.close()
this._page = null
this._browser = null
this._accessToken = null
}
protected async _getInputBox() {

Wyświetl plik

@ -3,7 +3,7 @@ import pTimeout from 'p-timeout'
import { v4 as uuidv4 } from 'uuid'
import * as types from './types'
import { ChatGPTConversation } from './chatgpt-conversation'
import { AChatGPTAPI } from './abstract-chatgpt-api'
import { fetch } from './fetch'
import { fetchSSE } from './fetch-sse'
import { markdownToText } from './utils'
@ -12,7 +12,7 @@ const KEY_ACCESS_TOKEN = 'accessToken'
const USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
export class ChatGPTAPI {
export class ChatGPTAPI extends AChatGPTAPI {
protected _sessionToken: string
protected _clearanceToken: string
protected _markdown: boolean
@ -71,6 +71,8 @@ export class ChatGPTAPI {
/** @defaultValue `false` **/
debug?: boolean
}) {
super()
const {
sessionToken,
clearanceToken,
@ -113,11 +115,15 @@ export class ChatGPTAPI {
}
if (!this._sessionToken) {
throw new types.ChatGPTError('ChatGPT invalid session token')
const error = new types.ChatGPTError('ChatGPT invalid session token')
error.statusCode = 401
throw error
}
if (!this._clearanceToken) {
throw new types.ChatGPTError('ChatGPT invalid clearance token')
const error = new types.ChatGPTError('ChatGPT invalid clearance token')
error.statusCode = 401
throw error
}
}
@ -143,6 +149,14 @@ export class ChatGPTAPI {
return this._userAgent
}
/**
* Refreshes the client's access token which will succeed only if the session
* is valid.
*/
override async initSession() {
await this.refreshSession()
}
/**
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
@ -159,23 +173,21 @@ export class ChatGPTAPI {
* @param opts.action - Optional ChatGPT `action` (either `next` or `variant`)
* @param opts.timeoutMs - Optional timeout in milliseconds (defaults to no timeout)
* @param opts.onProgress - Optional callback which will be invoked every time the partial response is updated
* @param opts.onConversationResponse - Optional callback which will be invoked every time the partial response is updated with the full conversation response
* @param opts.abortSignal - Optional callback used to abort the underlying `fetch` call using an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
*
* @returns The response from ChatGPT
*/
async sendMessage(
override async sendMessage(
message: string,
opts: types.SendMessageOptions = {}
): Promise<string> {
): Promise<types.ChatResponse> {
const {
conversationId,
parentMessageId = uuidv4(),
messageId = uuidv4(),
action = 'next',
timeoutMs,
onProgress,
onConversationResponse
onProgress
} = opts
let { abortSignal } = opts
@ -186,7 +198,7 @@ export class ChatGPTAPI {
abortSignal = abortController.signal
}
const accessToken = await this.refreshAccessToken()
const accessToken = await this.refreshSession()
const body: types.ConversationJSONBody = {
action,
@ -208,9 +220,13 @@ export class ChatGPTAPI {
body.conversation_id = conversationId
}
let response = ''
const result: types.ChatResponse = {
conversationId,
messageId,
response: ''
}
const responseP = new Promise<string>((resolve, reject) => {
const responseP = new Promise<types.ChatResponse>((resolve, reject) => {
const url = `${this._backendApiBaseUrl}/conversation`
const headers = {
...this._headers,
@ -231,17 +247,22 @@ export class ChatGPTAPI {
signal: abortSignal,
onMessage: (data: string) => {
if (data === '[DONE]') {
return resolve(response)
return resolve(result)
}
try {
const parsedData: types.ConversationResponseEvent = JSON.parse(data)
if (onConversationResponse) {
onConversationResponse(parsedData)
const convoResponseEvent: types.ConversationResponseEvent =
JSON.parse(data)
if (convoResponseEvent.conversation_id) {
result.conversationId = convoResponseEvent.conversation_id
}
const message = parsedData.message
// console.log('event', JSON.stringify(parsedData, null, 2))
if (convoResponseEvent.message?.id) {
result.messageId = convoResponseEvent.message.id
}
const message = convoResponseEvent.message
// console.log('event', JSON.stringify(convoResponseEvent, null, 2))
if (message) {
let text = message?.content?.parts?.[0]
@ -251,10 +272,10 @@ export class ChatGPTAPI {
text = markdownToText(text)
}
response = text
result.response = text
if (onProgress) {
onProgress(text)
onProgress(result)
}
}
}
@ -267,7 +288,7 @@ export class ChatGPTAPI {
const errMessageL = err.toString().toLowerCase()
if (
response &&
result.response &&
(errMessageL === 'error: typeerror: terminated' ||
errMessageL === 'typeerror: terminated')
) {
@ -275,7 +296,7 @@ export class ChatGPTAPI {
// the HTTP request has resolved cleanly. In my testing, these cases tend to
// happen when OpenAI has already send the last `response`, so we can ignore
// the `fetch` error in this case.
return resolve(response)
return resolve(result)
} else {
return reject(err)
}
@ -301,7 +322,7 @@ export class ChatGPTAPI {
}
async sendModeration(input: string) {
const accessToken = await this.refreshAccessToken()
const accessToken = await this.refreshSession()
const url = `${this._backendApiBaseUrl}/moderations`
const headers = {
...this._headers,
@ -343,23 +364,15 @@ export class ChatGPTAPI {
* @returns `true` if the client has a valid acces token or `false` if refreshing
* the token fails.
*/
async getIsAuthenticated() {
override async getIsAuthenticated() {
try {
void (await this.refreshAccessToken())
void (await this.refreshSession())
return true
} catch (err) {
return false
}
}
/**
* Refreshes the client's access token which will succeed only if the session
* is still valid.
*/
async ensureAuth() {
return await this.refreshAccessToken()
}
/**
* Attempts to refresh the current access token using the ChatGPT
* `sessionToken` cookie.
@ -370,7 +383,7 @@ export class ChatGPTAPI {
* @returns A valid access token
* @throws An error if refreshing the access token fails.
*/
async refreshAccessToken(): Promise<string> {
override async refreshSession(): Promise<string> {
const cachedAccessToken = this._accessTokenCache.get(KEY_ACCESS_TOKEN)
if (cachedAccessToken) {
return cachedAccessToken
@ -454,17 +467,7 @@ export class ChatGPTAPI {
}
}
/**
* Gets a new ChatGPTConversation instance, which can be used to send multiple
* messages as part of a single conversation.
*
* @param opts.conversationId - Optional ID of the previous message in a conversation
* @param opts.parentMessageId - Optional ID of the previous message in a conversation
* @returns The new conversation instance
*/
getConversation(
opts: { conversationId?: string; parentMessageId?: string } = {}
) {
return new ChatGPTConversation(this, opts)
override async closeSession(): Promise<void> {
this._accessTokenCache.delete(KEY_ACCESS_TOKEN)
}
}

Wyświetl plik

@ -1,73 +0,0 @@
import * as types from './types'
import { type ChatGPTAPI } from './chatgpt-api'
/**
* A conversation wrapper around the ChatGPTAPI. This allows you to send
* multiple messages to ChatGPT and receive responses, without having to
* manually pass the conversation ID and parent message ID for each message.
*/
export class ChatGPTConversation {
api: ChatGPTAPI
conversationId: string = undefined
parentMessageId: string = undefined
/**
* Creates a new conversation wrapper around the ChatGPT API.
*
* @param api - The ChatGPT API instance to use
* @param opts.conversationId - Optional ID of a conversation to continue
* @param opts.parentMessageId - Optional ID of the previous message in the conversation
*/
constructor(
api: ChatGPTAPI,
opts: { conversationId?: string; parentMessageId?: string } = {}
) {
this.api = api
this.conversationId = opts.conversationId
this.parentMessageId = opts.parentMessageId
}
/**
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
*
* If this is the first message in the conversation, the conversation ID and
* parent message ID will be automatically set.
*
* This allows you to send multiple messages to ChatGPT and receive responses,
* without having to manually pass the conversation ID and parent message ID
* for each message.
*
* @param message - The prompt message to send
* @param opts.onProgress - Optional callback which will be invoked every time the partial response is updated
* @param opts.onConversationResponse - Optional callback which will be invoked every time the partial response is updated with the full conversation response
* @param opts.abortSignal - Optional callback used to abort the underlying `fetch` call using an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
*
* @returns The response from ChatGPT
*/
async sendMessage(
message: string,
opts: types.SendConversationMessageOptions = {}
): Promise<string> {
const { onConversationResponse, ...rest } = opts
return this.api.sendMessage(message, {
...rest,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
onConversationResponse: (response) => {
if (response.conversation_id) {
this.conversationId = response.conversation_id
}
if (response.message?.id) {
this.parentMessageId = response.message.id
}
if (onConversationResponse) {
return onConversationResponse(response)
}
}
})
}
}

Wyświetl plik

@ -1,6 +1,6 @@
export * from './chatgpt-api'
export * from './chatgpt-api-browser'
export * from './chatgpt-conversation'
export * from './abstract-chatgpt-api'
export * from './types'
export * from './utils'
export * from './openai-auth'

Wyświetl plik

@ -281,8 +281,7 @@ export type SendMessageOptions = {
messageId?: string
action?: MessageActionType
timeoutMs?: number
onProgress?: (partialResponse: string) => void
onConversationResponse?: (response: ConversationResponseEvent) => void
onProgress?: (partialResponse: ChatResponse) => void
abortSignal?: AbortSignal
}
@ -300,16 +299,12 @@ export class ChatGPTError extends Error {
export type ChatError = {
error: { message: string; statusCode?: number; statusText?: string }
response: null
conversationId?: string
messageId?: string
conversationResponse?: ConversationResponseEvent
}
export type ChatResponse = {
error: null
response: string
conversationId: string
messageId: string
conversationResponse?: ConversationResponseEvent
}

Wyświetl plik

@ -37,7 +37,7 @@ export async function maximizePage(page: Page) {
}
export function isRelevantRequest(url: string): boolean {
let pathname
let pathname: string
try {
const parsedUrl = new URL(url)
@ -102,7 +102,6 @@ export async function browserPostEventStream(
const BOM = [239, 187, 191]
let conversationResponse: types.ConversationResponseEvent
let conversationId: string = body?.conversation_id
let messageId: string = body?.messages?.[0]?.id
let response = ''
@ -136,7 +135,6 @@ export async function browserPostEventStream(
statusCode: res.status,
statusText: res.statusText
},
response: null,
conversationId,
messageId
}
@ -147,18 +145,15 @@ export async function browserPostEventStream(
function onMessage(data: string) {
if (data === '[DONE]') {
return resolve({
error: null,
response,
conversationId,
messageId,
conversationResponse
messageId
})
}
try {
const convoResponseEvent: types.ConversationResponseEvent =
JSON.parse(data)
conversationResponse = convoResponseEvent
if (convoResponseEvent.conversation_id) {
conversationId = convoResponseEvent.conversation_id
}
@ -220,11 +215,9 @@ export async function browserPostEventStream(
// happen when OpenAI has already send the last `response`, so we can ignore
// the `fetch` error in this case.
return {
error: null,
response,
conversationId,
messageId,
conversationResponse
messageId
}
}
@ -234,10 +227,8 @@ export async function browserPostEventStream(
statusCode: err.statusCode || err.status || err.response?.statusCode,
statusText: err.statusText || err.response?.statusText
},
response: null,
conversationId,
messageId,
conversationResponse
messageId
}
}
@ -456,7 +447,7 @@ export async function browserPostEventStream(
customTimers = { setTimeout, clearTimeout }
} = options
let timer
let timer: number
const cancelablePromise = new Promise((resolve, reject) => {
if (typeof milliseconds !== 'number' || Math.sign(milliseconds) !== 1) {