diff --git a/packages/core/src/plugins/anthropic/anthropic.ts b/packages/core/src/plugins/anthropic/anthropic.ts index e005f5fc3..c679ed5ea 100644 --- a/packages/core/src/plugins/anthropic/anthropic.ts +++ b/packages/core/src/plugins/anthropic/anthropic.ts @@ -7,6 +7,9 @@ export type AnthropicModel = { completion: number; }; displayName: string; + + /** If true, this model is deprecated/legacy and will be sorted to the bottom of dropdowns. */ + legacy?: boolean; }; export const anthropicModels = { @@ -17,6 +20,7 @@ export const anthropicModels = { completion: 0.00551, }, displayName: 'Claude Instant', + legacy: true, }, 'claude-instant-1.2': { maxTokens: 100_000, @@ -25,6 +29,7 @@ export const anthropicModels = { completion: 2.4e-6, }, displayName: 'Claude Instant 1.2', + legacy: true, }, 'claude-2': { maxTokens: 100_000, @@ -33,6 +38,7 @@ export const anthropicModels = { completion: 24e-6, }, displayName: 'Claude 2', + legacy: true, }, 'claude-2.1': { maxTokens: 200_000, @@ -41,6 +47,7 @@ export const anthropicModels = { completion: 24e-6, }, displayName: 'Claude 2.1', + legacy: true, }, 'claude-3-haiku-20240307': { maxTokens: 200_000, @@ -49,6 +56,7 @@ export const anthropicModels = { completion: 1.25e-6, }, displayName: 'Claude 3 Haiku', + legacy: true, }, 'claude-3-sonnet-20240229': { maxTokens: 200_000, @@ -57,6 +65,7 @@ export const anthropicModels = { completion: 15e-6, }, displayName: 'Claude 3 Sonnet', + legacy: true, }, 'claude-3-opus-20240229': { maxTokens: 200_000, @@ -65,6 +74,7 @@ export const anthropicModels = { completion: 75e-6, }, displayName: 'Claude 3 Opus', + legacy: true, }, 'claude-3-5-sonnet-latest': { maxTokens: 200_000, @@ -106,14 +116,41 @@ export const anthropicModels = { }, displayName: 'Claude Opus 4', }, + 'claude-haiku-4-5-20251001': { + maxTokens: 200_000, + cost: { + prompt: 0.25e-6, + completion: 1.25e-6, + }, + displayName: 'Claude Haiku 4.5', + }, + 'claude-sonnet-4-6-20260414': { + maxTokens: 200_000, + cost: { + prompt: 3e-6, + completion: 15e-6, + }, + displayName: 'Claude Sonnet 4.6', + }, + 'claude-opus-4-6-20260414': { + maxTokens: 200_000, + cost: { + prompt: 5e-6, + completion: 25e-6, + }, + displayName: 'Claude Opus 4.6', + }, } satisfies Record; export type AnthropicModels = keyof typeof anthropicModels; -export const anthropicModelOptions = Object.entries(anthropicModels).map(([id, { displayName }]) => ({ - value: id, - label: displayName, -})); +export const anthropicModelOptions = Object.entries(anthropicModels) + .map(([id, model]) => ({ + value: id, + label: model.displayName, + legacy: 'legacy' in model ? model.legacy : false, + })) + .sort((a, b) => Number(a.legacy) - Number(b.legacy) || a.label.localeCompare(b.label)); export type Claude3ChatMessage = { role: 'user' | 'assistant'; diff --git a/packages/core/src/plugins/google/google.ts b/packages/core/src/plugins/google/google.ts index 7afc7c51f..618a9df93 100644 --- a/packages/core/src/plugins/google/google.ts +++ b/packages/core/src/plugins/google/google.ts @@ -7,6 +7,9 @@ export type GoogleModelDeprecated = { completion: number; }; displayName: string; + + /** If true, this model is deprecated/legacy and will be sorted to the bottom of dropdowns. */ + legacy?: boolean; }; export const googleModelsDeprecated = { @@ -17,6 +20,7 @@ export const googleModelsDeprecated = { completion: NaN, }, displayName: 'Gemini Pro', + legacy: true, }, 'gemini-pro-vision': { maxTokens: 16384, @@ -25,11 +29,24 @@ export const googleModelsDeprecated = { completion: NaN, }, displayName: 'Gemini Pro Vision', + legacy: true, }, } satisfies Record; export type GoogleModelsDeprecated = keyof typeof googleModelsDeprecated; +export type GenerativeAiGoogleModelDef = { + maxTokens: number; + cost: { + prompt: number; + completion: number; + }; + displayName: string; + + /** If true, this model is deprecated/legacy and will be sorted to the bottom of dropdowns. */ + legacy?: boolean; +}; + export const generativeAiGoogleModels = { 'gemini-2.5-pro': { maxTokens: 1048576, @@ -71,6 +88,30 @@ export const generativeAiGoogleModels = { }, displayName: 'Gemini 2.0 Flash Lite', }, + 'gemini-3-1-pro-preview': { + maxTokens: 1048576, + cost: { + prompt: 2.5 / 1000, + completion: 15 / 1000, + }, + displayName: 'Gemini 3.1 Pro (Preview)', + }, + 'gemini-3-pro-preview': { + maxTokens: 1048576, + cost: { + prompt: 2.0 / 1000, + completion: 12 / 1000, + }, + displayName: 'Gemini 3 Pro (Preview)', + }, + 'gemini-3-flash-preview': { + maxTokens: 1048576, + cost: { + prompt: 0.5 / 1000, + completion: 3 / 1000, + }, + displayName: 'Gemini 3 Flash (Preview)', + }, 'gemini-1.5-pro': { maxTokens: 2097152, cost: { @@ -78,6 +119,7 @@ export const generativeAiGoogleModels = { completion: 0, // It's per-character }, displayName: 'Gemini 1.5 Pro', + legacy: true, }, 'gemini-1.5-flash': { maxTokens: 1048576, @@ -86,8 +128,9 @@ export const generativeAiGoogleModels = { completion: 0, // It's per-character }, displayName: 'Gemini 1.5 Flash', - } -}; + legacy: true, + }, +} satisfies Record; export type GenerativeAiGoogleModel = keyof typeof generativeAiGoogleModels; @@ -96,10 +139,13 @@ export const googleModelOptionsDeprecated = Object.entries(googleModelsDeprecate label: displayName, })); -export const generativeAiOptions = Object.entries(generativeAiGoogleModels).map(([id, { displayName }]) => ({ - value: id, - label: displayName, -})); +export const generativeAiOptions = Object.entries(generativeAiGoogleModels) + .map(([id, model]) => ({ + value: id, + label: model.displayName, + legacy: 'legacy' in model ? model.legacy : false, + })) + .sort((a, b) => Number(a.legacy) - Number(b.legacy) || a.label.localeCompare(b.label)); export type ChatCompletionOptions = { project: string; diff --git a/packages/core/src/utils/openai.ts b/packages/core/src/utils/openai.ts index add544a0e..da2b17981 100644 --- a/packages/core/src/utils/openai.ts +++ b/packages/core/src/utils/openai.ts @@ -18,6 +18,9 @@ export type OpenAIModel = { supported?: { parallelFunctionCalls: boolean; }; + + /** If true, this model is deprecated/legacy and will be sorted to the bottom of dropdowns. */ + legacy?: boolean; }; export const defaultOpenaiSupported: NonNullable = { @@ -39,7 +42,7 @@ export const openaiModels = { 'gpt-5-mini': { maxTokens: 400000, cost: { - prompt: 0.25 - 6, + prompt: 0.25e-6, completion: 2e-6, }, displayName: 'GPT-5 mini', @@ -65,6 +68,7 @@ export const openaiModels = { completion: 0.015, }, displayName: 'GPT-4o', + legacy: true, }, 'gpt-4o-mini': { maxTokens: 128000, @@ -73,6 +77,7 @@ export const openaiModels = { completion: 0.00075, }, displayName: 'GPT-4o mini', + legacy: true, }, o1: { maxTokens: 128000, @@ -84,6 +89,7 @@ export const openaiModels = { supported: { parallelFunctionCalls: false, }, + legacy: true, }, 'o1-mini': { maxTokens: 128000, @@ -95,6 +101,7 @@ export const openaiModels = { supported: { parallelFunctionCalls: false, }, + legacy: true, }, 'o3-mini': { maxTokens: 200000, @@ -116,6 +123,7 @@ export const openaiModels = { audioCompletion: 0.08, }, displayName: 'GPT-4o Audio (Preview)', + legacy: true, }, 'gpt-4.1': { maxTokens: 1_047_576, @@ -141,6 +149,66 @@ export const openaiModels = { }, displayName: 'o4-mini', }, + 'gpt-5.1': { + maxTokens: 400000, + cost: { + prompt: 1.25e-6, + completion: 10e-6, + }, + displayName: 'GPT-5.1', + supported: { + parallelFunctionCalls: true, + }, + }, + 'gpt-5.2': { + maxTokens: 400000, + cost: { + prompt: 1.75e-6, + completion: 14e-6, + }, + displayName: 'GPT-5.2', + supported: { + parallelFunctionCalls: true, + }, + }, + 'gpt-5.5': { + maxTokens: 400000, + cost: { + prompt: 2e-6, + completion: 16e-6, + }, + displayName: 'GPT-5.5', + supported: { + parallelFunctionCalls: true, + }, + }, + 'gpt-4.1-mini': { + maxTokens: 1_047_576, + cost: { + prompt: 0.4e-6, + completion: 1.6e-6, + }, + displayName: 'GPT-4.1 mini', + }, + 'gpt-4.1-nano': { + maxTokens: 1_047_576, + cost: { + prompt: 0.1e-6, + completion: 0.4e-6, + }, + displayName: 'GPT-4.1 nano', + }, + 'o3-pro': { + maxTokens: 200_000, + cost: { + prompt: 20e-6, + completion: 80e-6, + }, + displayName: 'o3-pro', + supported: { + parallelFunctionCalls: false, + }, + }, 'local-model': { maxTokens: Number.MAX_SAFE_INTEGER, cost: { @@ -152,11 +220,12 @@ export const openaiModels = { } satisfies Record; export const openAiModelOptions = orderBy( - Object.entries(openaiModels).map(([id, { displayName }]) => ({ + Object.entries(openaiModels).map(([id, model]) => ({ value: id, - label: displayName, + label: model.displayName, + legacy: 'legacy' in model ? model.legacy : false, })), - 'label', + ['legacy', 'label'], ); export class OpenAIError extends Error { diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts new file mode 100644 index 000000000..9f993f405 --- /dev/null +++ b/packages/core/test/models.test.ts @@ -0,0 +1,115 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { openaiModels, openAiModelOptions } from '../src/utils/openai.js'; +import { anthropicModels, anthropicModelOptions } from '../src/plugins/anthropic/anthropic.js'; +import { + generativeAiGoogleModels, + generativeAiOptions, + googleModelsDeprecated, +} from '../src/plugins/google/google.js'; + +describe('OpenAI models', () => { + it('every model has required fields with valid values', () => { + for (const [id, model] of Object.entries(openaiModels)) { + assert.ok(model.displayName, `${id} missing displayName`); + assert.ok(typeof model.maxTokens === 'number' && model.maxTokens > 0, `${id} has invalid maxTokens`); + assert.ok(typeof model.cost.prompt === 'number', `${id} has invalid prompt cost`); + assert.ok(typeof model.cost.completion === 'number', `${id} has invalid completion cost`); + assert.ok(model.cost.prompt >= 0, `${id} has negative prompt cost`); + assert.ok(model.cost.completion >= 0, `${id} has negative completion cost`); + } + }); + + it('has no duplicate model IDs', () => { + const ids = Object.keys(openaiModels); + const unique = new Set(ids); + assert.strictEqual(ids.length, unique.size, 'Duplicate OpenAI model IDs found'); + }); + + it('dropdown options have no duplicates', () => { + const values = openAiModelOptions.map((o) => o.value); + assert.strictEqual(values.length, new Set(values).size, 'Duplicate dropdown values'); + }); + + it('legacy models are sorted to the bottom of dropdown', () => { + const legacyStart = openAiModelOptions.findIndex((o) => o.legacy); + if (legacyStart === -1) return; // no legacy models, nothing to check + const allAfterLegacyStart = openAiModelOptions.slice(legacyStart); + assert.ok( + allAfterLegacyStart.every((o) => o.legacy), + 'Non-legacy model found after first legacy model in dropdown', + ); + }); + + it('prompt cost is not accidentally a subtraction (regression for 0.25-6 vs 0.25e-6)', () => { + for (const [id, model] of Object.entries(openaiModels)) { + if (id === 'local-model') continue; + assert.ok(model.cost.prompt < 1, `${id} prompt cost ${model.cost.prompt} looks too high — possible math error`); + assert.ok( + model.cost.completion < 1, + `${id} completion cost ${model.cost.completion} looks too high — possible math error`, + ); + } + }); +}); + +describe('Anthropic models', () => { + it('every model has required fields with valid values', () => { + for (const [id, model] of Object.entries(anthropicModels)) { + assert.ok(model.displayName, `${id} missing displayName`); + assert.ok(typeof model.maxTokens === 'number' && model.maxTokens > 0, `${id} has invalid maxTokens`); + assert.ok(typeof model.cost.prompt === 'number', `${id} has invalid prompt cost`); + assert.ok(typeof model.cost.completion === 'number', `${id} has invalid completion cost`); + assert.ok(model.cost.prompt >= 0, `${id} has negative prompt cost`); + assert.ok(model.cost.completion >= 0, `${id} has negative completion cost`); + } + }); + + it('has no duplicate model IDs', () => { + const ids = Object.keys(anthropicModels); + assert.strictEqual(ids.length, new Set(ids).size, 'Duplicate Anthropic model IDs found'); + }); + + it('legacy models are sorted to the bottom of dropdown', () => { + const legacyStart = anthropicModelOptions.findIndex((o) => o.legacy); + if (legacyStart === -1) return; + const allAfterLegacyStart = anthropicModelOptions.slice(legacyStart); + assert.ok( + allAfterLegacyStart.every((o) => o.legacy), + 'Non-legacy model found after first legacy model in dropdown', + ); + }); +}); + +describe('Google Gemini models', () => { + it('every generative AI model has required fields with valid values', () => { + for (const [id, model] of Object.entries(generativeAiGoogleModels)) { + assert.ok(model.displayName, `${id} missing displayName`); + assert.ok(typeof model.maxTokens === 'number' && model.maxTokens > 0, `${id} has invalid maxTokens`); + assert.ok(typeof model.cost.prompt === 'number', `${id} has invalid prompt cost`); + assert.ok(typeof model.cost.completion === 'number', `${id} has invalid completion cost`); + } + }); + + it('deprecated models still have required fields', () => { + for (const [id, model] of Object.entries(googleModelsDeprecated)) { + assert.ok(model.displayName, `${id} missing displayName`); + assert.ok(typeof model.maxTokens === 'number' && model.maxTokens > 0, `${id} has invalid maxTokens`); + } + }); + + it('has no duplicate model IDs', () => { + const ids = Object.keys(generativeAiGoogleModels); + assert.strictEqual(ids.length, new Set(ids).size, 'Duplicate Google model IDs found'); + }); + + it('legacy models are sorted to the bottom of dropdown', () => { + const legacyStart = generativeAiOptions.findIndex((o) => o.legacy); + if (legacyStart === -1) return; + const allAfterLegacyStart = generativeAiOptions.slice(legacyStart); + assert.ok( + allAfterLegacyStart.every((o) => o.legacy), + 'Non-legacy model found after first legacy model in dropdown', + ); + }); +});