diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json
index 8ad74e6ef..0397d490d 100644
--- a/src/_locales/en/main.json
+++ b/src/_locales/en/main.json
@@ -124,6 +124,9 @@
"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",
diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json
index cfad6d2d1..3748a96ba 100644
--- a/src/_locales/zh-hans/main.json
+++ b/src/_locales/zh-hans/main.json
@@ -118,6 +118,9 @@
"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": "其他",
diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json
index 3dd20a229..832f8792f 100644
--- a/src/_locales/zh-hant/main.json
+++ b/src/_locales/zh-hant/main.json
@@ -118,6 +118,9 @@
"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": "其他",
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/services/apis/azure-openai-api.mjs b/src/services/apis/azure-openai-api.mjs
index f5e1b4b6d..7eb4850ad 100644
--- a/src/services/apis/azure-openai-api.mjs
+++ b/src/services/apis/azure-openai-api.mjs
@@ -5,6 +5,7 @@ import { fetchSSE } from '../../utils/fetch-sse.mjs'
import { isEmpty } from 'lodash-es'
import { getModelValue } from '../../utils/model-name-convert.mjs'
import { getTemperatureParams } from './temperature-params.mjs'
+import { getExtraBodyParams } from './extra-body-params.mjs'
/**
* @param {Runtime.Port} port
@@ -42,6 +43,7 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session)
max_tokens: config.maxResponseTokenLength,
// Azure deployment names are opaque aliases, not canonical model identifiers.
...getTemperatureParams(config),
+ ...getExtraBodyParams(config),
}),
onMessage(message) {
console.debug('sse message', message)
diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs
index 5d298877a..d9a12bd39 100644
--- a/src/services/apis/claude-api.mjs
+++ b/src/services/apis/claude-api.mjs
@@ -5,6 +5,7 @@ import { isEmpty } from 'lodash-es'
import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs'
import { getModelValue } from '../../utils/model-name-convert.mjs'
import { getTemperatureParams } from './temperature-params.mjs'
+import { getExtraBodyParams } from './extra-body-params.mjs'
function getThinkingConfig(model) {
if (model === 'claude-sonnet-5') return { type: 'disabled' }
@@ -37,6 +38,8 @@ export async function generateAnswersWithClaudeApi(port, question, session) {
}
const thinking = getThinkingConfig(model)
if (thinking) body.thinking = thinking
+ // The user-provided body wins over the built-in defaults above.
+ Object.assign(body, getExtraBodyParams(config))
let answer = ''
let stopReason = ''
diff --git a/src/services/apis/extra-body-params.mjs b/src/services/apis/extra-body-params.mjs
new file mode 100644
index 000000000..3be95c3e0
--- /dev/null
+++ b/src/services/apis/extra-body-params.mjs
@@ -0,0 +1,35 @@
+/**
+ * Parse the user-provided extra request body.
+ * @param {unknown} raw JSON text from the advanced settings textarea
+ * @returns {Record | null} the parsed object, or null when unusable
+ */
+export function parseExtraBody(raw) {
+ if (typeof raw !== 'string' || !raw.trim()) return null
+ let parsed
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return null
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
+ return parsed
+}
+
+/**
+ * Keys every request builder owns. A custom body may add parameters the UI does
+ * not expose, but replacing these would desync the request from the conversation,
+ * model and settings the user picked, and `stream` must stay on for SSE parsing.
+ */
+const RESERVED_KEYS = ['stream', 'model', 'messages', 'prompt', 'temperature']
+
+/**
+ * Extra fields merged into the API request body.
+ * @param {UserConfig} config
+ * @returns {Record}
+ */
+export function getExtraBodyParams(config) {
+ const extraBody = parseExtraBody(config?.extraBody)
+ if (!extraBody) return {}
+ for (const key of RESERVED_KEYS) delete extraBody[key]
+ return extraBody
+}
diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs
index 02dfe4a81..724d34a3f 100644
--- a/src/services/apis/openai-compatible-core.mjs
+++ b/src/services/apis/openai-compatible-core.mjs
@@ -4,6 +4,7 @@ import { isEmpty } from 'lodash-es'
import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs'
import { getChatCompletionsTokenParams } from './openai-token-params.mjs'
import { getTemperatureParams } from './temperature-params.mjs'
+import { getExtraBodyParams } from './extra-body-params.mjs'
function buildHeaders(apiKey, extraHeaders = {}) {
const headers = {
@@ -76,11 +77,15 @@ export async function generateAnswersWithOpenAICompatible({
session.conversationRecords = conversationRecords
const safeExtraBody = { ...extraBody }
delete safeExtraBody.temperature
+ // Merged last so the Advanced setting wins over built-in values, except for
+ // the token-limit key: only the one the request shape expects may be sent.
+ const configuredExtraBody = getExtraBodyParams(config)
if (endpointType === 'completion') {
const prompt =
(await getCompletionPromptBase()) +
getConversationPairs(conversationRecords.slice(-config.maxConversationContextLength), true) +
`Human: ${question}\nAI: `
+ delete configuredExtraBody.max_completion_tokens
requestBody = {
prompt,
model,
@@ -89,6 +94,7 @@ export async function generateAnswersWithOpenAICompatible({
...getTemperatureParams(config, model),
stop: '\nHuman',
...safeExtraBody,
+ ...configuredExtraBody,
}
} else {
const messages = getConversationPairs(
@@ -104,6 +110,7 @@ export async function generateAnswersWithOpenAICompatible({
const conflictingTokenParamKey =
'max_completion_tokens' in tokenParams ? 'max_tokens' : 'max_completion_tokens'
delete safeExtraBody[conflictingTokenParamKey]
+ delete configuredExtraBody[conflictingTokenParamKey]
requestBody = {
messages,
model,
@@ -111,6 +118,7 @@ export async function generateAnswersWithOpenAICompatible({
...tokenParams,
...getTemperatureParams(config, model),
...safeExtraBody,
+ ...configuredExtraBody,
}
}
diff --git a/tests/unit/services/extra-body-params.test.mjs b/tests/unit/services/extra-body-params.test.mjs
new file mode 100644
index 000000000..e7ebe9352
--- /dev/null
+++ b/tests/unit/services/extra-body-params.test.mjs
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import {
+ getExtraBodyParams,
+ parseExtraBody,
+} from '../../../src/services/apis/extra-body-params.mjs'
+
+test('parseExtraBody accepts a JSON object', () => {
+ assert.deepEqual(parseExtraBody('{"thinking":{"type":"enabled","budget_tokens":2048}}'), {
+ thinking: { type: 'enabled', budget_tokens: 2048 },
+ })
+})
+
+test('parseExtraBody rejects anything that is not a JSON object', () => {
+ for (const raw of ['', ' ', 'not json', '[1,2]', '"text"', '42', 'null', null, undefined, {}]) {
+ assert.equal(parseExtraBody(raw), null, `expected null for ${JSON.stringify(raw)}`)
+ }
+})
+
+test('getExtraBodyParams is empty without a usable config value', () => {
+ assert.deepEqual(getExtraBodyParams(undefined), {})
+ assert.deepEqual(getExtraBodyParams({}), {})
+ assert.deepEqual(getExtraBodyParams({ extraBody: '{oops' }), {})
+})
+
+test('getExtraBodyParams forwards user fields and keeps stream under extension control', () => {
+ const config = { extraBody: '{"reasoning_effort":"high","stream":false}' }
+
+ assert.deepEqual(getExtraBodyParams(config), { reasoning_effort: 'high' })
+ // The parsed object is rebuilt per call, so repeated reads stay stripped.
+ assert.deepEqual(getExtraBodyParams(config), { reasoning_effort: 'high' })
+})
+
+test('getExtraBodyParams strips every key the request builders own', () => {
+ const config = {
+ extraBody: JSON.stringify({
+ reasoning_effort: 'high',
+ top_p: 0.9,
+ stream: false,
+ model: 'gpt-4o',
+ messages: [{ role: 'user', content: 'tampered' }],
+ prompt: 'tampered',
+ temperature: 0.1,
+ }),
+ }
+
+ assert.deepEqual(getExtraBodyParams(config), { reasoning_effort: 'high', top_p: 0.9 })
+})
diff --git a/tests/unit/services/extra-body-request.test.mjs b/tests/unit/services/extra-body-request.test.mjs
new file mode 100644
index 000000000..b04357163
--- /dev/null
+++ b/tests/unit/services/extra-body-request.test.mjs
@@ -0,0 +1,171 @@
+import assert from 'node:assert/strict'
+import { beforeEach, test } from 'node:test'
+import { generateAnswersWithOpenAICompatible } from '../../../src/services/apis/openai-compatible-core.mjs'
+import { generateAnswersWithClaudeApi } from '../../../src/services/apis/claude-api.mjs'
+import { generateAnswersWithAzureOpenaiApi } from '../../../src/services/apis/azure-openai-api.mjs'
+import { createFakePort } from '../helpers/port.mjs'
+import { createMockSseResponse } from '../helpers/sse-response.mjs'
+
+const CHAT_CHUNKS = [
+ 'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n',
+ 'data: [DONE]\n\n',
+]
+
+const CLAUDE_CHUNKS = [
+ 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n',
+ 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n',
+ 'data: {"type":"message_stop"}\n\n',
+]
+
+beforeEach(() => {
+ globalThis.__TEST_BROWSER_SHIM__.clearStorage()
+})
+
+async function captureRequestBody(t, chunks, run) {
+ let requestBody
+ t.mock.method(console, 'debug', () => {})
+ t.mock.method(globalThis, 'fetch', async (url, options) => {
+ requestBody = JSON.parse(options.body)
+ return createMockSseResponse(chunks)
+ })
+ await run()
+ return requestBody
+}
+
+function openAiCompatibleRequest({ endpointType = 'chat', model = 'gpt-5', extraBody }) {
+ return {
+ port: createFakePort(),
+ question: 'hi',
+ session: { conversationRecords: [] },
+ endpointType,
+ requestUrl: `https://example.com/v1/${
+ endpointType === 'chat' ? 'chat/completions' : 'completions'
+ }`,
+ model,
+ apiKey: 'key',
+ provider: 'openai',
+ config: {
+ maxConversationContextLength: 9,
+ maxResponseTokenLength: 1000,
+ temperatureOverrideEnabled: false,
+ temperature: 1,
+ extraBody,
+ },
+ }
+}
+
+test('OpenAI-compatible requests send the extra body without losing the SSE stream', async (t) => {
+ const requestBody = await captureRequestBody(t, CHAT_CHUNKS, () =>
+ generateAnswersWithOpenAICompatible(
+ openAiCompatibleRequest({ extraBody: '{"reasoning_effort":"high","stream":false}' }),
+ ),
+ )
+
+ assert.equal(requestBody.reasoning_effort, 'high')
+ assert.equal(requestBody.stream, true)
+ assert.equal(requestBody.model, 'gpt-5')
+})
+
+test('OpenAI-compatible chat requests keep only the token key the model family uses', async (t) => {
+ const requestBody = await captureRequestBody(t, CHAT_CHUNKS, () =>
+ generateAnswersWithOpenAICompatible(
+ openAiCompatibleRequest({ extraBody: '{"max_tokens":123,"max_completion_tokens":456}' }),
+ ),
+ )
+
+ // gpt-5 sends max_completion_tokens, so that is where the override lands.
+ assert.equal(requestBody.max_completion_tokens, 456)
+ assert.equal('max_tokens' in requestBody, false)
+})
+
+test('OpenAI-compatible chat requests drop the unused token key for max_tokens models', async (t) => {
+ const requestBody = await captureRequestBody(t, CHAT_CHUNKS, () =>
+ generateAnswersWithOpenAICompatible(
+ openAiCompatibleRequest({
+ model: 'gpt-4.1',
+ extraBody: '{"max_tokens":123,"max_completion_tokens":456}',
+ }),
+ ),
+ )
+
+ assert.equal(requestBody.max_tokens, 123)
+ assert.equal('max_completion_tokens' in requestBody, false)
+})
+
+test('OpenAI-compatible completion requests stay on max_tokens', async (t) => {
+ const requestBody = await captureRequestBody(t, CHAT_CHUNKS, () =>
+ generateAnswersWithOpenAICompatible(
+ openAiCompatibleRequest({
+ endpointType: 'completion',
+ model: 'gpt-3.5-turbo-instruct',
+ extraBody: '{"max_tokens":123,"max_completion_tokens":456}',
+ }),
+ ),
+ )
+
+ assert.equal(requestBody.max_tokens, 123)
+ assert.equal('max_completion_tokens' in requestBody, false)
+})
+
+test('OpenAI-compatible requests keep the conversation and model under extension control', async (t) => {
+ const requestBody = await captureRequestBody(t, CHAT_CHUNKS, () =>
+ generateAnswersWithOpenAICompatible(
+ openAiCompatibleRequest({
+ extraBody: '{"model":"gpt-4o","messages":[{"role":"user","content":"tampered"}]}',
+ }),
+ ),
+ )
+
+ assert.equal(requestBody.model, 'gpt-5')
+ assert.deepEqual(requestBody.messages, [{ role: 'user', content: 'hi' }])
+})
+
+test('Azure OpenAI requests send the extra body', async (t) => {
+ globalThis.__TEST_BROWSER_SHIM__.replaceStorage({
+ azureApiKey: 'key',
+ azureEndpoint: 'https://example.openai.azure.com',
+ azureDeploymentName: 'deployment',
+ extraBody: '{"reasoning_effort":"high"}',
+ })
+
+ const requestBody = await captureRequestBody(t, CHAT_CHUNKS, () =>
+ generateAnswersWithAzureOpenaiApi(createFakePort(), 'hi', { conversationRecords: [] }),
+ )
+
+ assert.equal(requestBody.reasoning_effort, 'high')
+})
+
+test('Claude requests let the extra body override the built-in thinking default', async (t) => {
+ globalThis.__TEST_BROWSER_SHIM__.replaceStorage({
+ anthropicApiKey: 'key',
+ extraBody: '{"thinking":{"type":"enabled","budget_tokens":2048}}',
+ })
+
+ const requestBody = await captureRequestBody(t, CLAUDE_CHUNKS, () =>
+ generateAnswersWithClaudeApi(createFakePort(), 'hi', {
+ // This model normally gets thinking forced off.
+ modelName: 'claudeSonnet5Api',
+ conversationRecords: [],
+ }),
+ )
+
+ assert.equal(requestBody.model, 'claude-sonnet-5')
+ assert.deepEqual(requestBody.thinking, { type: 'enabled', budget_tokens: 2048 })
+})
+
+test('Claude requests keep the model and conversation under extension control', async (t) => {
+ globalThis.__TEST_BROWSER_SHIM__.replaceStorage({
+ anthropicApiKey: 'key',
+ extraBody: '{"model":"claude-opus-9","messages":[{"role":"user","content":"tampered"}]}',
+ })
+
+ const requestBody = await captureRequestBody(t, CLAUDE_CHUNKS, () =>
+ generateAnswersWithClaudeApi(createFakePort(), 'hi', {
+ modelName: 'claudeSonnet5Api',
+ conversationRecords: [],
+ }),
+ )
+
+ assert.equal(requestBody.model, 'claude-sonnet-5')
+ assert.deepEqual(requestBody.messages, [{ role: 'user', content: 'hi' }])
+})