diff --git a/tests/unit/config/config-predicates.test.mjs b/tests/unit/config/config-predicates.test.mjs index 92492161..710814cc 100644 --- a/tests/unit/config/config-predicates.test.mjs +++ b/tests/unit/config/config-predicates.test.mjs @@ -3,6 +3,11 @@ import { afterEach, beforeEach, describe, test } from 'node:test' import { getNavigatorLanguage, getPreferredLanguageKey, + chatgptApiModelKeys, + gptApiModelKeys, + claudeApiModelKeys, + openRouterApiModelKeys, + aimlApiModelKeys, isUsingAimlApiModel, isUsingAzureOpenAiApiModel, isUsingBingWebModel, @@ -19,9 +24,28 @@ import { isUsingMultiModeModel, isUsingOllamaApiModel, isUsingOpenAiApiModel, + isUsingGptCompletionApiModel, isUsingOpenRouterApiModel, } from '../../../src/config/index.mjs' +const representativeChatgptApiModelNames = [ + 'chatgptApi4oMini', + 'chatgptApi5', + 'chatgptApi5_1', + 'chatgptApi5_2', + 'chatgptApi5_4', +] +const representativeGptCompletionApiModelNames = ['gptApiInstruct'] +const representativeClaudeApiModelNames = ['claude37SonnetApi', 'claudeOpus4Api'] +const representativeOpenRouterApiModelNames = [ + 'openRouter_anthropic_claude_sonnet4', + 'openRouter_openai_o3', +] +const representativeAimlApiModelNames = [ + 'aiml_claude_3_7_sonnet_20250219', + 'aiml_openai_o3_2025_04_16', +] + const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator') const restoreNavigator = () => { @@ -63,24 +87,51 @@ test('getNavigatorLanguage treats zh-Hant locale as zhHant', () => { assert.equal(getNavigatorLanguage(), 'zhHant') }) -test('isUsingChatgptApiModel detects chatgpt API models and excludes custom model', () => { - assert.equal(isUsingChatgptApiModel({ modelName: 'chatgptApi4oMini' }), true) - assert.equal(isUsingChatgptApiModel({ modelName: 'chatgptApi5' }), true) - assert.equal(isUsingChatgptApiModel({ modelName: 'chatgptApi5_1' }), true) - assert.equal(isUsingChatgptApiModel({ modelName: 'chatgptApi5_2' }), true) - assert.equal(isUsingChatgptApiModel({ modelName: 'chatgptApi5_4' }), true) +test('isUsingChatgptApiModel matches representative chatgpt API keys', () => { + for (const modelName of representativeChatgptApiModelNames) { + assert.equal(isUsingChatgptApiModel({ modelName }), true) + } assert.equal(isUsingChatgptApiModel({ modelName: 'customModel' }), false) }) -test('isUsingOpenAiApiModel accepts both chat and completion API model groups', () => { - assert.equal(isUsingOpenAiApiModel({ modelName: 'chatgptApi4oMini' }), true) - assert.equal(isUsingOpenAiApiModel({ modelName: 'gptApiInstruct' }), true) +test('isUsingChatgptApiModel accepts exported chatgpt API model keys', () => { + for (const modelName of chatgptApiModelKeys) { + assert.equal(isUsingChatgptApiModel({ modelName }), true) + } }) -test('isUsingOpenAiApiModel excludes custom model', () => { +test('isUsingOpenAiApiModel matches representative chat and completion API keys', () => { + for (const modelName of representativeChatgptApiModelNames) { + assert.equal(isUsingOpenAiApiModel({ modelName }), true) + } + for (const modelName of representativeGptCompletionApiModelNames) { + assert.equal(isUsingOpenAiApiModel({ modelName }), true) + } assert.equal(isUsingOpenAiApiModel({ modelName: 'customModel' }), false) }) +test('isUsingOpenAiApiModel accepts exported chat and completion API model groups', () => { + for (const modelName of chatgptApiModelKeys) { + assert.equal(isUsingOpenAiApiModel({ modelName }), true) + } + for (const modelName of gptApiModelKeys) { + assert.equal(isUsingOpenAiApiModel({ modelName }), true) + } +}) + +test('isUsingGptCompletionApiModel matches representative completion API keys', () => { + for (const modelName of representativeGptCompletionApiModelNames) { + assert.equal(isUsingGptCompletionApiModel({ modelName }), true) + } + assert.equal(isUsingGptCompletionApiModel({ modelName: 'chatgptApi4oMini' }), false) +}) + +test('isUsingGptCompletionApiModel accepts exported completion API model keys', () => { + for (const modelName of gptApiModelKeys) { + assert.equal(isUsingGptCompletionApiModel({ modelName }), true) + } +}) + test('isUsingCustomModel works with modelName and apiMode forms', () => { assert.equal(isUsingCustomModel({ modelName: 'customModel' }), true) @@ -116,12 +167,19 @@ test('isUsingGeminiWebModel detects bard/gemini web models', () => { assert.equal(isUsingGeminiWebModel({ modelName: 'chatgptFree35' }), false) }) -test('isUsingClaudeApiModel detects Claude API models', () => { - assert.equal(isUsingClaudeApiModel({ modelName: 'claude37SonnetApi' }), true) - assert.equal(isUsingClaudeApiModel({ modelName: 'claudeOpus4Api' }), true) +test('isUsingClaudeApiModel matches representative Claude API keys', () => { + for (const modelName of representativeClaudeApiModelNames) { + assert.equal(isUsingClaudeApiModel({ modelName }), true) + } assert.equal(isUsingClaudeApiModel({ modelName: 'claude2WebFree' }), false) }) +test('isUsingClaudeApiModel accepts exported Claude API model keys', () => { + for (const modelName of claudeApiModelKeys) { + assert.equal(isUsingClaudeApiModel({ modelName }), true) + } +}) + test('isUsingMoonshotApiModel detects moonshot API models', () => { assert.equal(isUsingMoonshotApiModel({ modelName: 'moonshot_v1_8k' }), true) assert.equal(isUsingMoonshotApiModel({ modelName: 'moonshot_k2' }), true) @@ -134,20 +192,32 @@ test('isUsingDeepSeekApiModel detects DeepSeek models', () => { assert.equal(isUsingDeepSeekApiModel({ modelName: 'chatgptApi4oMini' }), false) }) -test('isUsingOpenRouterApiModel detects OpenRouter models', () => { - assert.equal( - isUsingOpenRouterApiModel({ modelName: 'openRouter_anthropic_claude_sonnet4' }), - true, - ) - assert.equal(isUsingOpenRouterApiModel({ modelName: 'openRouter_openai_o3' }), true) +test('isUsingOpenRouterApiModel matches representative OpenRouter API keys', () => { + for (const modelName of representativeOpenRouterApiModelNames) { + assert.equal(isUsingOpenRouterApiModel({ modelName }), true) + } assert.equal(isUsingOpenRouterApiModel({ modelName: 'chatgptApi4oMini' }), false) }) -test('isUsingAimlApiModel detects AI/ML models', () => { - assert.equal(isUsingAimlApiModel({ modelName: 'aiml_claude_3_7_sonnet_20250219' }), true) +test('isUsingOpenRouterApiModel accepts exported OpenRouter API model keys', () => { + for (const modelName of openRouterApiModelKeys) { + assert.equal(isUsingOpenRouterApiModel({ modelName }), true) + } +}) + +test('isUsingAimlApiModel matches representative AI/ML API keys', () => { + for (const modelName of representativeAimlApiModelNames) { + assert.equal(isUsingAimlApiModel({ modelName }), true) + } assert.equal(isUsingAimlApiModel({ modelName: 'chatgptApi4oMini' }), false) }) +test('isUsingAimlApiModel accepts exported AI/ML model keys', () => { + for (const modelName of aimlApiModelKeys) { + assert.equal(isUsingAimlApiModel({ modelName }), true) + } +}) + test('isUsingChatGLMApiModel detects ChatGLM models', () => { assert.equal(isUsingChatGLMApiModel({ modelName: 'chatglmTurbo' }), true) assert.equal(isUsingChatGLMApiModel({ modelName: 'chatglm4' }), true) diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs index 76b59edd..87834819 100644 --- a/tests/unit/services/apis/openai-api-compat.test.mjs +++ b/tests/unit/services/apis/openai-api-compat.test.mjs @@ -1,12 +1,26 @@ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' import { + generateAnswersWithChatgptApi, generateAnswersWithChatgptApiCompat, generateAnswersWithGptCompletionApi, } from '../../../../src/services/apis/openai-api.mjs' import { createFakePort } from '../../helpers/port.mjs' import { createMockSseResponse } from '../../helpers/sse-response.mjs' +const gpt5LatestCompatModelNames = [ + 'chatgptApi-gpt-5-chat-latest', + 'chatgptApi-gpt-5.1-chat-latest', + 'chatgptApi-gpt-5.2-chat-latest', + 'chatgptApi-gpt-5.3-chat-latest', +] +const gpt5LatestMappedModels = [ + ['chatgptApi5Latest', 'gpt-5-chat-latest'], + ['chatgptApi5_1Latest', 'gpt-5.1-chat-latest'], + ['chatgptApi5_2Latest', 'gpt-5.2-chat-latest'], + ['chatgptApi5_3Latest', 'gpt-5.3-chat-latest'], +] + const setStorage = (values) => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) } @@ -79,44 +93,163 @@ test('generateAnswersWithChatgptApiCompat sends expected request and aggregates assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hello' }) }) -test('generateAnswersWithChatgptApiCompat uses max_completion_tokens for OpenAI gpt-5 models', async (t) => { +test('generateAnswersWithChatgptApiCompat uses max_completion_tokens for OpenAI latest gpt-5 compat models', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ maxConversationContextLength: 3, maxResponseTokenLength: 321, temperature: 0.2, }) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + for (const modelName of gpt5LatestCompatModelNames) { + capturedInit = undefined + const session = { + modelName, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + await generateAnswersWithChatgptApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + {}, + 'openai', + ) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.max_completion_tokens, 321) + assert.equal(Object.hasOwn(body, 'max_tokens'), false) + } +}) + +test('generateAnswersWithChatgptApiCompat uses latest mapped gpt-5 API model values', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 111, + temperature: 0.2, + }) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + for (const [modelName, expectedModel] of gpt5LatestMappedModels) { + capturedInit = undefined + const session = { + modelName, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + await generateAnswersWithChatgptApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + {}, + 'openai', + ) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.model, expectedModel) + assert.equal(body.max_completion_tokens, 111) + assert.equal(Object.hasOwn(body, 'max_tokens'), false) + } +}) + +test('generateAnswersWithChatgptApi uses OpenAI token params for a latest mapped gpt-5 model', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + customOpenAiApiUrl: 'https://api.openai.example.com', + maxConversationContextLength: 3, + maxResponseTokenLength: 222, + temperature: 0.2, + }) const session = { - modelName: 'chatgptApi-gpt-5.1-chat-latest', + modelName: 'chatgptApi5_2Latest', conversationRecords: [], isRetry: false, } const port = createFakePort() + let capturedInput let capturedInit - t.mock.method(globalThis, 'fetch', async (_input, init) => { + t.mock.method(globalThis, 'fetch', async (input, init) => { + capturedInput = input capturedInit = init return createMockSseResponse([ 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', ]) }) - await generateAnswersWithChatgptApiCompat( - 'https://api.example.com/v1', - port, - 'CurrentQ', - session, - 'sk-test', - {}, - 'openai', - ) + await generateAnswersWithChatgptApi(port, 'CurrentQ', session, 'sk-test') const body = JSON.parse(capturedInit.body) - assert.equal(body.max_completion_tokens, 321) + assert.equal(capturedInput, 'https://api.openai.example.com/v1/chat/completions') + assert.equal(body.model, 'gpt-5.2-chat-latest') + assert.equal(body.max_completion_tokens, 222) assert.equal(Object.hasOwn(body, 'max_tokens'), false) }) +test('generateAnswersWithChatgptApiCompat keeps max_tokens for latest mapped gpt-5 models in compat provider', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 223, + temperature: 0.2, + }) + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + for (const [modelName, expectedModel] of gpt5LatestMappedModels) { + capturedInit = undefined + const session = { + modelName, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + await generateAnswersWithChatgptApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + {}, + 'compat', + ) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.model, expectedModel) + assert.equal(body.max_tokens, 223) + assert.equal(Object.hasOwn(body, 'max_completion_tokens'), false) + } +}) + test('generateAnswersWithChatgptApiCompat removes conflicting token key from extraBody', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ @@ -165,14 +298,6 @@ test('generateAnswersWithChatgptApiCompat removes max_tokens from extraBody for maxResponseTokenLength: 500, temperature: 0.2, }) - - const session = { - modelName: 'chatgptApi-gpt-5.1-chat-latest', - conversationRecords: [], - isRetry: false, - } - const port = createFakePort() - let capturedInit t.mock.method(globalThis, 'fetch', async (_input, init) => { capturedInit = init @@ -181,23 +306,33 @@ test('generateAnswersWithChatgptApiCompat removes max_tokens from extraBody for ]) }) - await generateAnswersWithChatgptApiCompat( - 'https://api.example.com/v1', - port, - 'CurrentQ', - session, - 'sk-test', - { - max_tokens: 999, - top_p: 0.8, - }, - 'openai', - ) + for (const modelName of gpt5LatestCompatModelNames) { + capturedInit = undefined + const session = { + modelName, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() - const body = JSON.parse(capturedInit.body) - assert.equal(body.max_completion_tokens, 500) - assert.equal(Object.hasOwn(body, 'max_tokens'), false) - assert.equal(body.top_p, 0.8) + await generateAnswersWithChatgptApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + { + max_tokens: 999, + top_p: 0.8, + }, + 'openai', + ) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.max_completion_tokens, 500) + assert.equal(Object.hasOwn(body, 'max_tokens'), false) + assert.equal(body.top_p, 0.8) + } }) test('generateAnswersWithChatgptApiCompat allows max_tokens override for compat provider', async (t) => { @@ -248,14 +383,6 @@ test('generateAnswersWithChatgptApiCompat allows max_completion_tokens override maxResponseTokenLength: 400, temperature: 0.2, }) - - const session = { - modelName: 'chatgptApi-gpt-5.1-chat-latest', - conversationRecords: [], - isRetry: false, - } - const port = createFakePort() - let capturedInit t.mock.method(globalThis, 'fetch', async (_input, init) => { capturedInit = init @@ -264,23 +391,33 @@ test('generateAnswersWithChatgptApiCompat allows max_completion_tokens override ]) }) - await generateAnswersWithChatgptApiCompat( - 'https://api.example.com/v1', - port, - 'CurrentQ', - session, - 'sk-test', - { - max_completion_tokens: 333, - top_p: 0.65, - }, - 'openai', - ) + for (const modelName of gpt5LatestCompatModelNames) { + capturedInit = undefined + const session = { + modelName, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() - const body = JSON.parse(capturedInit.body) - assert.equal(body.max_completion_tokens, 333) - assert.equal(Object.hasOwn(body, 'max_tokens'), false) - assert.equal(body.top_p, 0.65) + await generateAnswersWithChatgptApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + { + max_completion_tokens: 333, + top_p: 0.65, + }, + 'openai', + ) + + const body = JSON.parse(capturedInit.body) + assert.equal(body.max_completion_tokens, 333) + assert.equal(Object.hasOwn(body, 'max_tokens'), false) + assert.equal(body.top_p, 0.65) + } }) test('generateAnswersWithChatgptApiCompat throws on non-ok response with JSON error body', async (t) => { diff --git a/tests/unit/services/apis/openai-token-params.test.mjs b/tests/unit/services/apis/openai-token-params.test.mjs index 8f7e7c43..53913595 100644 --- a/tests/unit/services/apis/openai-token-params.test.mjs +++ b/tests/unit/services/apis/openai-token-params.test.mjs @@ -14,6 +14,23 @@ test('uses max_tokens for provider-prefixed gpt-5.x model names', () => { }) }) +test('uses max_completion_tokens for recent gpt-5.x model names', () => { + const models = [ + 'gpt-5.1', + 'gpt-5.1-chat-latest', + 'gpt-5.2', + 'gpt-5.2-chat-latest', + 'gpt-5.3', + 'gpt-5.3-chat-latest', + ] + + for (const model of models) { + assert.deepEqual(getChatCompletionsTokenParams('openai', model, 333), { + max_completion_tokens: 333, + }) + } +}) + test('uses max_completion_tokens for gpt-5 baseline model name', () => { assert.deepEqual(getChatCompletionsTokenParams('openai', 'gpt-5', 1536), { max_completion_tokens: 1536, @@ -39,7 +56,7 @@ test('uses max_tokens for empty model values', () => { }) test('uses max_tokens for non OpenAI providers even with gpt-5 models', () => { - assert.deepEqual(getChatCompletionsTokenParams('some-proxy-provider', 'openai/gpt-5.2', 257), { + assert.deepEqual(getChatCompletionsTokenParams('some-proxy-provider', 'gpt-5.2', 257), { max_tokens: 257, }) }) diff --git a/tests/unit/utils/model-name-convert.test.mjs b/tests/unit/utils/model-name-convert.test.mjs index da950e1a..8601418a 100644 --- a/tests/unit/utils/model-name-convert.test.mjs +++ b/tests/unit/utils/model-name-convert.test.mjs @@ -10,6 +10,7 @@ import { modelNameToApiMode, modelNameToCustomPart, modelNameToDesc, + modelNameToPresetPart, modelNameToValue, getModelValue, } from '../../../src/utils/model-name-convert.mjs' @@ -148,6 +149,19 @@ test('modelNameToCustomPart returns modelName when not custom', () => { assert.equal(modelNameToCustomPart('chatgptFree35'), 'chatgptFree35') }) +test('modelNameToPresetPart returns preset segment for custom names', () => { + assert.equal( + modelNameToPresetPart('azureOpenAiApiModelKeys-my-deploy'), + 'azureOpenAiApiModelKeys', + ) + assert.equal(modelNameToPresetPart('chatgptApi5_3Latest-chatgpt'), 'chatgptApi5_3Latest') +}) + +test('modelNameToCustomPart keeps entire suffix for multi-hyphen custom names', () => { + assert.equal(modelNameToCustomPart('azureOpenAiApiModelKeys-my-eu-1'), 'my-eu-1') + assert.equal(modelNameToCustomPart('chatgptApi5_3Latest-blue-green'), 'blue-green') +}) + test('apiModeToModelName uses groupName prefix when itemName is custom', () => { const apiMode = { groupName: 'customApiModelKeys', @@ -231,6 +245,13 @@ test('modelNameToValue returns value for known model', () => { assert.equal(modelNameToValue('chatgptFree35'), 'auto') }) +test('modelNameToValue returns endpoint for latest chatgptApi models', () => { + assert.equal(modelNameToValue('chatgptApi5Latest'), 'gpt-5-chat-latest') + assert.equal(modelNameToValue('chatgptApi5_1Latest'), 'gpt-5.1-chat-latest') + assert.equal(modelNameToValue('chatgptApi5_2Latest'), 'gpt-5.2-chat-latest') + assert.equal(modelNameToValue('chatgptApi5_3Latest'), 'gpt-5.3-chat-latest') +}) + test('modelNameToValue returns custom part for unknown model', () => { assert.equal(modelNameToValue('bingFree4-fast'), 'fast') }) @@ -249,6 +270,20 @@ test('getModelValue uses apiMode when present', () => { assert.equal(value, '') }) +test('getModelValue uses custom segment for always-custom groups in apiMode', () => { + const apiMode = { + groupName: 'azureOpenAiApiModelKeys', + itemName: 'azureOpenAi', + isCustom: true, + customName: 'deployment-east-1', + customUrl: '', + apiKey: '', + active: true, + } + const value = getModelValue({ apiMode }) + assert.equal(value, 'deployment-east-1') +}) + test('getModelValue uses modelName when apiMode is absent', () => { const value = getModelValue({ modelName: 'chatgptFree35' }) assert.equal(value, 'auto')