Jelajahi Sumber

Send max_completion_tokens to OpenAI reasoning models (#292)

Bernt Christian Egeland 1 bulan lalu
induk
melakukan
06f9e2e8d8

+ 55 - 0
src/__tests__/lib/ai-completion-tuning.test.ts

@@ -0,0 +1,55 @@
+/**
+ * Tests for the per-model completion parameters.
+ *
+ * OpenAI's reasoning models reject the classic `max_tokens` parameter with a
+ * 400 ("Use 'max_completion_tokens' instead") and refuse any temperature other
+ * than the default, while the Anthropic compatibility endpoint keeps the
+ * classic parameter. Picking wrong surfaces as an error toast on every AI
+ * feature, so the mapping is pinned down here.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+import { completionTuning } from '@/lib/ai'
+
+const config = (provider: string, model: string) => ({ provider, apiKey: 'key', model })
+
+describe('completionTuning', () => {
+  it('keeps max_tokens and temperature for the anthropic endpoint', () => {
+    expect(completionTuning(config('anthropic', 'claude-sonnet-4-6'), 2000, 0.7)).toEqual({
+      max_tokens: 2000,
+      temperature: 0.7,
+    })
+  })
+
+  it('sends max_completion_tokens for classic OpenAI models, keeping temperature', () => {
+    expect(completionTuning(config('openai', 'gpt-4o'), 2000, 0.7)).toEqual({
+      max_completion_tokens: 2000,
+      temperature: 0.7,
+    })
+  })
+
+  it('drops temperature for reasoning models', () => {
+    for (const model of ['o1', 'o3-mini', 'o4-mini', 'gpt-5', 'gpt-5-mini']) {
+      const tuning = completionTuning(config('openai', model), 2000, 0.7)
+      expect(tuning).not.toHaveProperty('temperature')
+      expect(tuning).not.toHaveProperty('max_tokens')
+    }
+  })
+
+  it('gives reasoning models headroom for hidden thinking tokens', () => {
+    const tuning = completionTuning(config('openai', 'gpt-5'), 2000, 0.7)
+    expect(tuning.max_completion_tokens).toBeGreaterThan(2000)
+  })
+
+  it('omits temperature when the caller does not set one', () => {
+    expect(completionTuning(config('openai', 'gpt-4o'), 5)).toEqual({
+      max_completion_tokens: 5,
+    })
+    expect(completionTuning(config('anthropic', 'claude-sonnet-4-6'), 5)).toEqual({
+      max_tokens: 5,
+    })
+  })
+})

+ 3 - 5
src/features/ai/Actions/aiChatActions.ts

@@ -2,7 +2,7 @@
 
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { getAiConfig, createClient } from '@/lib/ai'
+import { getAiConfig, createClient, completionTuning } from '@/lib/ai'
 import { getLocale } from 'next-intl/server'
 import { localeNames, type Locale } from '@/i18n/config'
 import { workshopTools, executeTool, DB_SCHEMA } from '../tools/workshop-tools'
@@ -144,8 +144,7 @@ export async function aiChat(chatId: string | null, messages: ChatMessage[]) {
           model: config.model,
           messages: apiMessages,
           tools: workshopTools,
-          temperature: 0.3,
-          max_tokens: 3000,
+          ...completionTuning(config, 3000, 0.3),
         })
 
         const choice = response.choices[0]
@@ -224,8 +223,7 @@ export async function aiChat(chatId: string | null, messages: ChatMessage[]) {
       const finalResponse = await client.chat.completions.create({
         model: config.model,
         messages: apiMessages,
-        temperature: 0.3,
-        max_tokens: 3000,
+        ...completionTuning(config, 3000, 0.3),
       })
 
       const assistantContent = finalResponse.choices[0]?.message?.content || ''

+ 36 - 5
src/lib/ai.ts

@@ -53,6 +53,39 @@ export function createClient(config: AiConfig): OpenAI {
   return new OpenAI({ apiKey: config.apiKey })
 }
 
+/**
+ * OpenAI's reasoning models (the o-series and the GPT-5 family) reject the
+ * classic `max_tokens` parameter with a 400 and only accept
+ * `max_completion_tokens`; they likewise refuse any temperature other than the
+ * default. Every current OpenAI chat model accepts `max_completion_tokens`, so
+ * it is used across the board there. The Anthropic compatibility endpoint
+ * keeps the classic parameter.
+ *
+ * Reasoning models spend billed-but-hidden thinking tokens inside the same
+ * cap before producing a visible answer, so they get headroom on top of the
+ * requested answer budget; without it the reply comes back truncated or empty.
+ */
+const REASONING_HEADROOM = 4000
+
+function isReasoningModel(model: string): boolean {
+  return /^(o\d|gpt-5)/i.test(model)
+}
+
+export function completionTuning(
+  config: AiConfig,
+  maxTokens: number,
+  temperature?: number
+): { max_tokens?: number; max_completion_tokens?: number; temperature?: number } {
+  if (config.provider !== 'openai') {
+    return { max_tokens: maxTokens, ...(temperature !== undefined && { temperature }) }
+  }
+  const reasoning = isReasoningModel(config.model)
+  return {
+    max_completion_tokens: reasoning ? maxTokens + REASONING_HEADROOM : maxTokens,
+    ...(temperature !== undefined && !reasoning && { temperature }),
+  }
+}
+
 function languageInstruction(locale: Locale): string {
   if (locale === 'en') return ''
   const name = localeNames[locale] || locale
@@ -74,8 +107,7 @@ async function chatCompletion(
         { role: 'system', content: systemPrompt },
         { role: 'user', content: userPrompt },
       ],
-      temperature: 0.7,
-      max_tokens: 2000,
+      ...completionTuning(config, 2000, 0.7),
     })
 
     return response.choices[0]?.message?.content ?? ''
@@ -109,8 +141,7 @@ export async function visionCompletion(
         { role: 'system', content: systemPrompt },
         { role: 'user', content },
       ],
-      temperature: 0.3,
-      max_tokens: 1000,
+      ...completionTuning(config, 1000, 0.3),
     })
 
     return response.choices[0]?.message?.content ?? ''
@@ -235,7 +266,7 @@ export async function testAiConnection(organizationId: string): Promise<boolean>
   const response = await client.chat.completions.create({
     model: config.model,
     messages: [{ role: 'user', content: 'Say OK' }],
-    max_tokens: 5,
+    ...completionTuning(config, 5),
   })
 
   return !!response.choices[0]?.message?.content