From f61529bfbcfda9d0f88fcb5815aafb72bf84d864 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:22:47 +0800 Subject: [PATCH] Make temperature overrides opt-in --- src/_locales/de/main.json | 3 + src/_locales/en/main.json | 3 + src/_locales/es/main.json | 3 + src/_locales/fr/main.json | 3 + src/_locales/in/main.json | 3 + src/_locales/it/main.json | 3 + src/_locales/ja/main.json | 3 + src/_locales/ko/main.json | 3 + src/_locales/pt/main.json | 3 + src/_locales/ru/main.json | 3 + src/_locales/tr/main.json | 3 + src/_locales/zh-hans/main.json | 3 + src/_locales/zh-hant/main.json | 3 + src/config/index.mjs | 1 + src/popup/sections/AdvancedPart.jsx | 49 +++++-- src/services/apis/azure-openai-api.mjs | 10 +- src/services/apis/claude-api.mjs | 14 +- src/services/apis/openai-api.mjs | 1 + src/services/apis/openai-compatible-core.mjs | 10 +- src/services/apis/temperature-params.mjs | 55 ++++++++ tests/unit/config/user-config.test.mjs | 21 +++ .../services/apis/azure-openai-api.test.mjs | 31 +++++ .../apis/azure-openai-temperature.test.mjs | 40 ++++++ tests/unit/services/apis/claude-api.test.mjs | 34 +++++ tests/unit/services/apis/custom-api.test.mjs | 1 + .../services/apis/openai-api-compat.test.mjs | 38 ++++++ .../openai-compatible-temperature.test.mjs | 122 ++++++++++++++++++ .../services/apis/temperature-params.test.mjs | 99 ++++++++++++++ 28 files changed, 537 insertions(+), 28 deletions(-) create mode 100644 src/services/apis/temperature-params.mjs create mode 100644 tests/unit/services/apis/azure-openai-temperature.test.mjs create mode 100644 tests/unit/services/apis/openai-compatible-temperature.test.mjs create mode 100644 tests/unit/services/apis/temperature-params.test.mjs diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 350a5a10d..8b0d204bc 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -114,6 +114,9 @@ "Pin Tab": "Tab anheften", "Modules": "Module", "API Params": "API-Parameter", + "Override provider temperature": "Anbieter-Temperatur überschreiben", + "The temperature parameter is not sent. The provider or model default is used.": "Der Temperaturparameter wird nicht gesendet. Es wird der Standardwert des Anbieters oder Modells verwendet.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "Das aktuelle Modell akzeptiert keine benutzerdefinierte Temperatur. Der Parameter wird nicht gesendet.", "API Url": "API-URL", "Provider": "Anbieter", "Others": "Andere", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 10f8caeeb..e558fc898 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -121,6 +121,9 @@ "Pin Tab": "Pin Tab", "Modules": "Modules", "API Params": "API Params", + "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.", "API Url": "API Url", "Provider": "Provider", "Others": "Others", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index 14953fb28..c9f5b5a11 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -112,6 +112,9 @@ "Pin Tab": "Fijar pestaña", "Modules": "Módulos", "API Params": "Parámetros de la API", + "Override provider temperature": "Sobrescribir la temperatura del proveedor", + "The temperature parameter is not sent. The provider or model default is used.": "El parámetro de temperatura no se envía. Se utiliza el valor predeterminado del proveedor o del modelo.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "El modelo actual no acepta una temperatura personalizada. El parámetro no se enviará.", "API Url": "URL de la API", "Provider": "Proveedor", "Others": "Otros", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index 3b0546ca1..1fa86e8a3 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -112,6 +112,9 @@ "Pin Tab": "Épingler l'onglet", "Modules": "Modules", "API Params": "Paramètres de l'API", + "Override provider temperature": "Remplacer la température du fournisseur", + "The temperature parameter is not sent. The provider or model default is used.": "Le paramètre de température n’est pas envoyé. La valeur par défaut du fournisseur ou du modèle est utilisée.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "Le modèle actuel n’accepte pas de température personnalisée. Le paramètre ne sera pas envoyé.", "API Url": "URL de l'API", "Provider": "Fournisseur", "Others": "Autres", diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index f38e645fc..5267098d2 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -112,6 +112,9 @@ "Pin Tab": "Sematkan Tab", "Modules": "Modul", "API Params": "Parameter API", + "Override provider temperature": "Timpa suhu penyedia", + "The temperature parameter is not sent. The provider or model default is used.": "Parameter suhu tidak dikirim. Nilai bawaan penyedia atau model akan digunakan.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "Model saat ini tidak menerima suhu khusus. Parameter tidak akan dikirim.", "API Url": "URL API", "Provider": "Penyedia", "Others": "Lainnya", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 8149c7b5c..4a592ff01 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -112,6 +112,9 @@ "Pin Tab": "Fissa scheda", "Modules": "Moduli", "API Params": "Parametri API", + "Override provider temperature": "Sovrascrivi la temperatura del provider", + "The temperature parameter is not sent. The provider or model default is used.": "Il parametro della temperatura non viene inviato. Viene usato il valore predefinito del provider o del modello.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "Il modello attuale non accetta una temperatura personalizzata. Il parametro non verrà inviato.", "API Url": "URL API", "Provider": "Provider", "Others": "Altri", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index b95cf7eab..91c960949 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -112,6 +112,9 @@ "Pin Tab": "タブをピン留め", "Modules": "モジュール", "API Params": "APIパラメータ", + "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.": "現在のモデルはカスタム温度を受け付けません。このパラメーターは送信されません。", "API Url": "API URL", "Provider": "プロバイダー", "Others": "その他", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 7ab91041d..252ed15f8 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -112,6 +112,9 @@ "Pin Tab": "탭 고정", "Modules": "모듈", "API Params": "API 매개변수", + "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.": "현재 모델은 사용자 지정 온도를 허용하지 않습니다. 이 매개변수는 전송되지 않습니다.", "API Url": "API 주소", "Provider": "공급자", "Others": "기타", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index 36045eb86..b3379c159 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -112,6 +112,9 @@ "Pin Tab": "Fixar Tab", "Modules": "Módulos", "API Params": "Parâmetros da API", + "Override provider temperature": "Substituir a temperatura do provedor", + "The temperature parameter is not sent. The provider or model default is used.": "O parâmetro de temperatura não é enviado. O valor padrão do provedor ou do modelo é utilizado.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "O modelo atual não aceita uma temperatura personalizada. O parâmetro não será enviado.", "API Url": "URL da API", "Provider": "Provedor", "Others": "Outros", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index aedd70ce9..d857f3f62 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -120,6 +120,9 @@ "Pin Tab": "Закрепить вкладку", "Modules": "Модули", "API Params": "Параметры API", + "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.": "Текущая модель не принимает пользовательскую температуру. Параметр не будет отправлен.", "API Url": "URL API", "Provider": "Провайдер", "Others": "Другие", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index e2a6bb556..1b2391cf4 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -112,6 +112,9 @@ "Pin Tab": "Sekmeyi Sabitle", "Modules": "Modüller", "API Params": "API Parametreleri", + "Override provider temperature": "Sağlayıcı sıcaklığını geçersiz kıl", + "The temperature parameter is not sent. The provider or model default is used.": "Sıcaklık parametresi gönderilmez. Sağlayıcının veya modelin varsayılan değeri kullanılır.", + "The current model does not accept a custom temperature. The parameter will not be sent.": "Geçerli model özel bir sıcaklık kabul etmiyor. Parametre gönderilmeyecek.", "API Url": "API Url'si", "Provider": "Sağlayıcı", "Others": "Diğerleri", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index d15d0c393..cfad6d2d1 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -115,6 +115,9 @@ "Pin Tab": "固定页面", "Modules": "模块", "API Params": "API参数", + "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.": "当前模型不接受自定义温度参数,因此不会发送该参数。", "API Url": "API地址", "Provider": "提供商", "Others": "其他", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index bc200d682..3dd20a229 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -115,6 +115,9 @@ "Pin Tab": "固定頁面", "Modules": "模組", "API Params": "API 參數", + "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.": "目前的模型不接受自訂溫度參數,因此不會傳送這個參數。", "API Url": "API 網址", "Provider": "供應商", "Others": "其他", diff --git a/src/config/index.mjs b/src/config/index.mjs index 3d37b3452..0e081e4dc 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -800,6 +800,7 @@ export const defaultConfig = { maxResponseTokenLength: 2000, maxConversationContextLength: 9, + temperatureOverrideEnabled: false, temperature: 1, customChatGptWebApiUrl: 'https://chatgpt.com', customChatGptWebApiPath: '/backend-api/conversation', diff --git a/src/popup/sections/AdvancedPart.jsx b/src/popup/sections/AdvancedPart.jsx index 74989dc1f..017b9191f 100644 --- a/src/popup/sections/AdvancedPart.jsx +++ b/src/popup/sections/AdvancedPart.jsx @@ -1,5 +1,8 @@ import { useTranslation } from 'react-i18next' 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 PropTypes from 'prop-types' import { Tab, TabList, TabPanel, Tabs } from 'react-tabs' import Browser from 'webextension-polyfill' @@ -11,6 +14,14 @@ ApiParams.propTypes = { function ApiParams({ config, updateConfig }) { const { t } = useTranslation() + const temperatureOverrideEnabled = config.temperatureOverrideEnabled === true + // Azure deployment names are opaque aliases, not canonical model identifiers. + const selectedModel = isUsingAzureOpenAiApiModel(config) + ? null + : config.modelName === 'customModel' && !config.apiMode + ? config.customModelName + : getModelValue(config) + const temperatureOverrideAvailable = canApplyTemperatureOverride(selectedModel) return ( <> @@ -43,19 +54,41 @@ function ApiParams({ config, updateConfig }) { /> + {!temperatureOverrideEnabled ? ( + + {t('The temperature parameter is not sent. The provider or model default is used.')} + + ) : !temperatureOverrideAvailable ? ( + + {t( + 'The current model does not accept a custom temperature. The parameter will not be sent.', + )} + + ) : ( + + )} ) } diff --git a/src/services/apis/azure-openai-api.mjs b/src/services/apis/azure-openai-api.mjs index a8e8eaf80..f5e1b4b6d 100644 --- a/src/services/apis/azure-openai-api.mjs +++ b/src/services/apis/azure-openai-api.mjs @@ -4,6 +4,7 @@ import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' 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' /** * @param {Runtime.Port} port @@ -13,8 +14,8 @@ import { getModelValue } from '../../utils/model-name-convert.mjs' export async function generateAnswersWithAzureOpenaiApi(port, question, session) { const { controller, messageListener, disconnectListener } = setAbortController(port) const config = await getUserConfig() - let model = getModelValue(session) - if (!model) model = config.azureDeploymentName + let deploymentName = getModelValue(session) + if (!deploymentName) deploymentName = config.azureDeploymentName const prompt = getConversationPairs( session.conversationRecords.slice(-config.maxConversationContextLength), @@ -27,7 +28,7 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session) `${config.azureEndpoint.replace( /\/$/, '', - )}/openai/deployments/${model}/chat/completions?api-version=2024-02-01`, + )}/openai/deployments/${deploymentName}/chat/completions?api-version=2024-02-01`, { method: 'POST', signal: controller.signal, @@ -39,7 +40,8 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session) messages: prompt, stream: true, max_tokens: config.maxResponseTokenLength, - temperature: config.temperature, + // Azure deployment names are opaque aliases, not canonical model identifiers. + ...getTemperatureParams(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 c9b4f9caf..d589fc894 100644 --- a/src/services/apis/claude-api.mjs +++ b/src/services/apis/claude-api.mjs @@ -4,15 +4,7 @@ import { FETCH_RESPONSE_STREAM_FAILED, fetchSSE } from '../../utils/fetch-sse.mj import { isEmpty } from 'lodash-es' import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' import { getModelValue } from '../../utils/model-name-convert.mjs' - -function shouldOmitTemperature(model) { - return ( - model === 'claude-opus-4-7' || - model === 'claude-opus-4-8' || - model === 'claude-sonnet-5' || - model === 'claude-opus-5' - ) -} +import { getTemperatureParams } from './temperature-params.mjs' function shouldDisableDefaultThinking(model) { return model === 'claude-sonnet-5' @@ -40,13 +32,11 @@ export async function generateAnswersWithClaudeApi(port, question, session) { messages: prompt, stream: true, max_tokens: config.maxResponseTokenLength, + ...getTemperatureParams(config, model), } if (shouldDisableDefaultThinking(model)) { body.thinking = { type: 'disabled' } } - if (!shouldOmitTemperature(model)) { - body.temperature = config.temperature - } let answer = '' let stopReason = '' diff --git a/src/services/apis/openai-api.mjs b/src/services/apis/openai-api.mjs index 9023db2ba..2a3f04b4c 100644 --- a/src/services/apis/openai-api.mjs +++ b/src/services/apis/openai-api.mjs @@ -33,6 +33,7 @@ function resolveModelName(session, config) { const OPENAI_COMPATIBLE_RUNTIME_CONFIG_KEYS = [ 'maxConversationContextLength', 'maxResponseTokenLength', + 'temperatureOverrideEnabled', 'temperature', ] diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index f65142667..02dfe4a81 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -3,6 +3,7 @@ import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' 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' function buildHeaders(apiKey, extraHeaders = {}) { const headers = { @@ -73,6 +74,8 @@ export async function generateAnswersWithOpenAICompatible({ ? session.conversationRecords : [] session.conversationRecords = conversationRecords + const safeExtraBody = { ...extraBody } + delete safeExtraBody.temperature if (endpointType === 'completion') { const prompt = (await getCompletionPromptBase()) + @@ -83,9 +86,9 @@ export async function generateAnswersWithOpenAICompatible({ model, stream: true, max_tokens: config.maxResponseTokenLength, - temperature: config.temperature, + ...getTemperatureParams(config, model), stop: '\nHuman', - ...extraBody, + ...safeExtraBody, } } else { const messages = getConversationPairs( @@ -100,14 +103,13 @@ export async function generateAnswersWithOpenAICompatible({ ) const conflictingTokenParamKey = 'max_completion_tokens' in tokenParams ? 'max_tokens' : 'max_completion_tokens' - const safeExtraBody = { ...extraBody } delete safeExtraBody[conflictingTokenParamKey] requestBody = { messages, model, stream: true, ...tokenParams, - temperature: config.temperature, + ...getTemperatureParams(config, model), ...safeExtraBody, } } diff --git a/src/services/apis/temperature-params.mjs b/src/services/apis/temperature-params.mjs new file mode 100644 index 000000000..cb065c115 --- /dev/null +++ b/src/services/apis/temperature-params.mjs @@ -0,0 +1,55 @@ +const MODELS_WITHOUT_CUSTOM_TEMPERATURE = new Set([ + 'claude-opus-4-7', + 'claude-opus-4-8', + 'claude-sonnet-5', + 'claude-opus-5', +]) + +function normalizeModelId(model) { + return String(model || '') + .trim() + .toLowerCase() + .replace(/^(?:anthropic|google)\//, '') + .replace(/\./g, '-') +} + +function matchesModelFamily(model, baseModel) { + return ( + model === baseModel || model.startsWith(`${baseModel}-`) || model.startsWith(`${baseModel}:`) + ) +} + +function isKnownModelWithoutCustomTemperature(model) { + return [...MODELS_WITHOUT_CUSTOM_TEMPERATURE].some((baseModel) => + matchesModelFamily(model, baseModel), + ) +} + +function isGeminiWithoutCustomTemperature(model) { + const match = /^gemini-(\d+)(?:-(\d+))?(?=$|[-:])/.exec(model) + if (!match) return false + + const major = Number(match[1]) + const minor = Number(match[2] || 0) + if (major > 3) return true + if (major !== 3) return false + + const isGemini35FlashLite = + minor === 5 && matchesModelFamily(model, 'gemini-3-5-flash-lite') + return minor >= 6 || isGemini35FlashLite +} + +export function canApplyTemperatureOverride(model) { + const normalizedModel = normalizeModelId(model) + return ( + !isKnownModelWithoutCustomTemperature(normalizedModel) && + !isGeminiWithoutCustomTemperature(normalizedModel) + ) +} + +export function getTemperatureParams(config, model) { + if (config?.temperatureOverrideEnabled !== true) return {} + if (!Number.isFinite(config?.temperature)) return {} + if (!canApplyTemperatureOverride(model)) return {} + return { temperature: config.temperature } +} diff --git a/tests/unit/config/user-config.test.mjs b/tests/unit/config/user-config.test.mjs index 4e9709152..14dbe1582 100644 --- a/tests/unit/config/user-config.test.mjs +++ b/tests/unit/config/user-config.test.mjs @@ -263,3 +263,24 @@ test('clearOldAccessToken keeps token when exactly 30 days old', async (t) => { assert.equal(storage.accessToken, 'boundary-token') assert.equal(storage.tokenSavedOn, savedOn) }) + +test('getUserConfig keeps a stored temperature but leaves overrides disabled by default', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ temperature: 0.7 }) + + const config = await getUserConfig() + + assert.equal(config.temperature, 0.7) + assert.equal(config.temperatureOverrideEnabled, false) +}) + +test('getUserConfig preserves an explicitly enabled temperature override', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + temperatureOverrideEnabled: true, + temperature: 0.7, + }) + + const config = await getUserConfig() + + assert.equal(config.temperature, 0.7) + assert.equal(config.temperatureOverrideEnabled, true) +}) diff --git a/tests/unit/services/apis/azure-openai-api.test.mjs b/tests/unit/services/apis/azure-openai-api.test.mjs index 66b5aabe3..1576fc8a0 100644 --- a/tests/unit/services/apis/azure-openai-api.test.mjs +++ b/tests/unit/services/apis/azure-openai-api.test.mjs @@ -125,6 +125,7 @@ test('azure-openai: sends max_tokens and temperature in body', async (t) => { azureDeploymentName: 'gpt-4o', maxConversationContextLength: 3, maxResponseTokenLength: 1024, + temperatureOverrideEnabled: true, temperature: 0.9, }) @@ -151,6 +152,36 @@ test('azure-openai: sends max_tokens and temperature in body', async (t) => { assert.equal(body.stream, true) }) +test('azure-openai: uses the provider temperature default', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + azureEndpoint: 'https://myinstance.openai.azure.com', + azureApiKey: 'az-key', + azureDeploymentName: 'gpt-4o', + maxConversationContextLength: 3, + maxResponseTokenLength: 1024, + }) + + const session = { + modelName: 'azureOpenAi', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithAzureOpenaiApi(port, 'Q', session) + + const body = JSON.parse(capturedInit.body) + assert.equal(Object.hasOwn(body, 'temperature'), false) +}) + test('azure-openai: aggregates SSE deltas and pushes record on finish', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ diff --git a/tests/unit/services/apis/azure-openai-temperature.test.mjs b/tests/unit/services/apis/azure-openai-temperature.test.mjs new file mode 100644 index 000000000..00bf3e5ec --- /dev/null +++ b/tests/unit/services/apis/azure-openai-temperature.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { generateAnswersWithAzureOpenaiApi } from '../../../../src/services/apis/azure-openai-api.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +test('Azure temperature override does not treat deployment aliases as canonical model IDs', async (t) => { + t.mock.method(console, 'debug', () => {}) + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + azureEndpoint: 'https://myinstance.openai.azure.com', + azureApiKey: 'az-key', + azureDeploymentName: 'gemini-4-flash', + maxConversationContextLength: 3, + maxResponseTokenLength: 128, + temperatureOverrideEnabled: true, + temperature: 0.9, + }) + + const session = { + modelName: 'azureOpenAi', + conversationRecords: [], + isRetry: false, + } + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithAzureOpenaiApi(createFakePort(), 'Q', session) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.temperature, 0.9) +}) diff --git a/tests/unit/services/apis/claude-api.test.mjs b/tests/unit/services/apis/claude-api.test.mjs index 322fe19c4..9e51ccb0e 100644 --- a/tests/unit/services/apis/claude-api.test.mjs +++ b/tests/unit/services/apis/claude-api.test.mjs @@ -76,6 +76,7 @@ test('claude-api: sends model, max_tokens, temperature in body', async (t) => { claudeApiKey: 'sk-ant-test', maxConversationContextLength: 3, maxResponseTokenLength: 1024, + temperatureOverrideEnabled: true, temperature: 0.9, }) @@ -105,6 +106,37 @@ test('claude-api: sends model, max_tokens, temperature in body', async (t) => { assert.equal(body.stream, true) }) +test('claude-api: uses the provider temperature default', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + customClaudeApiUrl: 'https://api.anthropic.com', + claudeApiKey: 'sk-ant-test', + maxConversationContextLength: 3, + maxResponseTokenLength: 1024, + }) + + const session = { + modelName: 'claudeSonnet46Api', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"OK"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ]) + }) + + await generateAnswersWithClaudeApi(port, 'Q', session) + + const body = JSON.parse(capturedInit.body) + assert.equal(Object.hasOwn(body, 'temperature'), false) +}) + test('claude-api: keeps temperature for Opus 4.6', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ @@ -112,6 +144,7 @@ test('claude-api: keeps temperature for Opus 4.6', async (t) => { claudeApiKey: 'sk-ant-test', maxConversationContextLength: 3, maxResponseTokenLength: 1024, + temperatureOverrideEnabled: true, temperature: 0.9, }) @@ -156,6 +189,7 @@ test('claude-api: omits temperature for models that reject custom sampling', asy claudeApiKey: 'sk-ant-test', maxConversationContextLength: 3, maxResponseTokenLength: 1024, + temperatureOverrideEnabled: true, temperature: 0.9, }) diff --git a/tests/unit/services/apis/custom-api.test.mjs b/tests/unit/services/apis/custom-api.test.mjs index c5151ed0b..57a6f7fde 100644 --- a/tests/unit/services/apis/custom-api.test.mjs +++ b/tests/unit/services/apis/custom-api.test.mjs @@ -18,6 +18,7 @@ test('aggregates delta.content SSE chunks and finishes on finish_reason', async setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 256, + temperatureOverrideEnabled: true, temperature: 0.5, }) diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs index c11a3ebb4..26559c480 100644 --- a/tests/unit/services/apis/openai-api-compat.test.mjs +++ b/tests/unit/services/apis/openai-api-compat.test.mjs @@ -69,6 +69,7 @@ test('generateAnswersWithOpenAiApiCompat sends expected request and aggregates S setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 256, + temperatureOverrideEnabled: true, temperature: 0.25, }) @@ -128,6 +129,39 @@ test('generateAnswersWithOpenAiApiCompat sends expected request and aggregates S assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hello' }) }) +test('generateAnswersWithOpenAiApiCompat uses the provider temperature default', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + }) + + const session = { + modelName: 'chatgptApi4oMini', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithOpenAiApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + ) + + const body = JSON.parse(capturedInit.body) + assert.equal(Object.hasOwn(body, 'temperature'), false) +}) + test('generateAnswersWithOpenAiApiCompat emits fallback done message when stream ends without finish reason', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ @@ -818,13 +852,16 @@ test('generateAnswersWithOpenAICompatibleApi uses caller config snapshot for req setStorage({ maxConversationContextLength: 1, maxResponseTokenLength: 111, + temperatureOverrideEnabled: true, temperature: 0.1, }) const config = { maxConversationContextLength: 2, maxResponseTokenLength: 777, + temperatureOverrideEnabled: true, temperature: 0.7, + customOpenAIProviders: [ { id: 'snapshot-provider', @@ -2184,6 +2221,7 @@ test('generateAnswersWithGptCompletionApi builds completion prompt and appends a customOpenAiApiUrl: 'https://api.example.com', maxConversationContextLength: 5, maxResponseTokenLength: 300, + temperatureOverrideEnabled: true, temperature: 0.5, }) diff --git a/tests/unit/services/apis/openai-compatible-temperature.test.mjs b/tests/unit/services/apis/openai-compatible-temperature.test.mjs new file mode 100644 index 000000000..d5d73c87f --- /dev/null +++ b/tests/unit/services/apis/openai-compatible-temperature.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +function createSession() { + return { + conversationRecords: [], + isRetry: false, + } +} + +function createConfig(overrides = {}) { + return { + maxConversationContextLength: 3, + maxResponseTokenLength: 128, + temperatureOverrideEnabled: false, + temperature: 0.7, + ...overrides, + } +} + +test('OpenAI-compatible extra body cannot enable temperature while override is disabled', async (t) => { + t.mock.method(console, 'debug', () => {}) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatible({ + port: createFakePort(), + question: 'Q', + session: createSession(), + endpointType: 'chat', + requestUrl: 'https://api.example.com/v1/chat/completions', + model: 'gpt-4.1', + apiKey: 'test-key', + config: createConfig(), + extraBody: { temperature: 0.2 }, + }) + + const body = JSON.parse(capturedInit.body) + assert.equal(Object.hasOwn(body, 'temperature'), false) +}) + +test('OpenAI-compatible configured override takes precedence over extra-body temperature', async (t) => { + t.mock.method(console, 'debug', () => {}) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatible({ + port: createFakePort(), + question: 'Q', + session: createSession(), + endpointType: 'chat', + requestUrl: 'https://api.example.com/v1/chat/completions', + model: 'gpt-4.1', + apiKey: 'test-key', + config: createConfig({ temperatureOverrideEnabled: true }), + extraBody: { temperature: 0.2 }, + }) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.temperature, 0.7) +}) + +test('OpenAI-compatible completion extra body cannot enable temperature while override is disabled', async (t) => { + t.mock.method(console, 'debug', () => {}) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse(['data: {"choices":[{"text":"OK","finish_reason":"stop"}]}\n\n']) + }) + + await generateAnswersWithOpenAICompatible({ + port: createFakePort(), + question: 'Q', + session: createSession(), + endpointType: 'completion', + requestUrl: 'https://api.example.com/v1/completions', + model: 'gpt-3.5-turbo-instruct', + apiKey: 'test-key', + config: createConfig(), + extraBody: { temperature: 0.2 }, + }) + + const body = JSON.parse(capturedInit.body) + assert.equal(Object.hasOwn(body, 'temperature'), false) +}) + +test('OpenAI-compatible completion override takes precedence over extra-body temperature', async (t) => { + t.mock.method(console, 'debug', () => {}) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse(['data: {"choices":[{"text":"OK","finish_reason":"stop"}]}\n\n']) + }) + + await generateAnswersWithOpenAICompatible({ + port: createFakePort(), + question: 'Q', + session: createSession(), + endpointType: 'completion', + requestUrl: 'https://api.example.com/v1/completions', + model: 'gpt-3.5-turbo-instruct', + apiKey: 'test-key', + config: createConfig({ temperatureOverrideEnabled: true }), + extraBody: { temperature: 0.2 }, + }) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.temperature, 0.7) +}) diff --git a/tests/unit/services/apis/temperature-params.test.mjs b/tests/unit/services/apis/temperature-params.test.mjs new file mode 100644 index 000000000..9358611e8 --- /dev/null +++ b/tests/unit/services/apis/temperature-params.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + canApplyTemperatureOverride, + getTemperatureParams, +} from '../../../../src/services/apis/temperature-params.mjs' + +test('temperature params require an explicit finite override', () => { + assert.deepEqual(getTemperatureParams({}, 'gpt-4.1'), {}) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: false, temperature: 0.7 }, 'gpt-4.1'), + {}, + ) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: Number.NaN }, 'gpt-4.1'), + {}, + ) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: 0 }, 'gpt-4.1'), + { temperature: 0 }, + ) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: 0.7 }, 'gpt-4.1'), + { temperature: 0.7 }, + ) +}) + +test('temperature overrides omit known Anthropic models across provider ID formats', () => { + for (const model of [ + 'claude-opus-4-7', + 'claude-opus-4-8', + 'anthropic/claude-opus-4.8', + 'anthropic/claude-opus-4-8', + 'anthropic/claude-opus-4.8:free', + 'claude-opus-4-8-20260801', + 'claude-sonnet-5', + 'claude-opus-5', + ]) { + assert.equal(canApplyTemperatureOverride(model), false, model) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: 0.7 }, model), + {}, + model, + ) + } + assert.equal(canApplyTemperatureOverride('claude-opus-4-6'), true) +}) + +test('temperature overrides omit Gemini models with deprecated sampling parameters', () => { + for (const model of [ + 'gemini-3.5-flash-lite', + 'google/gemini-3.5-flash-lite', + 'google/gemini-3-5-flash-lite', + 'google/gemini-3.5-flash-lite:free', + 'gemini-3-5-flash-lite-preview', + 'gemini-3.6-flash', + 'google/gemini-3.6-flash', + 'google/gemini-3-6-flash', + 'gemini-3.7-flash', + 'gemini-4-flash', + 'gemini-4:free', + ]) { + assert.equal(canApplyTemperatureOverride(model), false, model) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: 0.7 }, model), + {}, + model, + ) + } +}) + +test('Gemini version checks do not classify arbitrary numeric model names', () => { + for (const model of ['gemini-4o', 'gemini-35b', 'gemini-3-6flash']) { + assert.equal(canApplyTemperatureOverride(model), true, model) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: 0.7 }, model), + { temperature: 0.7 }, + model, + ) + } +}) + +test('temperature overrides remain available for earlier Gemini models', () => { + for (const model of [ + 'gemini-2.5-flash', + 'gemini-3-flash-preview', + 'gemini-3.1-pro-preview', + 'gemini-3.5-flash', + 'google/gemini-3.5-flash', + 'google/gemini-3-5-flash', + ]) { + assert.equal(canApplyTemperatureOverride(model), true, model) + assert.deepEqual( + getTemperatureParams({ temperatureOverrideEnabled: true, temperature: 0.7 }, model), + { temperature: 0.7 }, + model, + ) + } +})