diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json
index 8ad74e6ef..7f666c256 100644
--- a/src/_locales/en/main.json
+++ b/src/_locales/en/main.json
@@ -124,10 +124,18 @@
"Override provider temperature": "Override provider temperature",
"The temperature parameter is not sent. The provider or model default is used.": "The temperature parameter is not sent. The provider or model default is used.",
"The current model does not accept a custom temperature. The parameter will not be sent.": "The current model does not accept a custom temperature. The parameter will not be sent.",
+ "Extra Request Body (JSON)": "Extra Request Body (JSON)",
+ "Merged into the API request body. Must be a JSON object, other values are ignored.": "Merged into the API request body. Must be a JSON object, other values are ignored.",
+ "Invalid JSON object, this value is ignored.": "Invalid JSON object, this value is ignored.",
"API Url": "API Url",
"Provider": "Provider",
"Others": "Others",
"API Modes": "API Modes",
+ "Test": "Test",
+ "Testing...": "Testing...",
+ "Reachable": "Reachable",
+ "Unreachable": "Unreachable",
+ "Not testable": "Not testable",
"Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time",
"Display selection tools next to input box to avoid blocking": "Display selection tools next to input box to avoid blocking",
"Close All Chats In This Page": "Close All Chats In This Page",
diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json
index cfad6d2d1..d2b20e24a 100644
--- a/src/_locales/zh-hans/main.json
+++ b/src/_locales/zh-hans/main.json
@@ -118,10 +118,18 @@
"Override provider temperature": "覆盖提供商的温度参数",
"The temperature parameter is not sent. The provider or model default is used.": "不会发送温度参数,将使用提供商或模型的默认值。",
"The current model does not accept a custom temperature. The parameter will not be sent.": "当前模型不接受自定义温度参数,因此不会发送该参数。",
+ "Extra Request Body (JSON)": "额外请求参数 (JSON)",
+ "Merged into the API request body. Must be a JSON object, other values are ignored.": "会合并进 API 请求体,必须是 JSON 对象,其他类型的值会被忽略。",
+ "Invalid JSON object, this value is ignored.": "不是合法的 JSON 对象,该值会被忽略。",
"API Url": "API地址",
"Provider": "提供商",
"Others": "其他",
"API Modes": "API模式",
+ "Test": "测试",
+ "Testing...": "测试中…",
+ "Reachable": "可连通",
+ "Unreachable": "无法连通",
+ "Not testable": "无法测试",
"Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "禁用网页版模式历史记录以获得更好的隐私保护, 但会导致对话在一段时间后不可用",
"Display selection tools next to input box to avoid blocking": "将选择浮动工具显示在输入框旁边以避免遮挡",
"Close All Chats In This Page": "关闭本页所有聊天",
diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json
index 3dd20a229..50cadc6f0 100644
--- a/src/_locales/zh-hant/main.json
+++ b/src/_locales/zh-hant/main.json
@@ -118,10 +118,18 @@
"Override provider temperature": "覆寫供應商的溫度參數",
"The temperature parameter is not sent. The provider or model default is used.": "不會傳送溫度參數,將使用供應商或模型的預設值。",
"The current model does not accept a custom temperature. The parameter will not be sent.": "目前的模型不接受自訂溫度參數,因此不會傳送這個參數。",
+ "Extra Request Body (JSON)": "額外請求參數 (JSON)",
+ "Merged into the API request body. Must be a JSON object, other values are ignored.": "會合併進 API 請求主體,必須是 JSON 物件,其他類型的值會被忽略。",
+ "Invalid JSON object, this value is ignored.": "不是合法的 JSON 物件,這個值會被忽略。",
"API Url": "API 網址",
"Provider": "供應商",
"Others": "其他",
"API Modes": "API 模式",
+ "Test": "測試",
+ "Testing...": "測試中…",
+ "Reachable": "可連線",
+ "Unreachable": "無法連線",
+ "Not testable": "無法測試",
"Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "停用網頁版模式歷史記錄以提升隱私保護,但會導致對話記錄在一段時間後無法使用",
"Display selection tools next to input box to avoid blocking": "將選擇浮動工具顯示在輸入框旁邊以避免遮擋",
"Close All Chats In This Page": "關閉本頁所有對話",
diff --git a/src/background/index.mjs b/src/background/index.mjs
index 79b4f0c0d..177e7c054 100644
--- a/src/background/index.mjs
+++ b/src/background/index.mjs
@@ -47,10 +47,12 @@ import {
import { refreshMenu } from './menus.mjs'
import { registerCommands } from './commands.mjs'
import { generateAnswersWithBardWebApi } from '../services/apis/bard-web.mjs'
+import { testConnection } from '../services/apis/test-connection.mjs'
import { generateAnswersWithClaudeWebApi } from '../services/apis/claude-web.mjs'
import { generateAnswersWithMoonshotWebApi } from '../services/apis/moonshot-web.mjs'
import { isUsingModelName } from '../utils/model-name-convert.mjs'
import { redactSensitiveFields } from './redact.mjs'
+import { isTrustedExtensionSender } from './message-sender.mjs'
import {
clearProxyReconnectErrorSuppression,
consumeProxyReconnectErrorSuppression,
@@ -615,6 +617,17 @@ Browser.runtime.onMessage.addListener(async (message, sender) => {
await deleteConversation(token, message.data.conversationId)
break
}
+ case 'TEST_API_CONNECTION': {
+ if (!isTrustedExtensionSender(sender)) {
+ console.warn(
+ '[background] Rejecting TEST_API_CONNECTION message from untrusted sender:',
+ sender,
+ )
+ return { ok: false, elapsedMs: 0, error: 'unauthorized-sender' }
+ }
+ console.log('[background] Processing TEST_API_CONNECTION message')
+ return testConnection(message.data.session)
+ }
case 'NEW_URL': {
console.log('[background] Processing NEW_URL message:', message.data)
await Browser.tabs.create({
@@ -693,13 +706,7 @@ Browser.runtime.onMessage.addListener(async (message, sender) => {
break
}
case 'FETCH': {
- const senderId = sender?.id
- const senderUrl = sender?.url || sender?.documentUrl || sender?.origin
- const extensionOrigin = new URL(Browser.runtime.getURL('/')).origin
- const isTrustedExtensionSenderWithoutId =
- !senderId && typeof senderUrl === 'string' && senderUrl.startsWith(`${extensionOrigin}/`)
-
- if (senderId !== Browser.runtime.id && !isTrustedExtensionSenderWithoutId) {
+ if (!isTrustedExtensionSender(sender)) {
console.warn('[background] Rejecting FETCH message from untrusted sender:', sender)
return [null, { message: 'Unauthorized sender' }]
}
diff --git a/src/background/message-sender.mjs b/src/background/message-sender.mjs
new file mode 100644
index 000000000..8dc0ae6ec
--- /dev/null
+++ b/src/background/message-sender.mjs
@@ -0,0 +1,16 @@
+import Browser from 'webextension-polyfill'
+
+/**
+ * Messages that answer with data or reach stored credentials must come from extension
+ * code. A sender that reports an id is trusted only when it is this extension; extension
+ * pages in some browsers report no id, so their own URL is the fallback signal.
+ * @param {{id?: string, url?: string, documentUrl?: string, origin?: string}} sender
+ * @returns {boolean}
+ */
+export function isTrustedExtensionSender(sender) {
+ if (sender?.id === Browser.runtime.id) return true
+ if (sender?.id) return false
+ const senderUrl = sender?.url || sender?.documentUrl || sender?.origin
+ if (typeof senderUrl !== 'string') return false
+ return senderUrl.startsWith(Browser.runtime.getURL('/'))
+}
diff --git a/src/config/index.mjs b/src/config/index.mjs
index 81c956e46..1f1853fda 100644
--- a/src/config/index.mjs
+++ b/src/config/index.mjs
@@ -855,6 +855,7 @@ export const defaultConfig = {
maxConversationContextLength: 9,
temperatureOverrideEnabled: false,
temperature: 1,
+ extraBody: '',
customChatGptWebApiUrl: 'https://chatgpt.com',
customChatGptWebApiPath: '/backend-api/conversation',
customOpenAiApiUrl: 'https://api.openai.com',
diff --git a/src/popup/sections/AdvancedPart.jsx b/src/popup/sections/AdvancedPart.jsx
index 017b9191f..134535198 100644
--- a/src/popup/sections/AdvancedPart.jsx
+++ b/src/popup/sections/AdvancedPart.jsx
@@ -3,6 +3,7 @@ import { parseFloatWithClamp, parseIntWithClamp } from '../../utils/index.mjs'
import { getModelValue } from '../../utils/model-name-convert.mjs'
import { isUsingAzureOpenAiApiModel } from '../../config/index.mjs'
import { canApplyTemperatureOverride } from '../../services/apis/temperature-params.mjs'
+import { parseExtraBody } from '../../services/apis/extra-body-params.mjs'
import PropTypes from 'prop-types'
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'
import Browser from 'webextension-polyfill'
@@ -22,6 +23,8 @@ function ApiParams({ config, updateConfig }) {
? config.customModelName
: getModelValue(config)
const temperatureOverrideAvailable = canApplyTemperatureOverride(selectedModel)
+ const extraBodyValue = typeof config.extraBody === 'string' ? config.extraBody : ''
+ const extraBodyInvalid = extraBodyValue.trim() !== '' && !parseExtraBody(extraBodyValue)
return (
<>
@@ -89,6 +92,21 @@ function ApiParams({ config, updateConfig }) {
/>
)}
+
+
+ {extraBodyInvalid
+ ? t('Invalid JSON object, this value is ignored.')
+ : t('Merged into the API request body. Must be a JSON object, other values are ignored.')}
+
>
)
}
diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx
index 5f39e0fa0..6daf66ceb 100644
--- a/src/popup/sections/ApiModes.jsx
+++ b/src/popup/sections/ApiModes.jsx
@@ -13,6 +13,12 @@ import {
getCustomOpenAIProviders,
OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID,
} from '../../services/apis/provider-registry.mjs'
+import { canTestConnectionSession } from '../../services/apis/connection-test-groups.mjs'
+import {
+ getConnectionTestButtonStyle,
+ getConnectionTestLabel,
+ getConnectionTestTitle,
+} from './connection-test-status.mjs'
import {
applySelectedProviderToApiMode,
applyDeletedProviderSecrets,
@@ -72,6 +78,20 @@ const defaultProviderDraftValidation = {
apiUrl: false,
}
+// Results are keyed by what the mode is, not by where it happens to sit in the list, so
+// reordering or deleting a row cannot attach a result to a different provider.
+function getConnectionTestKey(apiMode) {
+ return [
+ apiMode?.groupName,
+ apiMode?.itemName,
+ apiMode?.customName,
+ apiMode?.providerId,
+ apiMode?.customUrl,
+ ]
+ .map((part) => String(part ?? '').trim())
+ .join('\u0000')
+}
+
export function ApiModes({ config, updateConfig }) {
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
@@ -88,6 +108,7 @@ export function ApiModes({ config, updateConfig }) {
const [providerSelector, setProviderSelector] = useState(LEGACY_CUSTOM_PROVIDER_ID)
const [isProviderEditorOpen, setIsProviderEditorOpen] = useState(false)
const [providerEditingId, setProviderEditingId] = useState('')
+ const [connectionTests, setConnectionTests] = useState({})
const [providerDraft, setProviderDraft] = useState(defaultProviderDraft)
const [providerDraftValidation, setProviderDraftValidation] = useState(
defaultProviderDraftValidation,
@@ -269,6 +290,25 @@ export function ApiModes({ config, updateConfig }) {
setIsProviderEditorOpen(true)
}
+ const runConnectionTest = async (apiMode) => {
+ const key = getConnectionTestKey(apiMode)
+ // A probe in flight owns the row: a second click would race it for the same result.
+ if (connectionTests[key]?.pending) return
+ setConnectionTests((current) => ({ ...current, [key]: { pending: true } }))
+ let result
+ try {
+ result = await Browser.runtime.sendMessage({
+ type: 'TEST_API_CONNECTION',
+ data: { session: { apiMode } },
+ })
+ } catch (error) {
+ result = { ok: false, error: error?.message ?? String(error) }
+ }
+ setConnectionTests((current) => ({ ...current, [key]: { ...result, pending: false } }))
+ }
+
+ const getConnectionTest = (apiMode) => connectionTests[getConnectionTestKey(apiMode)]
+
const onSaveProviderEditing = (event) => {
event.preventDefault()
const providerName = providerDraft.name.trim()
@@ -629,7 +669,26 @@ export function ApiModes({ config, updateConfig }) {
/>
{getApiModeDisplayLabel(apiMode, t, effectiveProviders)}