From be0e564031aad24c8b92ec3be5daa3ccd7471a46 Mon Sep 17 00:00:00 2001 From: Yi Zhan Date: Thu, 15 Oct 2026 09:43:49 +0800 Subject: [PATCH] fix: omit temperature for gpt-6 under openai-responses OpenAI Responses rejects temperature for gpt-6-* the same way it does for gpt-5 and the o-series. Share one omitsTemperature() helper so agent chat, title, and memory synthesis all skip it. Signed-off-by: Yi Zhan --- .../services/agentMemorySynthesisService.ts | 5 +- electron/services/agentModelLedService.ts | 5 +- electron/services/agentSamplingOptions.ts | 12 ++ electron/services/agentTitleService.ts | 6 +- scripts/check-agent-sampling-options.cjs | 138 ++++++++++++++++++ 5 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 electron/services/agentSamplingOptions.ts create mode 100644 scripts/check-agent-sampling-options.cjs diff --git a/electron/services/agentMemorySynthesisService.ts b/electron/services/agentMemorySynthesisService.ts index 918ca3d..6a64cbd 100644 --- a/electron/services/agentMemorySynthesisService.ts +++ b/electron/services/agentMemorySynthesisService.ts @@ -10,6 +10,7 @@ import { } from './agentMemoryService' import { createAgentTextRedactionSession } from './agentTextRedaction' import { filterAutomaticMemoryEntries, filterAutomaticMemoryPatch, type AgentMemoryQualityIssue } from './agentMemoryQuality' +import { omitsTemperature } from './agentSamplingOptions' import type { AgentFeedbackMemoryEvidence, AgentFeedbackMemorySample, AgentFeedbackReason } from './agentFeedbackStore' import type { AgentModelConfig } from './agentService' @@ -66,9 +67,7 @@ function latestUserText(messages: unknown[]): string { } function memorySamplingOptions(config: AgentModelConfig): { temperature?: number } { - const modelId = String(config.model || '').trim().toLocaleLowerCase() - const reasoningModel = config.protocol === 'openai-responses' && /^(?:gpt-5|o[1-4](?:-|$))/.test(modelId) - return reasoningModel || config.protocol === 'anthropic' ? {} : { temperature: 0.1 } + return omitsTemperature(config) ? {} : { temperature: 0.1 } } function modelIsConfigured(config?: AgentModelConfig | null): config is AgentModelConfig { diff --git a/electron/services/agentModelLedService.ts b/electron/services/agentModelLedService.ts index 93f029c..08c9e26 100644 --- a/electron/services/agentModelLedService.ts +++ b/electron/services/agentModelLedService.ts @@ -59,6 +59,7 @@ import { } from './agentMemoryRuntimeGuidance' import { createAgentModel } from './agentModelAdapter' import { truncateStringToSchemaMax } from './agentModelSafety' +import { omitsTemperature } from './agentSamplingOptions' import { AgentRunStore, assessAgentRunResume, @@ -851,9 +852,7 @@ function providerOptions(config: AgentModelConfig, promptCacheKey: string): Prov } function samplingOptions(config: AgentModelConfig, temperature: number): { temperature?: number } { - const model = text(config.model).toLocaleLowerCase() - if (config.protocol === 'anthropic' || (config.protocol === 'openai-responses' && /^(?:gpt-5|o[1-4](?:-|$))/.test(model))) return {} - return { temperature } + return omitsTemperature(config) ? {} : { temperature } } function compactToolOutputForStorage(toolName: string, output: unknown): unknown { diff --git a/electron/services/agentSamplingOptions.ts b/electron/services/agentSamplingOptions.ts new file mode 100644 index 0000000..75fdac2 --- /dev/null +++ b/electron/services/agentSamplingOptions.ts @@ -0,0 +1,12 @@ +export type AgentSamplingConfig = { + protocol?: string + model?: string +} + +const OPENAI_RESPONSES_REASONING_MODEL = /^(?:gpt-(?:5|6)|o[1-4](?:-|$))/ + +export function omitsTemperature(config: AgentSamplingConfig): boolean { + if (config.protocol === 'anthropic') return true + const modelId = String(config.model || '').trim().toLocaleLowerCase() + return config.protocol === 'openai-responses' && OPENAI_RESPONSES_REASONING_MODEL.test(modelId) +} diff --git a/electron/services/agentTitleService.ts b/electron/services/agentTitleService.ts index 00d3ce0..e6da8a5 100644 --- a/electron/services/agentTitleService.ts +++ b/electron/services/agentTitleService.ts @@ -1,5 +1,6 @@ import { streamText } from 'ai' import { createAgentModel } from './agentModelAdapter' +import { omitsTemperature } from './agentSamplingOptions' import { createAgentTextRedactionSession } from './agentTextRedaction' import type { AgentModelConfig } from './agentService' @@ -19,11 +20,8 @@ function fallbackTitle(value: string): string { function titleSamplingOptions(config: AgentModelConfig): { temperature?: number } { const modelId = String(config.model || '').trim().toLocaleLowerCase() - const openAiReasoningModel = config.protocol === 'openai-responses' - && /^(?:gpt-5|o[1-4](?:-|$))/.test(modelId) - const nativeAnthropicModel = config.protocol === 'anthropic' const modernClaudeModel = /(?:^|\/)claude-(?:opus-4-(?:[7-9]|\d{2,})|(?:sonnet|opus|haiku|fable|mythos)-(?:[5-9]|\d{2,})(?:-|$))/.test(modelId) - return openAiReasoningModel || nativeAnthropicModel || modernClaudeModel ? {} : { temperature: 0.2 } + return omitsTemperature(config) || modernClaudeModel ? {} : { temperature: 0.2 } } function titleErrorMessage(error: unknown): string { diff --git a/scripts/check-agent-sampling-options.cjs b/scripts/check-agent-sampling-options.cjs new file mode 100644 index 0000000..2fa3e3a --- /dev/null +++ b/scripts/check-agent-sampling-options.cjs @@ -0,0 +1,138 @@ +// Exercise the production omit-temperature predicate without loading Electron or native modules. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const { test } = require('node:test') + +const root = path.join(__dirname, '..') +const helperPath = path.join(root, 'electron/services/agentSamplingOptions.ts') +const ledPath = path.join(root, 'electron/services/agentModelLedService.ts') +const titlePath = path.join(root, 'electron/services/agentTitleService.ts') +const memoryPath = path.join(root, 'electron/services/agentMemorySynthesisService.ts') + +function extractFunction(source, name) { + const header = new RegExp(`function ${name}\\([^)]*\\)(?:\\s*:\\s*\\{[^}]*\\})?\\s*\\{`) + const match = header.exec(source) + assert.ok(match, `missing function ${name}`) + const start = match.index + const brace = start + match[0].length - 1 + let depth = 0 + for (let index = brace; index < source.length; index += 1) { + const char = source[index] + if (char === '{') depth += 1 + else if (char === '}') { + depth -= 1 + if (depth === 0) return source.slice(start, index + 1) + } + } + throw new Error(`unterminated function ${name}`) +} + +function stripTypes(source) { + return source + .replace(/\bexport type [A-Za-z0-9_]+ = \{[\s\S]*?\n\}\n/, '') + .replace(/\bexport /g, '') + .replace(/: Agent(?:Sampling|Model)Config/g, '') + .replace(/: \{ protocol\?: string; model\?: string \}/g, '') + .replace(/temperature: number/g, 'temperature') + .replace(/\): boolean/g, ')') + .replace(/\): \{ temperature\?: number \}/g, ')') +} + +function loadHelper() { + const source = fs.readFileSync(helperPath, 'utf8') + const js = stripTypes(source) + const module = { exports: {} } + vm.runInNewContext(`${js}\nmodule.exports = { omitsTemperature }`, { + module, + exports: module.exports, + }) + return module.exports +} + +function loadOmitsTemperature() { + if (fs.existsSync(helperPath)) return loadHelper().omitsTemperature + + const led = extractFunction(fs.readFileSync(ledPath, 'utf8'), 'samplingOptions') + const title = extractFunction(fs.readFileSync(titlePath, 'utf8'), 'titleSamplingOptions') + const memory = extractFunction(fs.readFileSync(memoryPath, 'utf8'), 'memorySamplingOptions') + const js = ` + function text(value) { return String(value ?? '').replace(/\\u0000/g, '').trim() } + ${stripTypes(led)} + ${stripTypes(title)} + ${stripTypes(memory)} + function omitsTemperature(config) { + return !('temperature' in samplingOptions(config, 0.7)) + && !('temperature' in titleSamplingOptions(config)) + && !('temperature' in memorySamplingOptions(config)) + } + ` + const module = { exports: {} } + vm.runInNewContext(`${js}\nmodule.exports = { omitsTemperature }`, { module, exports: module.exports }) + return module.exports.omitsTemperature +} + +function loadSamplingOptions() { + const omits = loadOmitsTemperature() + return (config, temperature) => (omits(config) ? {} : { temperature }) +} + +const omitsTemperature = loadOmitsTemperature() +const samplingOptions = loadSamplingOptions() +const plain = (value) => JSON.parse(JSON.stringify(value)) + +function responses(model) { + return { protocol: 'openai-responses', model } +} + +test('openai-responses gpt-6-luna omits temperature', () => { + assert.equal(omitsTemperature(responses('gpt-6-luna')), true) + assert.deepEqual(plain(samplingOptions(responses('gpt-6-luna'), 0.7)), {}) +}) + +test('openai-responses gpt-6-* variants omit temperature', () => { + for (const model of ['gpt-6', 'gpt-6-something', 'GPT-6-Luna']) { + assert.equal(omitsTemperature(responses(model)), true, model) + assert.deepEqual(plain(samplingOptions(responses(model), 0.2)), {}) + } +}) + +test('openai-responses gpt-5 and o-series still omit temperature', () => { + for (const model of ['gpt-5', 'gpt-5.5', 'gpt-5.4-mini', 'o1', 'o1-preview', 'o3', 'o4-mini']) { + assert.equal(omitsTemperature(responses(model)), true, model) + assert.deepEqual(plain(samplingOptions(responses(model), 0.7)), {}) + } +}) + +test('non-reasoning openai-responses still includes temperature', () => { + for (const model of ['gpt-4o', 'gpt-4.1', 'chatgpt-4o-latest']) { + assert.equal(omitsTemperature(responses(model)), false, model) + assert.deepEqual(plain(samplingOptions(responses(model), 0.7)), { temperature: 0.7 }) + } +}) + +test('openai-compatible gpt-6-luna still includes temperature', () => { + const config = { protocol: 'openai-compatible', model: 'gpt-6-luna' } + assert.equal(omitsTemperature(config), false) + assert.deepEqual(plain(samplingOptions(config, 0.3)), { temperature: 0.3 }) +}) + +test('anthropic path still omits temperature for any model', () => { + for (const model of ['claude-sonnet-4', 'gpt-4o', 'gpt-6-luna']) { + const config = { protocol: 'anthropic', model } + assert.equal(omitsTemperature(config), true, model) + assert.deepEqual(plain(samplingOptions(config, 0.7)), {}) + } +}) + +test('agent chat, title, and memory sampling share omitsTemperature', () => { + if (!fs.existsSync(helperPath)) { + assert.fail('shared helper electron/services/agentSamplingOptions.ts is required') + } + for (const file of [ledPath, titlePath, memoryPath]) { + const source = fs.readFileSync(file, 'utf8') + assert.match(source, /from '\.\/agentSamplingOptions'/) + assert.match(source, /omitsTemperature\s*\(/) + } +})