From 558b410b8f525781634213225cb5d8804ceaebea Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:58:16 +0000 Subject: [PATCH 1/2] Stop sending temperature to OpenRouter Claude Opus 5 requests failed with "No endpoints found that can handle the requested parameters". The catalog marked Opus 5 as accepting temperature, but none of its 11 OpenRouter endpoints do, and require_parameters: true drops every endpoint that lacks a sent parameter. Temperature is now gone from every request: the catalog flag, the ai and ai-stream payloads, the research sub-calls, the JSON repair retry, and the client's saved feature settings. Saved settings that still carry a temperature get rewritten once through the existing canonical check. Older clients that still send temperature are ignored by the server. The routing test now fails if any catalog model's request carries temperature. Co-Authored-By: Claude Opus 5.5 --- e2e/animation-polish.spec.ts | 2 +- e2e/authed-pages.spec.ts | 2 +- packages/core/src/ai/providers/types.ts | 1 - src/lib/ai/client.ts | 3 +-- src/lib/ai/streaming.ts | 1 - src/lib/stores/settings-store.ts | 12 +++------ src/test/ai/streaming.test.ts | 26 +++++++++---------- src/test/debate/debate-context.test.tsx | 2 +- src/test/pages/CrossfireVoice.test.tsx | 2 +- src/test/pages/SparringVoice.test.tsx | 2 +- supabase/functions/_shared/model-catalog.ts | 21 +++++++-------- supabase/functions/_shared/research.ts | 3 --- supabase/functions/ai-stream/index.ts | 2 -- supabase/functions/ai/features/ask.ts | 4 +-- supabase/functions/ai/features/attack.ts | 4 +-- supabase/functions/ai/features/brainstorm.ts | 4 +-- supabase/functions/ai/features/crossfire.ts | 3 +-- supabase/functions/ai/features/factoids.ts | 4 +-- supabase/functions/ai/features/generate.ts | 4 +-- supabase/functions/ai/features/sparring.ts | 4 +-- supabase/functions/ai/features/types.ts | 1 - .../functions/ai/handler-research.test.ts | 4 ++- supabase/functions/ai/index.ts | 7 ++--- supabase/functions/ai/provider.ts | 8 +----- 24 files changed, 51 insertions(+), 75 deletions(-) diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 41235dcad..63abc5f7d 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test'; import { seedCurrentDebate, stubSupabaseSession } from './helpers/auth'; import { stubAIResponses } from './fixtures/ai-responses'; -const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }; +const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash' }; const STATE_TIMEOUT_MS = 15_000; async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | 'reduce') { diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index d4d7f3741..cd85d9540 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -100,7 +100,7 @@ test('visible Ask advanced controls use the non-streaming contract', async ({ pa outputLanguage: 'en', theme: 'light', featureConfigs: { - ask: { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + ask: { provider: 'openai', model: 'gpt-5.4-mini' }, }, }, version: 0, diff --git a/packages/core/src/ai/providers/types.ts b/packages/core/src/ai/providers/types.ts index 8fcf04971..93ddcd638 100644 --- a/packages/core/src/ai/providers/types.ts +++ b/packages/core/src/ai/providers/types.ts @@ -26,6 +26,5 @@ export const AVAILABLE_MODELS = MODEL_CATALOG; export interface ProviderConfig { provider: string; model: string; - temperature: number; reasoningEffort?: ReasoningEffort; } diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index e75b969ad..d955fb405 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -541,7 +541,6 @@ async function callSupabaseAI( input, provider, model: config.model, - temperature: config.temperature, reasoningEffort: config.reasoningEffort ?? 'low', sourceIds, ...(debateContext ? { debateContext } : {}), @@ -1456,7 +1455,7 @@ function sparringRequestKey( voice = false, ): string { const authScope = getActiveUserId() ?? getActiveAccessToken() ?? 'signed-out'; - return JSON.stringify([authScope, workspaceContext?.workspaceId ?? null, debateId, input, config.provider, config.model, config.temperature, config.reasoningEffort, voice]); + return JSON.stringify([authScope, workspaceContext?.workspaceId ?? null, debateId, input, config.provider, config.model, config.reasoningEffort, voice]); } /** Generate the opponent's next speech for a validated sparring turn. */ diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index eaf144713..28cfb4c99 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -210,7 +210,6 @@ export async function streamAskChat( ...(debateContext ? { debateContext } : {}), provider, model: config.model, - temperature: config.temperature, reasoningEffort: config.reasoningEffort ?? 'low', }), signal: controller.signal, diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 6c4c56610..ed9d5bb30 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -57,15 +57,14 @@ const LEGACY_MODEL_SUCCESSORS: Record = { const defaultConfig: ProviderConfig = { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL, - temperature: 0.7, reasoningEffort: 'low', }; const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ generate: { ...defaultConfig, model }, attack: { ...defaultConfig, model }, - brainstorm: { ...defaultConfig, model, temperature: 0.9 }, - factoids: { ...defaultConfig, model, temperature: 0.3 }, + brainstorm: { ...defaultConfig, model }, + factoids: { ...defaultConfig, model }, ask: { ...defaultConfig, model }, sparring: { ...defaultConfig, model }, }); @@ -91,9 +90,6 @@ function normalizeFeatureConfig( ): ProviderConfig { const candidate = asRecord(value); const model = normalizeModelId(candidate?.model, fallback.model); - const temperature = typeof candidate?.temperature === 'number' && Number.isFinite(candidate.temperature) - ? candidate.temperature - : fallback.temperature; const reasoningEffort = isReasoningEffort(candidate?.reasoningEffort) ? candidate.reasoningEffort : fallback.reasoningEffort ?? 'low'; @@ -105,7 +101,6 @@ function normalizeFeatureConfig( : !allowProModels && getModelCatalogEntry(model)?.isProOnly ? DEFAULT_MODEL : model, - temperature, reasoningEffort, }; } @@ -129,7 +124,7 @@ function featureConfigsAreCanonical(value: unknown, expected: Record { const config = asRecord(saved[feature]); if (!config) return false; @@ -137,7 +132,6 @@ function featureConfigsAreCanonical(value: unknown, expected: Record allowedKeys.has(key)) && config.provider === target.provider && config.model === target.model - && config.temperature === target.temperature && config.reasoningEffort === target.reasoningEffort; }); } diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 4a50e3053..49b34ad04 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -41,7 +41,7 @@ function mockFetchOnce(body: BodyInit | null, init: ResponseInit = { status: 200 return fetchMock; } -const streamConfig = { provider: 'openrouter', model: 'gpt-5.4-mini', temperature: 0.7 } as const; +const streamConfig = { provider: 'openrouter', model: 'gpt-5.4-mini' } as const; const streamOptions = {}; beforeEach(() => { @@ -88,38 +88,38 @@ describe('AI streaming helpers', () => { }); it('enables streaming for basic OpenRouter ask chats', () => { - expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 })).toBe(true); - expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 })).toBe(false); - expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 })).toBe(false); - expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001', temperature: 0.7 })).toBe(false); - expect(isAskStreamingSupported({ provider: 'unknown' as string, model: 'x', temperature: 0.7 })).toBe(false); + expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-5.6-luna' })).toBe(true); + expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini' })).toBe(false); + expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview' })).toBe(false); + expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' })).toBe(false); + expect(isAskStreamingSupported({ provider: 'unknown' as string, model: 'x' })).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { citationMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { webSearch: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { prosConsMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { argumentMapMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { mode: 'clarify' } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { mode: 'general' } )).toBe(true); for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) { expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, { mode } )).toBe(false); } diff --git a/src/test/debate/debate-context.test.tsx b/src/test/debate/debate-context.test.tsx index 24a8669cf..64ac090eb 100644 --- a/src/test/debate/debate-context.test.tsx +++ b/src/test/debate/debate-context.test.tsx @@ -128,7 +128,7 @@ describe('Current Debate context in requests and the Review control', () => { }); const brainstormInput = { motion: 'This House would make public transport free', side: 'pro' as const, outputMode: 'topfive' as const, language: 'en' }; - const config = { provider: 'openrouter' as const, model: 'openai/gpt-5.6-luna', temperature: 0.7 }; + const config = { provider: 'openrouter' as const, model: 'openai/gpt-5.6-luna' }; const lastBody = () => (supabaseMock.functions.invoke.mock.calls.at(-1) as unknown as [string, { body: Record }])[1].body; it('sends IDs only, and a Review choice lasts for exactly one successful request', async () => { diff --git a/src/test/pages/CrossfireVoice.test.tsx b/src/test/pages/CrossfireVoice.test.tsx index 428d91615..75c62e1a6 100644 --- a/src/test/pages/CrossfireVoice.test.tsx +++ b/src/test/pages/CrossfireVoice.test.tsx @@ -24,7 +24,7 @@ vi.mock('@/lib/stores/session-store', () => ({ vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: () => ({ - getFeatureConfig: () => ({ provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }), + getFeatureConfig: () => ({ provider: 'google', model: 'gemini-3-flash-preview' }), outputLanguage: 'es', }), })); diff --git a/src/test/pages/SparringVoice.test.tsx b/src/test/pages/SparringVoice.test.tsx index 713a099f7..8536100ee 100644 --- a/src/test/pages/SparringVoice.test.tsx +++ b/src/test/pages/SparringVoice.test.tsx @@ -37,7 +37,7 @@ vi.mock('@/lib/voice/opponent-voice', () => ({ }, })); -const CONFIG: ProviderConfig = { provider: 'mock', model: 'local', temperature: 0.4 }; +const CONFIG: ProviderConfig = { provider: 'mock', model: 'local' }; const SETTINGS = { outputLanguage: 'en' as const, getFeatureConfig: vi.fn((): ProviderConfig => CONFIG) }; vi.mock('@/lib/stores/settings-store', () => ({ diff --git a/supabase/functions/_shared/model-catalog.ts b/supabase/functions/_shared/model-catalog.ts index 6830c22b3..7cc8ae81e 100644 --- a/supabase/functions/_shared/model-catalog.ts +++ b/supabase/functions/_shared/model-catalog.ts @@ -11,21 +11,20 @@ export interface ModelCatalogEntry { readonly name: string; readonly maxTokens: number; readonly isProOnly: boolean; - readonly supportsTemperature: boolean; readonly supportedEfforts: readonly ReasoningEffort[]; } export const MODEL_CATALOG = [ - { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", maxTokens: 128_000, isProOnly: false, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", maxTokens: 65_536, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "deepseek/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash", maxTokens: 131_072, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "high"] }, - { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "anthropic/claude-opus-5", name: "Claude Opus 5", maxTokens: 128_000, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "openai/gpt-5.6-terra", name: "GPT-5.6 Terra", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "deepseek/deepseek-v4-pro-0813", name: "DeepSeek V4 Pro", maxTokens: 384_000, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "high"] }, - { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", maxTokens: 131_072, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "high"] }, - { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3", maxTokens: 943_718, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", maxTokens: 128_000, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", maxTokens: 65_536, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "deepseek/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash", maxTokens: 131_072, isProOnly: false, supportedEfforts: ["low", "high"] }, + { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "anthropic/claude-opus-5", name: "Claude Opus 5", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-5.6-terra", name: "GPT-5.6 Terra", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "deepseek/deepseek-v4-pro-0813", name: "DeepSeek V4 Pro", maxTokens: 384_000, isProOnly: true, supportedEfforts: ["low", "high"] }, + { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", maxTokens: 131_072, isProOnly: true, supportedEfforts: ["low", "high"] }, + { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3", maxTokens: 943_718, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, ] as const satisfies readonly ModelCatalogEntry[]; export type ModelId = (typeof MODEL_CATALOG)[number]["id"]; diff --git a/supabase/functions/_shared/research.ts b/supabase/functions/_shared/research.ts index 86a68b29d..9fd35b060 100644 --- a/supabase/functions/_shared/research.ts +++ b/supabase/functions/_shared/research.ts @@ -78,7 +78,6 @@ export interface ResearchDeps { model: string; systemPrompt: string; messages: Array<{ role: string; content: string }>; - temperature: number; reasoningEffort: ReasoningEffort; signal?: AbortSignal; }) => Promise<{ output: unknown }>; @@ -376,7 +375,6 @@ export async function decideAskSearch( model: deps.model, systemPrompt: ASK_SEARCH_DECISION_PROMPT, messages: [{ role: "user", content: prompt }], - temperature: 0, reasoningEffort: deps.reasoningEffort, signal: deps.signal, }); @@ -415,7 +413,6 @@ async function generateFollowUpQueries( model: deps.model, systemPrompt: QUERY_GENERATION_PROMPT, messages: [{ role: "user", content: prompt }], - temperature: 0.3, reasoningEffort: deps.reasoningEffort, signal: deps.signal, }); diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 195baebfb..34d940e7c 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -228,7 +228,6 @@ export async function handleRequest(req: Request): Promise { if (!providerKey) return jsonResponse({ error: "OpenRouter is not configured" }, 500, corsHeaders); const history = normalizeHistory(input.history); - const temperature = typeof body.temperature === "number" ? body.temperature : 0.7; const startedAt = Date.now(); const stream = new ReadableStream({ @@ -271,7 +270,6 @@ export async function handleRequest(req: Request): Promise { stream: true, stream_options: { include_usage: true }, }; - if (modelEntry.supportsTemperature) payload.temperature = temperature; const response = await fetch(`${OPENROUTER_API_URL}/v1/chat/completions`, { method: "POST", diff --git a/supabase/functions/ai/features/ask.ts b/supabase/functions/ai/features/ask.ts index 30e7ebc7d..c6f81bffd 100644 --- a/supabase/functions/ai/features/ask.ts +++ b/supabase/functions/ai/features/ask.ts @@ -22,7 +22,7 @@ const MAX_ASK_CONTEXT_LENGTH = 32000; // ASK: Optionally run web search and include URL context from chat export async function handleAsk(ctx: FeatureContext): Promise { const feature = "ask" as const; - const { userId, provider, model, temperature, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; + const { userId, provider, model, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; const history = (sanitizedInput.history as Array<{ role: string; content: string }>) || []; const message = String(sanitizedInput.message || ""); const mode = String(sanitizedInput.mode || "general"); @@ -97,7 +97,7 @@ export async function handleAsk(ctx: FeatureContext): Promise { const systemPrompt = await buildSystemPromptFor(feature, effectiveResearch); const prompt = `${basePrompt}${urlContext}${webSearchContext}`; const messages = [...history, { role: "user", content: prompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); if (output && typeof output === "object") { diff --git a/supabase/functions/ai/features/attack.ts b/supabase/functions/ai/features/attack.ts index 09accb227..bce235cfe 100644 --- a/supabase/functions/ai/features/attack.ts +++ b/supabase/functions/ai/features/attack.ts @@ -15,7 +15,7 @@ import type { FeatureContext } from "./types.ts"; // ATTACK: Use deep research for evidence-based critique export async function handleAttack(ctx: FeatureContext): Promise { const feature = "attack" as const; - const { userId, provider, model, temperature, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; + const { userId, provider, model, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; const caseText = String(sanitizedInput.caseText || ""); const detectFallacies = typeof sanitizedInput.detectFallacies === "boolean" ? sanitizedInput.detectFallacies : true; const analyzeWeighing = typeof sanitizedInput.analyzeWeighing === "boolean" ? sanitizedInput.analyzeWeighing : true; @@ -28,7 +28,7 @@ export async function handleAttack(ctx: FeatureContext): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); enforceAttackControls(output, detectFallacies, analyzeWeighing); diff --git a/supabase/functions/ai/features/brainstorm.ts b/supabase/functions/ai/features/brainstorm.ts index ddfb0652b..c889d93af 100644 --- a/supabase/functions/ai/features/brainstorm.ts +++ b/supabase/functions/ai/features/brainstorm.ts @@ -15,7 +15,7 @@ import type { FeatureContext } from "./types.ts"; // BRAINSTORM: Use research to inform argument angles export async function handleBrainstorm(ctx: FeatureContext): Promise { const feature = "brainstorm" as const; - const { userId, provider, model, temperature, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; + const { userId, provider, model, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; const motion = String(sanitizedInput.motion || sanitizedInput.topic || ""); const side = String(sanitizedInput.side || "both"); const outputMode = String(sanitizedInput.outputMode || "topfive"); @@ -29,7 +29,7 @@ export async function handleBrainstorm(ctx: FeatureContext): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); enforceBrainstormControls(output, side, outputMode); diff --git a/supabase/functions/ai/features/crossfire.ts b/supabase/functions/ai/features/crossfire.ts index 52a344182..31ee7286f 100644 --- a/supabase/functions/ai/features/crossfire.ts +++ b/supabase/functions/ai/features/crossfire.ts @@ -29,7 +29,7 @@ export interface CrossfireHandlerOptions { // apply, and the combined prompt stays inside a fixed character budget. export async function handleCrossfire(ctx: FeatureContext, options: CrossfireHandlerOptions): Promise { const feature = "crossfire" as const; - const { userId, provider, model, temperature, reasoningEffort, workSignal, startTime, recordMainResult } = ctx; + const { userId, provider, model, reasoningEffort, workSignal, startTime, recordMainResult } = ctx; const { input: crossfireInput, sources: crossfireSources, workspaceSources } = options; if (!crossfireInput) throw new Error("Crossfire input was not validated"); @@ -62,7 +62,6 @@ export async function handleCrossfire(ctx: FeatureContext, options: CrossfireHan model, systemPrompt, [{ role: "user", content: userPrompt }], - temperature, reasoningEffort, workSignal, )); diff --git a/supabase/functions/ai/features/factoids.ts b/supabase/functions/ai/features/factoids.ts index a8f224b76..13f14f690 100644 --- a/supabase/functions/ai/features/factoids.ts +++ b/supabase/functions/ai/features/factoids.ts @@ -16,7 +16,7 @@ import type { FeatureContext } from "./types.ts"; // FACTOIDS: Use deep research with multiple searches export async function handleFactoids(ctx: FeatureContext): Promise { const feature = "factoids" as const; - const { userId, provider, model, temperature, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; + const { userId, provider, model, reasoningEffort, workSignal, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; const claim = String(sanitizedInput.claim || ""); const region = String(sanitizedInput.region || "Global"); const preferredSourceType = String(sanitizedInput.sourceType || "any"); @@ -34,7 +34,7 @@ export async function handleFactoids(ctx: FeatureContext): Promise { const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); // Post-process: quality floor. Only citations whose URL matches fetched, diff --git a/supabase/functions/ai/features/generate.ts b/supabase/functions/ai/features/generate.ts index 76accb016..abd97ea6f 100644 --- a/supabase/functions/ai/features/generate.ts +++ b/supabase/functions/ai/features/generate.ts @@ -26,7 +26,7 @@ import type { FeatureContext } from "./types.ts"; // GENERATE: Use deep research for evidence-backed arguments export async function handleGenerate(ctx: FeatureContext): Promise { const feature = "generate" as const; - const { req, userId, provider, model, temperature, reasoningEffort, workSignal, sources, sourceAssessments, sourceDecisionCandidates, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; + const { req, userId, provider, model, reasoningEffort, workSignal, sources, sourceAssessments, sourceDecisionCandidates, researchDeps, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor, input: sanitizedInput } = ctx; const motion = String(sanitizedInput.motion || sanitizedInput.topic || ""); const side = String(sanitizedInput.side || "pro"); const format = String(sanitizedInput.format || "custom"); @@ -81,7 +81,7 @@ export async function handleGenerate(ctx: FeatureContext): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); // Preserve model evidence warnings when research exists. If research diff --git a/supabase/functions/ai/features/sparring.ts b/supabase/functions/ai/features/sparring.ts index 39d8e5472..36ccaffaf 100644 --- a/supabase/functions/ai/features/sparring.ts +++ b/supabase/functions/ai/features/sparring.ts @@ -28,14 +28,14 @@ export interface SparringHandlerOptions { // branch runs here, and score persistence follows output validation. export async function handleSparring(ctx: FeatureContext, options: SparringHandlerOptions): Promise { const feature = "sparring" as const; - const { userId, provider, model, temperature, reasoningEffort, workSignal, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor } = ctx; + const { userId, provider, model, reasoningEffort, workSignal, startTime, researchUsageForLog, recordMainResult, buildSystemPromptFor } = ctx; const { request: sparringRequest, database: sparringDatabase, claimToken: sparringScoreClaimToken } = options; if (!sparringRequest) throw new Error("Invalid sparring request"); const systemPrompt = await buildSystemPromptFor(feature); const userPrompt = buildUserPrompt(feature, sparringRequest as unknown as Record, ""); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(aiResult.output); const normalizedOutput = normalizeSparringOutput(output, sparringRequest.action); const parsedOutput = parseSparringProviderOutput(normalizedOutput, sparringRequest.action); diff --git a/supabase/functions/ai/features/types.ts b/supabase/functions/ai/features/types.ts index 14d63b729..c5c9c6d7f 100644 --- a/supabase/functions/ai/features/types.ts +++ b/supabase/functions/ai/features/types.ts @@ -14,7 +14,6 @@ export interface FeatureContext { userId: string; provider: Provider; model: string; - temperature: number; reasoningEffort: ReasoningEffort; /** Caller cancellation combined with the shared request budget. */ workSignal: AbortSignal; diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 7732ba838..9e4d03478 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -727,7 +727,9 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t if (reasoning.exclude !== true || provider.require_parameters !== true) throw new Error(`strict reasoning route missing for ${entry.id}`); if ("sort" in provider || "order" in provider) throw new Error(`sort/order disabled balanced routing for ${entry.id}`); if ((body.response_format as Record)?.type !== "json_object") throw new Error(`JSON response format missing for ${entry.id}`); - if (entry.supportsTemperature !== ("temperature" in body)) throw new Error(`temperature support mismatch for ${entry.id}`); + // With require_parameters, one endpoint without temperature support + // turns the whole request into a routing 404 (Claude Opus 5 did). + if ("temperature" in body) throw new Error(`temperature was sent for ${entry.id}`); } for (const [offset, effort] of ["low", "medium", "high"].entries()) { const body = openRouterBodies[MODEL_CATALOG.length + offset]; diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index af3ec5264..34281ee4a 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -159,7 +159,6 @@ export async function handleRequest(req: Request): Promise { ? undefined : isReasoningEffort(body.reasoningEffort) ? body.reasoningEffort : null; if (requestedReasoningEffort === null) return badRequest("Invalid reasoning effort", corsHeaders); - const temperature = typeof body.temperature === "number" ? body.temperature : 0.7; // A voice round. It sits beside `input` so servers from before voice ignore it. const voiceRequested = body.voice === true; // The per-user custom system prompt was replaced by each Current Debate's @@ -610,7 +609,6 @@ export async function handleRequest(req: Request): Promise { model: modelEntry.id, requestedReasoningEffort: requestedReasoningEffort ?? "medium", reasoningEffort, - supportsTemperature: modelEntry.supportsTemperature, }); const startTime = Date.now(); @@ -628,7 +626,7 @@ export async function handleRequest(req: Request): Promise { }, callModel: async (args) => { try { - const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.temperature, args.reasoningEffort, args.signal); + const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.reasoningEffort, args.signal); // A provider that ignores cancellation must not append usage after the // research timeout has already been finalized by the parent request. if (result.usage && !args.signal?.aborted) researchUsage.push(result.usage); @@ -683,7 +681,6 @@ export async function handleRequest(req: Request): Promise { userId, provider, model, - temperature, reasoningEffort, workSignal, voice: voiceRequested, @@ -748,7 +745,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature); const userPrompt = buildUserPrompt(feature, sanitizedInput, ""); const messages = [{ role: "user", content: userPrompt }]; - const fallbackResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, workSignal)); + const fallbackResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, reasoningEffort, workSignal)); const output = cleanGeneratedText(fallbackResult.output); const latencyMs = Date.now() - startTime; diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index cada9d4ee..024e35dcd 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -101,12 +101,11 @@ export async function callAI( model: string, systemPrompt: string, messages: Array<{ role: string; content: string }>, - temperature: number, reasoningEffort: ReasoningEffort, signal?: AbortSignal ): Promise { try { - return await callAIOnce(provider, model, systemPrompt, messages, temperature, reasoningEffort, signal); + return await callAIOnce(provider, model, systemPrompt, messages, reasoningEffort, signal); } catch (err) { if (!(err instanceof InvalidJsonResponseError)) throw attachMissingUsage(err, "call"); @@ -118,7 +117,6 @@ export async function callAI( model, `${systemPrompt}\n\nJSON REPAIR INSTRUCTION: Return only valid JSON matching this schema, no prose.`, withJsonRepairPrompt(messages), - 0, reasoningEffort, signal, ); @@ -169,7 +167,6 @@ export async function callAIOnce( model: string, systemPrompt: string, messages: Array<{ role: string; content: string }>, - temperature: number, reasoningEffort: ReasoningEffort, signal?: AbortSignal ): Promise { @@ -177,7 +174,6 @@ export async function callAIOnce( return await callOpenRouter( [{ role: "system", content: systemPrompt }, ...messages], model, - temperature, reasoningEffort, signal, ); @@ -199,7 +195,6 @@ export function createRequestBudgetSignal( export async function callOpenRouter( messages: Array<{ role: string; content: string }>, model: string, - temperature: number, reasoningEffort: ReasoningEffort, signal?: AbortSignal, ): Promise { @@ -216,7 +211,6 @@ export async function callOpenRouter( // Omitting sort and order keeps OpenRouter's balanced provider load balancing. provider: { require_parameters: true }, }; - if (modelEntry.supportsTemperature) payload.temperature = temperature; const { response, data, errorBody } = await fetchJsonWithTimeout(`${OPENROUTER_API_URL}/v1/chat/completions`, { method: "POST", From 2f3f2c841daa9787aa2b096c09203b546845b069 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 17:10:26 +0000 Subject: [PATCH 2/2] Skip the settings cleanup when the row changed since hydration read it Dropping temperature makes every saved feature_configs non-canonical, so every existing user now takes the one-time rewrite path. That write sent the whole snapshot read at hydration, so a model choice saved in the meantime could be reverted on the server. The rewrite now applies only while updated_at still matches the read, and only if the user has not changed settings locally since hydration. A skipped cleanup retries on the next hydration. Co-Authored-By: Claude Opus 5.5 --- src/lib/stores/settings-store.ts | 20 +++- src/test/settings/settings-migration.test.ts | 97 ++++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/test/settings/settings-migration.test.ts diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index ed9d5bb30..9de5d6884 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -183,13 +183,22 @@ async function updateSettings(payload: { } } -async function persistMigratedFeatureConfigs(featureConfigs: Record) { +// The cleanup rewrites the whole feature_configs snapshot it read, so it only +// applies while the row is unchanged since that read. A settings save that +// lands first moves updated_at and wins; the next hydration retries. +async function persistMigratedFeatureConfigs( + featureConfigs: Record, + readUpdatedAt: string | null, +) { const userId = await getUserId(); if (!userId) return; - const { error } = await supabase + const update = supabase .from('user_settings') .update({ feature_configs: featureConfigs, updated_at: new Date().toISOString() }) .eq('user_id', userId); + const { error } = await (readUpdatedAt === null + ? update.is('updated_at', null) + : update.eq('updated_at', readUpdatedAt)); if (error) { console.warn('[SettingsStore] Failed to persist migrated settings:', error.message); } @@ -318,8 +327,11 @@ export const useSettingsStore = create()( feature_configs: mergedConfigs, custom_system_prompt: customSystemPrompt ?? null, }); - } else if (configsNeedMigration) { - await persistMigratedFeatureConfigs(mergedConfigs); + } else if (configsNeedMigration && get().featureConfigs === mergedConfigs) { + await persistMigratedFeatureConfigs( + mergedConfigs, + typeof data.updated_at === 'string' ? data.updated_at : null, + ); } }, resetLocalData: (preserveLanguage = false) => { diff --git a/src/test/settings/settings-migration.test.ts b/src/test/settings/settings-migration.test.ts new file mode 100644 index 000000000..fb24a1033 --- /dev/null +++ b/src/test/settings/settings-migration.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type Row = Record; + +const db = vi.hoisted(() => ({ + row: {} as Row, + onGetUser: undefined as (() => void) | undefined, +})); + +vi.mock('@/i18n', () => ({ applyAppLanguage: vi.fn(async () => undefined) })); + +vi.mock('@/lib/supabase/client', () => { + // One user_settings row. An update applies only when every filter matches, + // like a PostgREST UPDATE ... WHERE. + const update = (payload: Row) => { + const filters: Array<(row: Row) => boolean> = []; + const builder = { + eq(column: string, value: unknown) { + filters.push((row) => row[column] === value); + return builder; + }, + is(column: string, value: null) { + filters.push((row) => row[column] === value); + return builder; + }, + then(resolve: (result: { error: null }) => void) { + if (filters.every((matches) => matches(db.row))) db.row = { ...db.row, ...payload }; + resolve({ error: null }); + }, + }; + return builder; + }; + return { + supabase: { + auth: { + getUser: async () => { + db.onGetUser?.(); + return { data: { user: { id: 'user-1' } } }; + }, + }, + from: () => ({ + select: () => ({ single: async () => ({ data: { ...db.row }, error: null as null }) }), + update, + }), + }, + }; +}); + +import { useSettingsStore } from '@/lib/stores/settings-store'; + +const features = ['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'sparring'] as const; +const savedWithTemperature = Object.fromEntries(features.map((feature) => [ + feature, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, +])); + +describe('settings hydration cleanup', () => { + beforeEach(() => { + db.onGetUser = undefined; + db.row = { + user_id: 'user-1', + subscription_tier: 'trial', + language: 'en', + output_language: 'en', + theme: 'system', + feature_configs: savedWithTemperature, + updated_at: '2026-10-01T00:00:00.000000+00:00', + }; + }); + + it('rewrites saved configs without temperature', async () => { + await useSettingsStore.getState().hydrateFromServer(); + + const saved = db.row.feature_configs as Record; + expect(Object.keys(saved.ask)).not.toContain('temperature'); + expect(saved.ask.model).toBe('openai/gpt-5.6-luna'); + }); + + it('does not overwrite a settings save that lands before the cleanup', async () => { + db.onGetUser = () => { + db.onGetUser = undefined; + db.row = { + ...db.row, + feature_configs: { + ...savedWithTemperature, + ask: { provider: 'openrouter', model: 'google/gemini-3.8-flash', reasoningEffort: 'low' }, + }, + updated_at: '2026-10-09T17:00:00.000Z', + }; + }; + + await useSettingsStore.getState().hydrateFromServer(); + + const saved = db.row.feature_configs as Record; + expect(saved.ask.model).toBe('google/gemini-3.8-flash'); + }); +});