Procházet zdrojové kódy

List only the OpenAI models that can answer a chat, newest first (#570)

* List only the OpenAI models that can answer a chat, newest first

The model dropdown on the OpenAI connector offered every id starting with
gpt- or o, which includes image, speech, transcription, realtime and audio
models and the pro, codex, cyber and open-weight models that only answer
the Responses API. Picking one made every AI feature fail. Those are now
left out, matched as whole words so a new chat model is not dropped by
accident.

The list was also sorted from a table that put the o-series first and
everything after GPT-4 last. It now reads the version out of the id, so
GPT-6 and GPT-5 lead, a model released later lands at the top on its own,
the larger model leads within a version and a dated snapshot follows its
alias.

* Treat GPT-6 and later as reasoning models when calling OpenAI

Only the o-series and GPT-5 were recognised, so the GPT-6 models now at the
top of the model list were sent a temperature, which OpenAI refuses while
reasoning is on, and no headroom for their hidden thinking tokens. Every
major version from GPT-5 on is now treated as a reasoning model, while
GPT-4.x, GPT-4o and GPT-3.5 keep their temperature.

* List GPT-4 Turbo ahead of the original GPT-4

Both had the same rank, so the original GPT-4 came first by name. Within
GPT-4 the order is now GPT-4o, then GPT-4 Turbo and its previews, then the
original GPT-4.
Bernt Christian Egeland před 5 dny
rodič
revize
56701d983f

+ 169 - 0
src/__tests__/features/integrations/openai-compatible.test.ts

@@ -117,3 +117,172 @@ describe('OpenAI-compatible connector', () => {
     })
     })
   })
   })
 })
 })
