Skip to content

Commit ed6effd

Browse files
Merge pull request #165 from rijnb/main
Work on backup host
2 parents 8396056 + c946e67 commit ed6effd

File tree

8 files changed

+613
-557
lines changed

8 files changed

+613
-557
lines changed

.env.local.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ OPENAI_UNLOCK_CODE=...
88
# Azure OpenAI
99
OPENAI_API_TYPE=azure
1010
OPENAI_API_KEY=...
11+
OPENAI_API_KEY_BACKUP=...
1112
OPENAI_API_HOST=...
1213
OPENAI_API_HOST_BACKUP=...
1314
OPENAI_API_VERSION=2023-05-15
@@ -21,6 +22,7 @@ DEBUG=false
2122
# OpenAI
2223
# OPENAI_API_TYPE=openai
2324
# OPENAI_API_KEY=...
25+
# OPENAI_API_KEY_BACKUP=...
2426
# OPENAI_API_HOST=https://api.openai.com
2527
# OPENAI_API_HOST_BACKUP=https://api.openai.com
2628
# OPENAI_API_VERSION=2023-05-15-preview

package-lock.json

Lines changed: 484 additions & 491 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "chatty",
33
"productName": "Chatty",
4-
"version": "1.9.3",
4+
"version": "1.9.4",
55
"license": "MIT",
66
"private": true,
77
"scripts": {
@@ -25,7 +25,7 @@
2525
"@tabler/icons-react": "^2.47.0",
2626
"ai": "^2.2.37",
2727
"html-to-image": "^1.11.13",
28-
"i18next": "^25.3.6",
28+
"i18next": "^25.4.2",
2929
"js-tiktoken": "1.0.7",
3030
"next": "^14.2.31",
3131
"next-i18next": "^15.4.2",
@@ -34,10 +34,10 @@
3434
"react": "18.2.0",
3535
"react-dom": "18.2.0",
3636
"react-hot-toast": "^2.6.0",
37-
"react-i18next": "^15.6.1",
37+
"react-i18next": "^15.7.3",
3838
"react-markdown": "^8.0.7",
3939
"react-query": "^3.39.3",
40-
"react-syntax-highlighter": "^15.6.1",
40+
"react-syntax-highlighter": "^15.6.6",
4141
"rehype-mathjax": "^4.0.3",
4242
"remark-gfm": "^3.0.1",
4343
"remark-math": "^5.1.1",
@@ -46,31 +46,31 @@
4646
"devDependencies": {
4747
"@mozilla/readability": "^0.6.0",
4848
"@tailwindcss/typography": "^0.5.16",
49-
"@testing-library/jest-dom": "^6.7.0",
49+
"@testing-library/jest-dom": "^6.8.0",
5050
"@testing-library/react": "^16.3.0",
5151
"@testing-library/user-event": "^14.6.1",
5252
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
5353
"@types/jest": "^29.5.14",
5454
"@types/jsdom": "^21.1.7",
55-
"@types/node": "^24.1.0",
56-
"@types/react": "^18.3.23",
55+
"@types/node": "^24.3.0",
56+
"@types/react": "^18.3.24",
5757
"@types/react-dom": "^18.3.7",
5858
"@types/react-syntax-highlighter": "^15.5.13",
5959
"@types/testing-library__jest-dom": "^5.14.9",
6060
"@types/uuid": "^9.0.8",
6161
"autoprefixer": "^10.4.21",
6262
"endent": "^2.1.0",
6363
"eslint": "^8.57.0",
64-
"eslint-config-next": "^15.4.6",
65-
"jest": "^30.0.5",
66-
"jest-environment-jsdom": "^30.0.5",
64+
"eslint-config-next": "^15.5.2",
65+
"jest": "^30.1.3",
66+
"jest-environment-jsdom": "^30.1.2",
6767
"jest-fetch-mock": "^3.0.3",
6868
"jsdom": "^26.1.0",
6969
"next-router-mock": "^1.0.2",
70-
"prettier": "^3.5.3",
71-
"prettier-plugin-tailwindcss": "^0.6.13",
70+
"prettier": "^3.6.2",
71+
"prettier-plugin-tailwindcss": "^0.6.14",
7272
"tailwindcss": "^3.4.17",
73-
"ts-jest": "^29.4.0",
73+
"ts-jest": "^29.4.1",
7474
"typescript": "^5.9.2"
7575
}
7676
}

