chatgpt-api/src/chatgpt-api.ts

474 wiersze
14 KiB
TypeScript
Czysty Zwykły widok Historia

2022-12-05 05:13:36 +00:00
import ExpiryMap from 'expiry-map'
import pTimeout from 'p-timeout'
2022-12-05 05:13:36 +00:00
import { v4 as uuidv4 } from 'uuid'
import * as types from './types'
import { AChatGPTAPI } from './abstract-chatgpt-api'
import { fetch } from './fetch'
import { fetchSSE } from './fetch-sse'
2022-12-05 05:13:36 +00:00
import { markdownToText } from './utils'
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'
2022-12-02 23:43:59 +00:00
export class ChatGPTAPI extends AChatGPTAPI {
2022-12-05 05:13:36 +00:00
protected _sessionToken: string
protected _clearanceToken: string
2022-12-02 23:43:59 +00:00
protected _markdown: boolean
protected _debug: boolean
2022-12-05 05:13:36 +00:00
protected _apiBaseUrl: string
protected _backendApiBaseUrl: string
protected _userAgent: string
protected _headers: Record<string, string>
protected _user: types.User | null = null
2022-12-05 05:34:15 +00:00
2022-12-07 04:07:14 +00:00
// Stores access tokens for `accessTokenTTL` milliseconds before needing to refresh
protected _accessTokenCache: ExpiryMap<string, string>
2022-12-02 23:43:59 +00:00
/**
2022-12-05 05:13:36 +00:00
* Creates a new client wrapper around the unofficial ChatGPT REST API.
*
* Note that your IP address and `userAgent` must match the same values that you used
* to obtain your `clearanceToken`.
*
2022-12-05 05:13:36 +00:00
* @param opts.sessionToken = **Required** OpenAI session token which can be found in a valid session's cookies (see readme for instructions)
* @param opts.clearanceToken = **Required** Cloudflare `cf_clearance` cookie value (see readme for instructions)
2022-12-05 05:13:36 +00:00
* @param apiBaseUrl - Optional override; the base URL for ChatGPT webapp's API (`/api`)
* @param backendApiBaseUrl - Optional override; the base URL for the ChatGPT backend API (`/backend-api`)
* @param userAgent - Optional override; the `user-agent` header to use with ChatGPT requests
2022-12-07 04:07:14 +00:00
* @param accessTokenTTL - Optional override; how long in milliseconds access tokens should last before being forcefully refreshed
* @param accessToken - Optional default access token if you already have a valid one generated
* @param heaaders - Optional additional HTTP headers to be added to each `fetch` request
* @param debug - Optional enables logging debugging into to stdout
2022-12-02 23:43:59 +00:00
*/
2022-12-05 05:13:36 +00:00
constructor(opts: {
sessionToken: string
clearanceToken: string
2022-12-02 23:43:59 +00:00
2022-12-05 05:13:36 +00:00
/** @defaultValue `true` **/
markdown?: boolean
2022-12-02 23:43:59 +00:00
2022-12-05 05:13:36 +00:00
/** @defaultValue `'https://chat.openai.com/api'` **/
apiBaseUrl?: string
2022-12-02 23:43:59 +00:00
2022-12-05 05:13:36 +00:00
/** @defaultValue `'https://chat.openai.com/backend-api'` **/
backendApiBaseUrl?: string
/** @defaultValue `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'` **/
2022-12-05 05:13:36 +00:00
userAgent?: string
2022-12-07 04:07:14 +00:00
/** @defaultValue 1 hour **/
2022-12-07 04:07:14 +00:00
accessTokenTTL?: number
/** @defaultValue `undefined` **/
accessToken?: string
2022-12-12 01:40:06 +00:00
/** @defaultValue `undefined` **/
2022-12-12 01:40:06 +00:00
headers?: Record<string, string>
/** @defaultValue `false` **/
debug?: boolean
2022-12-05 05:13:36 +00:00
}) {
super()
2022-12-02 23:43:59 +00:00
const {
2022-12-05 05:13:36 +00:00
sessionToken,
clearanceToken,
2022-12-05 05:13:36 +00:00
markdown = true,
apiBaseUrl = 'https://chat.openai.com/api',
backendApiBaseUrl = 'https://chat.openai.com/backend-api',
2022-12-07 04:07:14 +00:00
userAgent = USER_AGENT,
accessTokenTTL = 60 * 60000, // 1 hour
2022-12-12 01:40:06 +00:00
accessToken,
headers,
debug = false
2022-12-02 23:43:59 +00:00
} = opts
2022-12-05 05:13:36 +00:00
this._sessionToken = sessionToken
this._clearanceToken = clearanceToken
2022-12-02 23:43:59 +00:00
this._markdown = !!markdown
this._debug = !!debug
2022-12-05 05:13:36 +00:00
this._apiBaseUrl = apiBaseUrl
this._backendApiBaseUrl = backendApiBaseUrl
this._userAgent = userAgent
this._headers = {
2022-12-12 01:40:06 +00:00
'user-agent': this._userAgent,
'x-openai-assistant-app-id': '',
'accept-language': 'en-US,en;q=0.9',
'accept-encoding': 'gzip, deflate, br',
origin: 'https://chat.openai.com',
referer: 'https://chat.openai.com/chat',
'sec-ch-ua':
'"Not?A_Brand";v="8", "Chromium";v="108", "Google Chrome";v="108"',
'sec-ch-ua-platform': '"macOS"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
2022-12-12 01:40:06 +00:00
'sec-fetch-site': 'same-origin',
...headers
}
2022-12-03 00:04:53 +00:00
2022-12-07 04:07:14 +00:00
this._accessTokenCache = new ExpiryMap<string, string>(accessTokenTTL)
if (accessToken) {
this._accessTokenCache.set(KEY_ACCESS_TOKEN, accessToken)
}
2022-12-07 04:07:14 +00:00
2022-12-05 05:13:36 +00:00
if (!this._sessionToken) {
const error = new types.ChatGPTError('ChatGPT invalid session token')
error.statusCode = 401
throw error
2022-12-03 00:04:53 +00:00
}
if (!this._clearanceToken) {
const error = new types.ChatGPTError('ChatGPT invalid clearance token')
error.statusCode = 401
throw error
}
2022-12-02 23:43:59 +00:00
}
/**
* Gets the currently signed-in user, if authenticated, `null` otherwise.
*/
get user() {
return this._user
}
/** Gets the current session token. */
get sessionToken() {
return this._sessionToken
}
/** Gets the current Cloudflare clearance token (`cf_clearance` cookie value). */
get clearanceToken() {
return this._clearanceToken
}
/** Gets the current user agent. */
get userAgent() {
return this._userAgent
}
/**
* Refreshes the client's access token which will succeed only if the session
* is valid.
*/
override async initSession() {
await this.refreshSession()
}
2022-12-05 05:13:36 +00:00
/**
* Sends a message to ChatGPT, waits for the response to resolve, and returns
* the response.
*
2022-12-07 04:07:14 +00:00
* If you want to receive a stream of partial responses, use `opts.onProgress`.
* If you want to receive the full response, including message and conversation IDs,
* you can use `opts.onConversationResponse` or use the `ChatGPTAPI.getConversation`
* helper.
*
* @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`)
2022-12-07 04:07:14 +00:00
* @param opts.timeoutMs - Optional timeout in milliseconds (defaults to no timeout)
2022-12-07 00:19:30 +00:00
* @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
2022-12-05 05:13:36 +00:00
*/
override async sendMessage(
2022-12-05 05:13:36 +00:00
message: string,
2022-12-07 00:19:30 +00:00
opts: types.SendMessageOptions = {}
): Promise<types.ChatResponse> {
2022-12-06 07:38:32 +00:00
const {
conversationId,
parentMessageId = uuidv4(),
messageId = uuidv4(),
action = 'next',
2022-12-07 04:07:14 +00:00
timeoutMs,
onProgress
2022-12-06 07:38:32 +00:00
} = opts
2022-12-05 05:13:36 +00:00
2022-12-07 04:07:14 +00:00
let { abortSignal } = opts
let abortController: AbortController = null
if (timeoutMs && !abortSignal) {
abortController = new AbortController()
abortSignal = abortController.signal
}
const accessToken = await this.refreshSession()
2022-12-05 05:13:36 +00:00
const body: types.ConversationJSONBody = {
action,
2022-12-05 05:13:36 +00:00
messages: [
{
id: messageId,
2022-12-05 05:13:36 +00:00
role: 'user',
content: {
content_type: 'text',
parts: [message]
}
}
],
model: 'text-davinci-002-render',
2022-12-06 07:38:32 +00:00
parent_message_id: parentMessageId
}
if (conversationId) {
body.conversation_id = conversationId
2022-12-05 05:13:36 +00:00
}
2022-12-02 23:43:59 +00:00
const result: types.ChatResponse = {
conversationId,
messageId,
response: ''
}
2022-12-05 05:13:36 +00:00
const responseP = new Promise<types.ChatResponse>((resolve, reject) => {
const url = `${this._backendApiBaseUrl}/conversation`
const headers = {
...this._headers,
Authorization: `Bearer ${accessToken}`,
Accept: 'text/event-stream',
'Content-Type': 'application/json',
Cookie: `cf_clearance=${this._clearanceToken}`
}
if (this._debug) {
console.log('POST', url, { body, headers })
}
fetchSSE(url, {
2022-12-05 05:13:36 +00:00
method: 'POST',
headers,
2022-12-05 05:13:36 +00:00
body: JSON.stringify(body),
signal: abortSignal,
2022-12-05 05:13:36 +00:00
onMessage: (data: string) => {
if (data === '[DONE]') {
return resolve(result)
2022-12-05 05:13:36 +00:00
}
try {
const convoResponseEvent: types.ConversationResponseEvent =
JSON.parse(data)
if (convoResponseEvent.conversation_id) {
result.conversationId = convoResponseEvent.conversation_id
2022-12-06 07:38:32 +00:00
}
2022-12-07 00:19:30 +00:00
if (convoResponseEvent.message?.id) {
result.messageId = convoResponseEvent.message.id
}
const message = convoResponseEvent.message
// console.log('event', JSON.stringify(convoResponseEvent, null, 2))
2022-12-05 05:13:36 +00:00
if (message) {
let text = message?.content?.parts?.[0]
if (text) {
if (!this._markdown) {
text = markdownToText(text)
}
result.response = text
2022-12-05 05:13:36 +00:00
if (onProgress) {
onProgress(result)
2022-12-05 05:13:36 +00:00
}
}
}
} catch (err) {
console.warn('fetchSSE onMessage unexpected error', err)
reject(err)
}
}
}).catch((err) => {
const errMessageL = err.toString().toLowerCase()
if (
result.response &&
(errMessageL === 'error: typeerror: terminated' ||
errMessageL === 'typeerror: terminated')
) {
// OpenAI sometimes forcefully terminates the socket from their end before
// 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(result)
} else {
return reject(err)
}
})
2022-12-05 05:13:36 +00:00
})
2022-12-07 04:07:14 +00:00
if (timeoutMs) {
if (abortController) {
// This will be called when a timeout occurs in order for us to forcibly
// ensure that the underlying HTTP request is aborted.
;(responseP as any).cancel = () => {
abortController.abort()
}
}
return pTimeout(responseP, {
milliseconds: timeoutMs,
message: 'ChatGPT timed out waiting for response'
})
} else {
return responseP
}
2022-12-02 23:43:59 +00:00
}
async sendModeration(input: string) {
const accessToken = await this.refreshSession()
const url = `${this._backendApiBaseUrl}/moderations`
const headers = {
...this._headers,
Authorization: `Bearer ${accessToken}`,
Accept: '*/*',
'Content-Type': 'application/json',
Cookie: `cf_clearance=${this._clearanceToken}`
}
const body: types.ModerationsJSONBody = {
input,
model: 'text-moderation-playground'
}
if (this._debug) {
console.log('POST', url, headers, body)
}
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
}).then((r) => {
if (!r.ok) {
const error = new types.ChatGPTError(`${r.status} ${r.statusText}`)
error.response = r
error.statusCode = r.status
error.statusText = r.statusText
throw error
}
return r.json() as any as types.ModerationsJSONResult
})
return res
}
2022-12-07 04:07:14 +00:00
/**
* @returns `true` if the client has a valid acces token or `false` if refreshing
* the token fails.
*/
override async getIsAuthenticated() {
2022-12-07 04:07:14 +00:00
try {
void (await this.refreshSession())
2022-12-07 04:07:14 +00:00
return true
} catch (err) {
return false
}
}
/**
* Attempts to refresh the current access token using the ChatGPT
* `sessionToken` cookie.
*
* Access tokens will be cached for up to `accessTokenTTL` milliseconds to
* prevent refreshing access tokens too frequently.
*
* @returns A valid access token
* @throws An error if refreshing the access token fails.
*/
override async refreshSession(): Promise<string> {
2022-12-05 05:13:36 +00:00
const cachedAccessToken = this._accessTokenCache.get(KEY_ACCESS_TOKEN)
if (cachedAccessToken) {
return cachedAccessToken
2022-12-02 23:43:59 +00:00
}
let response: Response
2022-12-05 05:13:36 +00:00
try {
const url = `${this._apiBaseUrl}/auth/session`
const headers = {
...this._headers,
cookie: `cf_clearance=${this._clearanceToken}; __Secure-next-auth.session-token=${this._sessionToken}`,
accept: '*/*'
}
if (this._debug) {
console.log('GET', url, headers)
}
const res = await fetch(url, {
headers
2022-12-07 00:19:30 +00:00
}).then((r) => {
response = r
2022-12-07 00:19:30 +00:00
if (!r.ok) {
const error = new types.ChatGPTError(`${r.status} ${r.statusText}`)
error.response = r
error.statusCode = r.status
error.statusText = r.statusText
throw error
2022-12-07 00:19:30 +00:00
}
return r.json() as any as types.SessionResult
})
2022-12-02 23:43:59 +00:00
2022-12-05 05:13:36 +00:00
const accessToken = res?.accessToken
2022-12-02 23:43:59 +00:00
2022-12-05 05:13:36 +00:00
if (!accessToken) {
const error = new types.ChatGPTError('Unauthorized')
error.response = response
error.statusCode = response?.status
error.statusText = response?.statusText
throw error
2022-12-02 23:43:59 +00:00
}
const appError = res?.error
if (appError) {
if (appError === 'RefreshAccessTokenError') {
const error = new types.ChatGPTError('session token may have expired')
error.response = response
error.statusCode = response?.status
error.statusText = response?.statusText
throw error
} else {
const error = new types.ChatGPTError(appError)
error.response = response
error.statusCode = response?.status
error.statusText = response?.statusText
throw error
}
}
if (res.user) {
this._user = res.user
}
2022-12-05 05:13:36 +00:00
this._accessTokenCache.set(KEY_ACCESS_TOKEN, accessToken)
return accessToken
} catch (err: any) {
if (this._debug) {
console.error(err)
}
const error = new types.ChatGPTError(
`ChatGPT failed to refresh auth token. ${err.toString()}`
)
error.response = response
error.statusCode = response?.status
error.statusText = response?.statusText
error.originalError = err
throw error
2022-12-05 05:13:36 +00:00
}
2022-12-05 05:14:23 +00:00
}
2022-12-06 07:38:32 +00:00
override async closeSession(): Promise<void> {
this._accessTokenCache.delete(KEY_ACCESS_TOKEN)
2022-12-06 07:38:32 +00:00
}
2022-12-02 23:43:59 +00:00
}