+
+/**
+ * The models an OpenAI key lists in November 2026 that answer a chat request,
+ * in the order the dropdown should show them. Ids, aliases and snapshots are
+ * the ones on OpenAI's model pages, where each of these has Chat Completions
+ * marked as supported.
+ */
+const OPENAI_CHAT_NEWEST_FIRST = [
+  'gpt-6.1-sol',
+  'gpt-6.1-terra',
+  'gpt-6-astra',
+  'gpt-6-sol',
+  'gpt-6-luna',
+  'gpt-5.6',
+  'gpt-5.6-sol',
+  'gpt-5.6-terra',
+  'gpt-5.6-luna',
+  'gpt-5.5',
+  'gpt-5.5-2026-04-23',
+  'gpt-5.4',
+  'gpt-5.4-2026-03-05',
+  'gpt-5.4-mini',
+  'gpt-5.4-mini-2026-03-17',
+  'gpt-5.4-nano',
+  'gpt-5.4-nano-2026-03-17',
+  'gpt-5.2',
+  'gpt-5.2-2025-12-11',
+  'gpt-5.1',
+  'gpt-5.1-2025-11-13',
+  'gpt-5',
+  'gpt-5-2025-08-07',
+  'gpt-5-mini',
+  'gpt-5-mini-2025-08-07',
+  'gpt-5-nano',
+  'gpt-5-nano-2025-08-07',
+  'o3',
+  'o3-2025-04-16',
+  'gpt-4.1',
+  'gpt-4.1-2025-04-14',
+  'gpt-4.1-mini',
+  'gpt-4.1-mini-2025-04-14',
+  'gpt-4o',
+  'gpt-4o-2024-11-20',
+  'gpt-4o-2024-08-06',
+  'gpt-4o-mini',
+  'gpt-4o-mini-2024-07-18',
+]
+
+/** What the same key lists that a chat request cannot use, by the reason it cannot. */
+const OPENAI_NOT_FOR_CHAT = [
+  // Images.
+  'gpt-image-2.6-sunburst',
+  'gpt-image-2.6-flare',
+  'gpt-image-2.5-sunburst',
+  'gpt-image-2',
+  'gpt-image-2-2026-04-21',
+  'gpt-image-1.5',
+  'gpt-image-1-mini',
+  'chatgpt-image-latest',
+  // Voice sessions.
+  'gpt-live-1',
+  'gpt-realtime-2.1',
+  'gpt-realtime-2.1-mini',
+  'gpt-realtime-2',
+  'gpt-realtime-1.5',
+  'gpt-realtime-translate',
+  'gpt-realtime-whisper',
+  'gpt-realtime',
+  'gpt-realtime-mini',
+  // Sound in, sound out.
+  'gpt-audio-1.5',
+  'gpt-audio',
+  'gpt-audio-mini',
+  // Text to speech.
+  'tts-1',
+  'tts-1-hd',
+  'gpt-4o-mini-tts',
+  // Speech to text.
+  'whisper-1',
+  'gpt-transcribe',
+  'gpt-live-transcribe',
+  'gpt-4o-transcribe',
+  'gpt-4o-mini-transcribe',
+  'gpt-4o-transcribe-diarize',
+  // Answered by the Responses API only.
+  'gpt-5.5-pro',
+  'gpt-5.5-pro-2026-04-23',
+  'gpt-5.4-pro',
+  'gpt-5.2-pro',
+  'gpt-5-pro',
+  'o3-pro',
+  'o3-pro-2025-06-10',
+  'gpt-5.3-codex',
+  'gpt-5.6-cyber',
+  'gpt-oss-120b',
+  // Embeddings and moderation.
+  'text-embedding-3-large',
+  'text-embedding-3-small',
+  'text-embedding-ada-002',
+  'omni-moderation-latest',
+  // Retired during 2026, and the kind may come back under a new name.
+  'gpt-4o-search-preview',
+  'gpt-4o-mini-search-preview',
+  'o3-deep-research',
+  'o4-mini-deep-research',
+  'gpt-5.1-codex-mini',
+  'gpt-3.5-turbo-instruct',
+]
+
+describe('OpenAI model list', () => {
+  it('offers only the models that can answer a chat, newest first', async () => {
+    // The vendor returns them in no useful order; alphabetical stands in for that.
+    const listed = [...OPENAI_NOT_FOR_CHAT, ...OPENAI_CHAT_NEWEST_FIRST].sort()
+    const ctx = context(listing(listed), { apiKey: 'sk-1' })
+
+    const ids = (await listOpenAiModels(ctx)).map((m) => m.value)
+    expect(ids).toEqual(OPENAI_CHAT_NEWEST_FIRST)
+  })
+
+  it('names the current models the way a workshop would read them', async () => {
+    const ctx = context(listing(OPENAI_CHAT_NEWEST_FIRST), { apiKey: 'sk-1' })
+
+    expect((await listOpenAiModels(ctx)).slice(0, 3)).toEqual([
+      { value: 'gpt-6.1-sol', label: 'GPT 6.1 Sol' },
+      { value: 'gpt-6.1-terra', label: 'GPT 6.1 Terra' },
+      { value: 'gpt-6-astra', label: 'GPT 6 Astra' },
+    ])
+  })
+
+  it('lists the older GPT-4 models newest first: 4o, then Turbo, then the original', async () => {
+    const listed = [
+      'gpt-4',
+      'gpt-4-0613',
+      'gpt-4-turbo',
+      'gpt-4-turbo-2024-04-09',
+      'gpt-4-0125-preview',
+      'gpt-4o',
+      'gpt-3.5-turbo',
+    ]
+    const ctx = context(listing(listed), { apiKey: 'sk-1' })
+
+    const ids = (await listOpenAiModels(ctx)).map((m) => m.value)
+    expect(ids).toEqual([
+      'gpt-4o',
+      'gpt-4-0125-preview',
+      'gpt-4-turbo',
+      'gpt-4-turbo-2024-04-09',
+      'gpt-4',
+      'gpt-4-0613',
+      'gpt-3.5-turbo',
+    ])
+  })
+
+  it('puts a model released after this was written at the top', async () => {
+    // Made-up ids in the vendor's naming. The last one starts like "pro", the
+    // Responses-only kind, and is a chat model all the same.
+    const listed = ['gpt-5.6-sol', 'gpt-6.1-sol', 'gpt-6.2-terra', 'gpt-7-sol', 'gpt-7-proxima']
+    const ctx = context(listing(listed), { apiKey: 'sk-1' })
+
+    const ids = (await listOpenAiModels(ctx)).map((m) => m.value)
+    expect(ids).toEqual([
+      'gpt-7-proxima',
+      'gpt-7-sol',
+      'gpt-6.2-terra',
+      'gpt-6.1-sol',
+      'gpt-5.6-sol',
+    ])
+  })
+})

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

@@ -81,6 +81,29 @@ describe('completionTuning', () => {
     expect(tuning.max_completion_tokens).toBeGreaterThan(2000)
     expect(tuning.max_completion_tokens).toBeGreaterThan(2000)
   })
   })
 
 