pages/api/google.api.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import endent from "endent"
2020
import jsdom, {JSDOM} from "jsdom"
2121
import {NextApiRequest, NextApiResponse} from "next"
2222

23-
import {Message, getMessageAsStringOnlyText} from "@/types/chat"
23+
24+
import {getMessageAsStringOnlyText, Message} from "@/types/chat"
2425
import {GoogleBody, GoogleSource} from "@/types/google"
2526
import {getAzureDeploymentIdForModelId} from "@/utils/app/azure"
2627
import {
@@ -33,6 +34,7 @@ import {
3334
import {trimForPrivacy} from "@/utils/app/privacy"
3435
import {cleanSourceText} from "@/utils/server/google"
3536

37+
3638
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
3739
try {
3840
const {messages, apiKey, modelId, googleAPIKey, googleCSEId} = req.body as GoogleBody
@@ -129,14 +131,15 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
129131
)}/chat/completions?api-version=${OPENAI_API_VERSION}`
130132
}
131133
console.debug(`Google search, POST:${OPENAI_API_TYPE}`)
134+
const primaryApiKey = apiKey || process.env.OPENAI_API_KEY
132135
const answerRes = await fetch(`${url}`, {
133136
headers: {
134137
"Content-Type": "application/json",
135138
...(OPENAI_API_TYPE === "openai" && {
136-
Authorization: `Bearer ${apiKey || process.env.OPENAI_API_KEY}`
139+
Authorization: `Bearer ${primaryApiKey}`
137140
}),
138141
...(OPENAI_API_TYPE === "azure" && {
139-
"api-key": `${apiKey || process.env.OPENAI_API_KEY}`
142+
"api-key": `${primaryApiKey}`
140143
}),
141144
...(OPENAI_API_TYPE === "openai" &&
142145
OPENAI_ORGANIZATION && {

pages/api/models.api.ts

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,17 @@ import {
2626
import {
2727
OPENAI_API_HOST,
2828
OPENAI_API_HOST_BACKUP,
29+
OPENAI_API_KEY,
30+
OPENAI_API_KEY_BACKUP,
2931
OPENAI_API_TYPE,
3032
OPENAI_API_VERSION,
3133
OPENAI_ORGANIZATION,
3234
SWITCH_BACK_TO_PRIMARY_HOST_TIMEOUT_MS
3335
} from "@/utils/app/const"
3436

3537
// Host switching mechanism.
36-
let currentHost = OPENAI_API_HOST
38+
let currentHost = ""
39+
let currentApiKey = ""
3740
let switchBackToPrimaryHostTime: number | undefined = undefined
3841

3942
function switchToBackupHost(): void {
@@ -42,24 +45,28 @@ function switchToBackupHost(): void {
4245
`Switching to backup host: ${OPENAI_API_HOST_BACKUP} for the next ${SWITCH_BACK_TO_PRIMARY_HOST_TIMEOUT_MS / 60000} minutes.`
4346
)
4447
currentHost = OPENAI_API_HOST_BACKUP
48+
currentApiKey = OPENAI_API_KEY_BACKUP
4549
switchBackToPrimaryHostTime = Date.now() + SWITCH_BACK_TO_PRIMARY_HOST_TIMEOUT_MS
50+
} else {
51+
switchBackToPrimaryHostIfNeeded(true)
4652
}
4753
}
4854

49-
function switchBackToPrimaryHostIfNeeded(): void {
50-
if (currentHost !== OPENAI_API_HOST && switchBackToPrimaryHostTime && Date.now() >= switchBackToPrimaryHostTime) {
51-
console.log(`Switching to primary host: ${OPENAI_API_HOST}`)
55+
function switchBackToPrimaryHostIfNeeded(forced = false): void {
56+
if (forced || !currentHost || (currentHost !== OPENAI_API_HOST && switchBackToPrimaryHostTime && Date.now() >= switchBackToPrimaryHostTime)) {
57+
console.log(`Switching back to primary host${forced ? " (forced)" : ""}: ${OPENAI_API_HOST}`)
5258
currentHost = OPENAI_API_HOST
59+
currentApiKey = OPENAI_API_KEY
5360
switchBackToPrimaryHostTime = undefined
5461
}
5562
}
5663

5764
function createGetModelsUrls(host: string): string {
58-
let url = `${host}/v1/models`
65+
let url = `${host}/v1/models?api-version=${OPENAI_API_VERSION}`
5966
if (OPENAI_API_TYPE === "azure") {
6067
url = `${host}/openai/models?api-version=${OPENAI_API_VERSION}`
6168
}
62-
console.debug(`Get models (${OPENAI_API_TYPE}): ${url}`)
69+
console.debug(`Get models (for ${OPENAI_API_TYPE}): ${url}`)
6370
return url
6471
}
6572

@@ -73,14 +80,15 @@ async function processModelsResponse(response: Response): Promise<Response> {
7380
const removeVisibleModels = OPENAI_API_TYPE === "azure" ? ["gpt-35-turbo-16k", "gpt-4", "gpt-4-32k"] : []
7481

7582
// Find models to display.
76-
const models: OpenAIModel[] = json.data.map((model: any) => {
77-
return {
78-
id: model.id,
79-
inputTokenLimit: maxInputTokensForModel(model.id),
80-
outputTokenLimit: maxOutputTokensForModel(model.id),
81-
isOpenAiReasoningModel: isOpenAIReasoningModel(model.id)
82-
}
83-
})
83+
const models: OpenAIModel[] = json.data
84+
.map((model: any) => {
85+
return {
86+
id: model.id,
87+
inputTokenLimit: maxInputTokensForModel(model.id),
88+
outputTokenLimit: maxOutputTokensForModel(model.id),
89+
isOpenAiReasoningModel: isOpenAIReasoningModel(model.id)
90+
}
91+
})
8492
.filter((model: any) => !removeVisibleModels.includes(model.id))
8593
.concat(addHiddenModels)
8694
.filter((model: OpenAIModel) => {
@@ -92,7 +100,7 @@ async function processModelsResponse(response: Response): Promise<Response> {
92100
}
93101
})
94102
.sort((a: OpenAIModel, b: OpenAIModel) => a.id.localeCompare(b.id))
95-
console.debug(`Found ${models.length} models: ${models.map((model) => model.id).join(", ")}`)
103+
console.debug(`Found ${models.length} models`)
96104
return new Response(JSON.stringify(models), {status: 200})
97105
}
98106

@@ -106,18 +114,17 @@ const handler = async (req: Request): Promise<Response> => {
106114
// Compose URL to get models.
107115
let url = createGetModelsUrls(currentHost)
108116

109-
// Compose HTTP headers.
110117
const headers = {
111118
"Content-Type": "application/json",
112119
...(OPENAI_API_TYPE === "openai" && {
113-
Authorization: `Bearer ${apiKey || process.env.OPENAI_API_KEY}`
120+
Authorization: `Bearer ${currentApiKey.length > 0 ? currentApiKey : apiKey}`
114121
}),
115122
...(OPENAI_API_TYPE === "openai" &&
116123
OPENAI_ORGANIZATION && {
117124
"OpenAI-Organization": OPENAI_ORGANIZATION
118125
}),
119126
...(OPENAI_API_TYPE === "azure" && {
120-
"api-key": `${apiKey || process.env.OPENAI_API_KEY}`
127+
"api-key": currentApiKey.length > 0 ? currentApiKey : apiKey
121128
})
122129
}
123130

@@ -129,39 +136,40 @@ const handler = async (req: Request): Promise<Response> => {
129136
return await processModelsResponse(response)
130137
} else {
131138
// Primary host response not OK. This should not cause a switch to the backup host.
132-
console.error(`Primary host for getting models for '${OPENAI_API_TYPE}' returned an error: ${JSON.stringify(response)}`)
139+
console.error(
140+
`Primary host for getting models for '${OPENAI_API_TYPE}' returned an error: ${JSON.stringify(response)}`
141+
)
133142
responseInit = {status: 500, statusText: response ? JSON.stringify(response) : ""}
134143
}
135144
} catch (error) {
136145
// Primary host response returns HTTP error.
137146
console.error(`Primary host for '${OPENAI_API_TYPE}' threw an exception; ${JSON.stringify(error)}`)
138-
if (
139-
currentHost !== OPENAI_API_HOST_BACKUP &&
140-
(!(error instanceof RemoteError) || (error.status >= 500 && error.status < 600))
141-
) {
147+
if (!(error instanceof RemoteError) || (error.status >= 500 && error.status < 600)) {
142148
// Exception was thrown because the primary server (not the backup one) returns an 5xx error.
143149
console.log(`Switching to backup host due to error: ${JSON.stringify(error)}`)
144150
switchToBackupHost()
145151

146152
// Retry with the backup host. Recreate the URL with the new host. HTTP headers remains the same.
147-
let backupUrl = createGetModelsUrls(currentHost)
153+
let retryUrl = createGetModelsUrls(currentHost)
148154

149155
try {
150-
const backupResponse = await fetch(backupUrl, {headers: headers})
151-
if (backupResponse.ok) {
156+
const retryResponse = await fetch(retryUrl, {headers: headers})
157+
if (retryResponse.ok) {
152158
// Backup host OK.
153-
return await processModelsResponse(backupResponse)
159+
return await processModelsResponse(retryResponse)
154160
} else {
155161
// Backup host response not OK.
156-
console.error(`Backup host for getting models for '${OPENAI_API_TYPE}' returned an error: ${JSON.stringify(backupResponse)}`)
157-
responseInit = {status: 500, statusText: backupResponse ? JSON.stringify(backupResponse) : ""}
162+
console.error(
163+
`Backup host for getting models for '${OPENAI_API_TYPE}' returned an error: ${JSON.stringify(retryResponse)}`
164+
)
165+
responseInit = {status: 500, statusText: retryResponse ? JSON.stringify(retryResponse) : ""}
158166
}
159-
} catch (backupError) {
167+
} catch (retryError) {
160168
// Backup host response throws an HTTP error.
161169
console.error(`Backup host for '${OPENAI_API_TYPE}' threw an exception: ${JSON.stringify(error)}`)
162170

163171
// Return a 5xx error.
164-
responseInit = {status: 500, statusText: backupError ? JSON.stringify(backupError) : ""}
172+
responseInit = {status: 500, statusText: retryError ? JSON.stringify(retryError) : ""}
165173
}
166174
} else {
167175
// Some other exception. No retry.

public/RELEASE_NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ _Rijn Buve & Oleksii Kulyk_
158158

159159
## Release notes
160160

161+
### 2025-09-02
162+
163+
### Features
164+
165+
- Allow backup host to use a different API key.
166+
161167
### 2025-08-18
162168

163169
#### Features

utils/app/const.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,16 @@ export const OPENAI_DEFAULT_TEMPERATURE = parseFloat(process.env.OPENAI_DEFAULT_
3131
// The API type; "openai" or "azure".
3232
export const OPENAI_API_TYPE = process.env.OPENAI_API_TYPE ?? "openai"
3333

34-
// The host URI; for OpenAI only.
34+
// The API key for the primary host.
35+
export const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""
36+
37+
// The API key for the backup host.
38+
export const OPENAI_API_KEY_BACKUP = process.env.OPENAI_API_KEY_BACKUP ?? OPENAI_API_KEY
39+
40+
// The primary host URI.
3541
export const OPENAI_API_HOST = process.env.OPENAI_API_HOST ?? "https://api.openai.com"
3642

37-
// The backup host URI; for OpenAI only.
43+
// The backup host URI.
3844
export const OPENAI_API_HOST_BACKUP = process.env.OPENAI_API_HOST_BACKUP ?? OPENAI_API_HOST
3945

4046
// The API version.

0 commit comments

Comments
 (0)