+  it('treats GPT-6 and later as reasoning models', () => {
+    // OpenAI's GPT-6 guide: remove temperature when reasoning effort is not none.
+    for (const model of ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-luna', 'gpt-7', 'gpt-10-mini']) {
+      const tuning = completionTuning(config('openai', model), 2000, 0.7)
+      expect(tuning, model).toEqual({ max_completion_tokens: 2000 + 4000 })
+    }
+  })
+
+  it('keeps GPT-5 a reasoning model and GPT-4.x a classic one', () => {
+    expect(completionTuning(config('openai', 'gpt-5-mini'), 2000, 0.7)).toEqual({
+      max_completion_tokens: 6000,
+    })
+    expect(completionTuning(config('openai', 'gpt-5.6-terra'), 2000, 0.7)).toEqual({
+      max_completion_tokens: 6000,
+    })
+    for (const model of ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4', 'gpt-3.5-turbo']) {
+      expect(completionTuning(config('openai', model), 2000, 0.7), model).toEqual({
+        max_completion_tokens: 2000,
+        temperature: 0.7,
+      })
+    }
+  })
+
   it('omits temperature when the caller does not set one', () => {
   it('omits temperature when the caller does not set one', () => {
     expect(completionTuning(config('openai', 'gpt-4o'), 5)).toEqual({
     expect(completionTuning(config('openai', 'gpt-4o'), 5)).toEqual({
       max_completion_tokens: 5,
       max_completion_tokens: 5,

+ 71 - 15
src/integrations/ai/models.ts

@@ -52,26 +52,82 @@ export function anthropicHeaders(apiKey: string): Record<string, string> {
 /** Chat-capable OpenAI models; the rest of the list is embeddings, audio and images. */
 /** Chat-capable OpenAI models; the rest of the list is embeddings, audio and images. */
 const OPENAI_CHAT_PREFIXES = ['gpt-', 'o1', 'o3', 'o4', 'chatgpt-']
 const OPENAI_CHAT_PREFIXES = ['gpt-', 'o1', 'o3', 'o4', 'chatgpt-']
 
 
+/**
+ * Words in an id that carries a chat prefix and still cannot answer the app,
+ * which sends text and photos to Chat Completions. A workshop that picked one
+ * of these would get an error from every AI feature.
+ */
+const OPENAI_NOT_CHAT = [
+  // Served by another endpoint: images, speech, transcription, voice sessions.
+  'image',
+  'tts',
+  'transcribe',
+  'realtime',
+  'live',
+  // Chat Completions takes these, but only for sound in and out or a web search.
+  'audio',
+  'search',
+  // Answered by the Responses API only.
+  'pro',
+  'deep-research',
+  'codex',
+  'oss',
+  // Security research models for approved organisations, Responses only until GPT-6.
+  'cyber',
+  'daybreak',
+  // The legacy Completions endpoint.
+  'instruct',
+]
+
 function isOpenAiChatModel(id: string): boolean {
 function isOpenAiChatModel(id: string): boolean {
-  return OPENAI_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix))
+  if (!OPENAI_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix))) return false
+  // Whole words between hyphens, so "pro" does not take a model named Proxima with it.
+  return !OPENAI_NOT_CHAT.some((word) => `${id}-`.includes(`-${word}-`))
+}
+
+/**
+ * Size within a version. The GPT-5.6 naming put Sol where the unsuffixed
+ * model was, Terra where mini was and Luna where nano was, with Astra above.
+ */
+function openAiModelSize(id: string): number {
+  const words = id.split('-')
+  if (words.includes('astra')) return 0
+  if (words.includes('terra') || words.includes('mini')) return 2
+  if (words.includes('luna') || words.includes('nano')) return 3
+  return 1
 }
 }
 
 
 /**
 /**
- * Sort OpenAI models: newest version first, within a version full > mini >
- * nano. The o-series leads, then GPT-4.x, then GPT-4o, then the rest.
+ * Where an OpenAI model sorts, lowest first. The version is read out of the
+ * id rather than looked up, so a model released next month lands at the top
+ * without a change here: GPT-5 and later, then the o-series that GPT-5
+ * replaced, then GPT-4.x. Within a version the larger model leads, and a
+ * dated snapshot follows the alias it pins, the latest snapshot first.
  */
  */
-function openAiModelOrder(id: string): number {
+function openAiModelRank(id: string): number[] {
   const l = id.toLowerCase()
   const l = id.toLowerCase()
-  if (l.startsWith('o4')) return 100 + (l.includes('mini') ? 1 : 0)
-  if (l.startsWith('o3')) return 200 + (l.includes('mini') ? 1 : 0)
-  if (l.startsWith('o1')) return 300 + (l.includes('mini') ? 1 : 0)
-  if (l.startsWith('gpt-4.1')) return 400 + (l.includes('nano') ? 2 : l.includes('mini') ? 1 : 0)
-  if (l.startsWith('gpt-4.5')) return 350 + (l.includes('nano') ? 2 : l.includes('mini') ? 1 : 0)
-  if (l.startsWith('gpt-4o')) return 500 + (l.includes('mini') ? 1 : 0)
-  if (l.startsWith('gpt-4'))
-    return 600 + (l.includes('turbo') ? 0 : 1) + (l.includes('mini') ? 2 : 0)
-  if (l.startsWith('chatgpt')) return 700
-  return 900
+  const gpt = l.match(/^gpt-(\d+)(?:\.(\d+))?(o?)(?=-|$)/)
+  const oSeries = l.match(/^o(\d+)(?=-|$)/)
+  const major = Number.parseInt(gpt?.[1] ?? oSeries?.[1] ?? '0', 10)
+  const minor = Number.parseInt(gpt?.[2] ?? '0', 10)
+  const line = gpt ? (major >= 5 ? 0 : 2) : oSeries ? 1 : 3
+  // Within GPT-4: GPT-4o, then GPT-4 Turbo (its previews included), then the
+  // original GPT-4. GPT-4.1 came after all three and has its own minor.
+  const words = l.split('-')
+  const turbo = major === 4 && (words.includes('turbo') || words.includes('preview'))
+  const generation = gpt?.[3] ? 0 : turbo ? 1 : 2
+  const snapshot = l.match(/-(\d{4})-(\d{2})-(\d{2})$/)
+  const age = snapshot ? 1e8 - Number.parseInt(snapshot.slice(1).join(''), 10) : 0
+  return [line, -major, -minor, generation, openAiModelSize(l), age]
+}
+
+function compareOpenAiModels(a: string, b: string): number {
+  const rankA = openAiModelRank(a)
+  const rankB = openAiModelRank(b)
+  for (let i = 0; i < rankA.length; i++) {
+    if (rankA[i] !== rankB[i]) return rankA[i] - rankB[i]
+  }
+  return a.localeCompare(b)
 }
 }
 
 
 /** Sort Anthropic models: newest version first, within a version opus > sonnet > haiku. */
 /** Sort Anthropic models: newest version first, within a version opus > sonnet > haiku. */
@@ -110,7 +166,7 @@ export async function listOpenAiModels(ctx: ConnectorContext): Promise<SettingOp
   const ids = await fetchModelIds(ctx, OPENAI_BASE)
   const ids = await fetchModelIds(ctx, OPENAI_BASE)
   return ids
   return ids
     .filter(isOpenAiChatModel)
     .filter(isOpenAiChatModel)
-    .sort((a, b) => openAiModelOrder(a) - openAiModelOrder(b))
+    .sort(compareOpenAiModels)
     .map((id) => ({ value: id, label: formatModelLabel(id) }))
     .map((id) => ({ value: id, label: formatModelLabel(id) }))
 }
 }
 
 

+ 5 - 1
src/lib/ai.ts

@@ -80,8 +80,12 @@ export function createClient(config: AiConfig): OpenAI {
  */
  */
 const REASONING_HEADROOM = 4000
 const REASONING_HEADROOM = 4000
 
 
+/**
+ * The o-series and GPT-5 and every later major version (5.6, 6, 6.1, 7, 10 and
+ * on) reason before they answer; GPT-4.x, GPT-4o and GPT-3.5 do not.
+ */
 function isReasoningModel(model: string): boolean {
 function isReasoningModel(model: string): boolean {
-  return /^(o\d|gpt-5)/i.test(model)
+  return /^(?:o\d|gpt-(?:[5-9]|[1-9]\d+)(?=[.-]|$))/i.test(model)
 }
 }
 
 
 /**
 /**