diff --git a/src/config/index.mjs b/src/config/index.mjs index 0a043c38c..86e364c07 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -179,7 +179,17 @@ export const openRouterApiModelKeys = [ 'openRouter_deepseek_v4_flash', ] export const aimlApiModelKeys = [ - 'aiml_claude_sonnet_4_6_20260218', + 'aiml_openai_gpt_6_1_sol', + 'aiml_openai_gpt_6_astra', + 'aiml_openai_gpt_6_luna', + 'aiml_claude_fable_5_1', + 'aiml_claude_opus_5_5', + 'aiml_claude_sonnet_5_5', + 'aiml_claude_haiku_5_5', + 'aiml_google_gemini_4_argon', + 'aiml_google_gemini_3_8_flash', + 'aiml_moonshot_kimi_k3', + 'aiml_claude_sonnet_4_6', 'aiml_openai_gpt_5_2', 'aiml_google_gemini_3_flash_preview', 'aiml_google_gemini_3_1_pro_preview', @@ -643,69 +653,109 @@ export const Models = { value: 'openai/gpt-4.1-mini', desc: 'OpenRouter (GPT-4.1 Mini)', }, + aiml_openai_gpt_6_1_sol: { + value: 'openai/gpt-6.1-sol', + desc: 'AI/ML (GPT-6.1 Sol)', + }, + aiml_openai_gpt_6_astra: { + value: 'openai/gpt-6-astra', + desc: 'AI/ML (GPT-6 Astra)', + }, + aiml_openai_gpt_6_luna: { + value: 'openai/gpt-6-luna', + desc: 'AI/ML (GPT-6 Luna)', + }, aiml_openai_gpt_5_5: { value: 'openai/gpt-5-5', - desc: 'AIML (GPT-5.5)', + desc: 'AI/ML (GPT-5.5)', }, aiml_openai_gpt_5_4: { value: 'openai/gpt-5-4', - desc: 'AIML (GPT-5.4)', + desc: 'AI/ML (GPT-5.4)', }, aiml_openai_gpt_5_1: { value: 'openai/gpt-5-1', - desc: 'AIML (GPT-5.1)', + desc: 'AI/ML (GPT-5.1)', }, aiml_openai_gpt_5: { value: 'openai/gpt-5', - desc: 'AIML (GPT-5)', + desc: 'AI/ML (GPT-5)', + }, + aiml_claude_fable_5_1: { + value: 'anthropic/claude-fable-5.1', + desc: 'AI/ML (Claude Fable 5.1)', + }, + aiml_claude_opus_5_5: { + value: 'anthropic/claude-opus-5.5', + desc: 'AI/ML (Claude Opus 5.5)', + }, + aiml_claude_sonnet_5_5: { + value: 'anthropic/claude-sonnet-5.5', + desc: 'AI/ML (Claude Sonnet 5.5)', + }, + aiml_claude_haiku_5_5: { + value: 'anthropic/claude-haiku-5.5', + desc: 'AI/ML (Claude Haiku 5.5)', }, aiml_claude_opus_4_8: { value: 'anthropic/claude-opus-4-8', - desc: 'AIML (Claude Opus 4.8)', + desc: 'AI/ML (Claude Opus 4.8)', }, aiml_claude_haiku_4_5: { value: 'anthropic/claude-haiku-4.5', - desc: 'AIML (Claude Haiku 4.5)', + desc: 'AI/ML (Claude Haiku 4.5)', }, - aiml_claude_sonnet_4_6_20260218: { - value: 'anthropic/claude-sonnet-4-6-20260218', - desc: 'AIML (Claude Sonnet 4.6)', + aiml_claude_sonnet_4_6: { + value: 'anthropic/claude-sonnet-4.6', + desc: 'AI/ML (Claude Sonnet 4.6)', }, aiml_openai_gpt_5_2: { value: 'openai/gpt-5-2', - desc: 'AIML (GPT-5.2)', + desc: 'AI/ML (GPT-5.2)', + }, + aiml_google_gemini_4_argon: { + value: 'google/gemini-4-argon', + desc: 'AI/ML (Gemini 4 Argon)', + }, + aiml_google_gemini_3_8_flash: { + value: 'google/gemini-3.8-flash', + desc: 'AI/ML (Gemini 3.8 Flash)', }, aiml_google_gemini_3_5_flash: { value: 'google/gemini-3-5-flash', - desc: 'AIML (Gemini 3.5 Flash)', + desc: 'AI/ML (Gemini 3.5 Flash)', }, aiml_google_gemini_3_flash_preview: { value: 'google/gemini-3-flash-preview', - desc: 'AIML (Gemini 3 Flash)', + desc: 'AI/ML (Gemini 3 Flash)', }, aiml_google_gemini_3_1_pro_preview: { - value: 'google/gemini-3-1-pro-preview', - desc: 'AIML (Gemini 3.1 Pro)', + value: 'google/gemini-3.1-pro-preview', + desc: 'AI/ML (Gemini 3.1 Pro)', }, aiml_google_gemini_2_5_pro: { value: 'google/gemini-2.5-pro', - desc: 'AIML (Gemini 2.5 Pro)', + desc: 'AI/ML (Gemini 2.5 Pro)', }, aiml_google_gemini_2_5_flash: { value: 'google/gemini-2.5-flash', - desc: 'AIML (Gemini 2.5 Flash)', + desc: 'AI/ML (Gemini 2.5 Flash)', + }, + aiml_moonshot_kimi_k3: { + value: 'moonshot/kimi-k3', + desc: 'AI/ML (Kimi K3)', }, aiml_moonshot_kimi_k2_5: { - value: 'moonshot/kimi-k2-5', - desc: 'AIML (Kimi K2.5)', + value: 'moonshotai/kimi-k2.5', + desc: 'AI/ML (Kimi K2.5)', }, aiml_deepseek_v4_pro: { value: 'deepseek/deepseek-v4-pro', - desc: 'AIML (DeepSeek V4 Pro)', + desc: 'AI/ML (DeepSeek V4 Pro)', }, aiml_deepseek_v4_flash: { value: 'deepseek/deepseek-v4-flash', - desc: 'AIML (DeepSeek V4 Flash)', + desc: 'AI/ML (DeepSeek V4 Flash)', }, googleGemini3_1Pro: { value: 'gemini-3.1-pro-preview', @@ -796,6 +846,7 @@ export const defaultApiModeIds = [ 'mistralMediumLatest', 'openRouter_auto', 'openRouter_free', + 'aiml_openai_gpt_6_1_sol', 'nvidiaNim_nemotron_3_super', ] diff --git a/src/config/model-key-migrations.mjs b/src/config/model-key-migrations.mjs index c2995495a..97fa066f3 100644 --- a/src/config/model-key-migrations.mjs +++ b/src/config/model-key-migrations.mjs @@ -41,8 +41,9 @@ export const LEGACY_MODEL_KEY_MIGRATIONS = { openRouter_deepseek_deepseek_chat_v3_0324_free: 'openRouter_deepseek_v4_flash', aiml_anthropic_claude_opus_4: 'aiml_claude_opus_4_8', - aiml_anthropic_claude_sonnet_4: 'aiml_claude_sonnet_4_6_20260218', - aiml_claude_3_7_sonnet_20250219: 'aiml_claude_sonnet_4_6_20260218', + aiml_anthropic_claude_sonnet_4: 'aiml_claude_sonnet_4_6', + aiml_claude_3_7_sonnet_20250219: 'aiml_claude_sonnet_4_6', + aiml_claude_sonnet_4_6_20260218: 'aiml_claude_sonnet_4_6', aiml_google_gemini_2_5_pro_preview_05_06: 'aiml_google_gemini_2_5_pro', aiml_google_gemini_2_5_flash_preview: 'aiml_google_gemini_2_5_flash', aiml_openai_o3_2025_04_16: 'aiml_openai_gpt_5_5', @@ -75,7 +76,7 @@ const MODEL_KEY_GROUP_OVERRIDES = { openRouter_deepseek_v4_flash: 'openRouterApiModelKeys', aiml_claude_opus_4_8: 'aimlModelKeys', - aiml_claude_sonnet_4_6_20260218: 'aimlModelKeys', + aiml_claude_sonnet_4_6: 'aimlModelKeys', aiml_google_gemini_2_5_pro: 'aimlModelKeys', aiml_google_gemini_2_5_flash: 'aimlModelKeys', aiml_openai_gpt_5_5: 'aimlModelKeys', diff --git a/src/services/apis/openai-api.mjs b/src/services/apis/openai-api.mjs index 86ae062d8..76a87828c 100644 --- a/src/services/apis/openai-api.mjs +++ b/src/services/apis/openai-api.mjs @@ -43,6 +43,11 @@ const OPENROUTER_ATTRIBUTION_HEADERS = { 'X-OpenRouter-Title': 'ChatGPTBox', 'X-OpenRouter-Categories': 'general-chat,writing-assistant', } +const AIML_API_HOSTNAME = 'api.aimlapi.com' +const AIML_ATTRIBUTION_HEADERS = { + 'X-AIMLAPI-Source': 'agent/chatgptbox', + 'X-AIMLAPI-Partner-ID': 'part_suAsqzeIpC2a5yS8K1leCNtf', +} function hasOpenAICompatibleRuntimeConfig(config) { if (!config || typeof config !== 'object') return false @@ -115,6 +120,22 @@ function getOpenRouterAttributionHeaders(requestUrl) { return OPENROUTER_ATTRIBUTION_HEADERS } +function getAimlAttributionHeaders(requestUrl) { + try { + if (new URL(requestUrl).hostname !== AIML_API_HOSTNAME) return {} + } catch { + return {} + } + return AIML_ATTRIBUTION_HEADERS +} + +function getAppAttributionHeaders(requestUrl) { + return { + ...getOpenRouterAttributionHeaders(requestUrl), + ...getAimlAttributionHeaders(requestUrl), + } +} + function resolveOllamaKeepAliveBaseUrl(request) { const requestUrl = normalizeBaseUrl(request?.requestUrl) if (requestUrl) { @@ -329,7 +350,7 @@ export async function generateAnswersWithOpenAICompatibleApi(port, question, ses apiKey: request.apiKey, config: runtimeConfig, provider: providerRequestShapingId, - extraHeaders: getOpenRouterAttributionHeaders(request.requestUrl), + extraHeaders: getAppAttributionHeaders(request.requestUrl), allowLegacyResponseField: request.provider.allowLegacyResponseField, }) diff --git a/src/services/apis/provider-registry.mjs b/src/services/apis/provider-registry.mjs index 206cc4481..2f01db530 100644 --- a/src/services/apis/provider-registry.mjs +++ b/src/services/apis/provider-registry.mjs @@ -65,7 +65,6 @@ const BUILTIN_PROVIDER_TEMPLATE = [ name: 'AI/ML', baseUrl: 'https://api.aimlapi.com/v1', chatCompletionsPath: '/chat/completions', - completionsPath: '/completions', builtin: true, enabled: true, }, diff --git a/tests/unit/config/config-predicates.test.mjs b/tests/unit/config/config-predicates.test.mjs index 221bb6b0c..11e4a2ab7 100644 --- a/tests/unit/config/config-predicates.test.mjs +++ b/tests/unit/config/config-predicates.test.mjs @@ -67,7 +67,11 @@ const representativeOpenRouterApiModelNames = [ 'openRouter_anthropic_claude_sonnet4_6', 'openRouter_openai_gpt_5_5', ] -const representativeAimlApiModelNames = ['aiml_claude_sonnet_4_6_20260218', 'aiml_openai_gpt_5_5'] +const representativeAimlApiModelNames = [ + 'aiml_claude_sonnet_4_6', + 'aiml_openai_gpt_5_5', + 'aiml_openai_gpt_6_1_sol', +] const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator') @@ -104,6 +108,12 @@ test('Claude defaults use Haiku 5.5 and keep Haiku 4.5 selectable', () => { assert.ok(claudeApiModelKeys.includes('claudeHaiku45Api')) }) +test('AI/ML defaults expose one current AI/ML preset', () => { + const aimlDefaults = defaultApiModeIds.filter((modelName) => aimlApiModelKeys.includes(modelName)) + assert.deepEqual(aimlDefaults, ['aiml_openai_gpt_6_1_sol']) + assert.equal(isUsingAimlApiModel({ modelName: aimlDefaults[0] }), true) +}) + test('getNavigatorLanguage returns zh-Hant for zh-TW style locales', () => { setNavigatorLanguage('zh-TW') assert.equal(getNavigatorLanguage(), 'zh-Hant') @@ -128,6 +138,7 @@ test('canonicalized legacy model keys still match their provider predicates', () ['moonshot_k2', isUsingMoonshotApiModel], ['openRouter_deepseek_deepseek_chat_v3_0324_free', isUsingOpenRouterApiModel], ['aiml_openai_o3_2025_04_16', isUsingAimlApiModel], + ['aiml_claude_sonnet_4_6_20260218', isUsingAimlApiModel], ] for (const [legacyModelKey, predicate] of cases) { @@ -312,6 +323,12 @@ test('aimlApiModelKeys does not expose duplicate picker entries', () => { assert.equal(new Set(signatures).size, signatures.length) }) +test('aimlApiModelKeys use the AI/ML provider label', () => { + for (const modelName of aimlApiModelKeys) { + assert.match(Models[modelName].desc, /^AI\/ML \(.+\)$/, modelName) + } +}) + test('isUsingChatGLMApiModel detects ChatGLM models', () => { assert.equal(isUsingChatGLMApiModel({ modelName: 'chatglm52' }), true) assert.equal(isUsingChatGLMApiModel({ modelName: 'chatglm47' }), true) diff --git a/tests/unit/config/migrate-user-config.test.mjs b/tests/unit/config/migrate-user-config.test.mjs index e4104b02d..58d23da49 100644 --- a/tests/unit/config/migrate-user-config.test.mjs +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -118,6 +118,46 @@ test('getUserConfig migrates legacy model keys in selected config fields', async assert.equal(storage.customApiModes.at(-1).itemName, 'aiml_openai_gpt_5_5') }) +test('getUserConfig moves the dated AI/ML Claude Sonnet 4.6 key to the canonical preset', async () => { + const datedSonnetMode = { + groupName: 'aimlModelKeys', + itemName: 'aiml_claude_sonnet_4_6_20260218', + isCustom: false, + customName: '', + customUrl: '', + apiKey: '', + providerId: '', + active: true, + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + knownApiModeDefaultIds: [...defaultApiModeIds], + modelName: 'aiml_claude_sonnet_4_6_20260218', + apiMode: datedSonnetMode, + customApiModes: [datedSonnetMode], + providerSecrets: { + aiml: 'aiml-key', + }, + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.equal(config.modelName, 'aiml_claude_sonnet_4_6') + assert.equal(storage.modelName, 'aiml_claude_sonnet_4_6') + assert.equal(config.apiMode.itemName, 'aiml_claude_sonnet_4_6') + assert.equal(storage.apiMode.itemName, 'aiml_claude_sonnet_4_6') + assert.equal(config.customApiModes[0].itemName, 'aiml_claude_sonnet_4_6') + assert.equal(storage.customApiModes[0].itemName, 'aiml_claude_sonnet_4_6') + + const request = resolveOpenAICompatibleRequest(config, { + modelName: config.modelName, + apiMode: config.apiMode, + }) + assert.equal(request.providerId, 'aiml') + assert.equal(request.requestUrl, 'https://api.aimlapi.com/v1/chat/completions') +}) + test('getUserConfig reuses custom mode key promoted earlier in the same migration pass', async () => { const customUrl = 'https://proxy.example.com/v1/chat/completions' globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ diff --git a/tests/unit/services/apis/aiml-attribution.test.mjs b/tests/unit/services/apis/aiml-attribution.test.mjs new file mode 100644 index 000000000..44126cc47 --- /dev/null +++ b/tests/unit/services/apis/aiml-attribution.test.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const ATTRIBUTION_HEADER_NAMES = ['X-AIMLAPI-Source', 'X-AIMLAPI-Partner-ID'] + +function createConfig(overrides = {}) { + return { + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + temperatureOverrideEnabled: false, + temperature: 1, + ...overrides, + } +} + +function createCustomProviderSession(providerId) { + return { + modelName: 'customModel', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId, + customName: 'openai/gpt-5-5', + customUrl: '', + apiKey: '', + active: true, + }, + } +} + +function createCustomProviderConfig(providerId, baseUrl, extraProviderFields = {}) { + return createConfig({ + customOpenAIProviders: [ + { + id: providerId, + name: providerId, + baseUrl, + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + enabled: true, + ...extraProviderFields, + }, + ], + providerSecrets: { + [providerId]: 'custom-key', + }, + }) +} + +async function captureRequest(t, config, session) { + let capturedInput + let capturedInit + 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 generateAnswersWithOpenAICompatibleApi(createFakePort(), 'CurrentQ', session, config) + + return { capturedInput, capturedInit } +} + +function assertAimlAttribution(headers) { + assert.equal(headers['X-AIMLAPI-Source'], 'agent/chatgptbox') + assert.equal(headers['X-AIMLAPI-Partner-ID'], 'part_suAsqzeIpC2a5yS8K1leCNtf') +} + +function assertNoAimlAttribution(headers) { + for (const headerName of ATTRIBUTION_HEADER_NAMES) { + assert.equal(Object.hasOwn(headers, headerName), false, headerName) + } +} + +test('adds attribution headers for built-in AI/ML requests', async (t) => { + const config = createConfig({ + providerSecrets: { + aiml: 'aiml-test-key', + }, + }) + const session = { + modelName: 'aiml_openai_gpt_5_5', + conversationRecords: [], + isRetry: false, + } + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, 'https://api.aimlapi.com/v1/chat/completions') + assert.equal(capturedInit.headers.Authorization, 'Bearer aiml-test-key') + assertAimlAttribution(capturedInit.headers) + assert.equal(Object.hasOwn(capturedInit.headers, 'X-OpenRouter-Title'), false) + + const body = JSON.parse(capturedInit.body) + for (const headerName of ATTRIBUTION_HEADER_NAMES) { + assert.equal(Object.hasOwn(body, headerName), false) + } +}) + +test('adds attribution headers for custom providers that call AI/ML directly', async (t) => { + const config = createCustomProviderConfig('direct-aiml', 'https://api.aimlapi.com/v1') + const session = createCustomProviderSession('direct-aiml') + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, 'https://api.aimlapi.com/v1/chat/completions') + assert.equal(capturedInit.headers.Authorization, 'Bearer custom-key') + assertAimlAttribution(capturedInit.headers) +}) + +const nonAimlBaseUrls = [ + ['third-party proxy', 'https://proxy.example.com/v1'], + ['look-alike host', 'https://api.aimlapi.com.example.com/v1'], + ['look-alike prefix', 'https://notapi.aimlapi.com/v1'], + ['subdomain', 'https://eu.api.aimlapi.com/v1'], + ['parent domain', 'https://aimlapi.com/v1'], + ['host in path', 'https://proxy.example.com/api.aimlapi.com/v1'], +] + +for (const [label, baseUrl] of nonAimlBaseUrls) { + test(`omits AI/ML attribution headers for a ${label} copied from the AI/ML provider`, async (t) => { + const config = createCustomProviderConfig('aiml-proxy', baseUrl, { sourceProviderId: 'aiml' }) + const session = createCustomProviderSession('aiml-proxy') + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, `${baseUrl}/chat/completions`) + assert.equal(capturedInit.headers.Authorization, 'Bearer custom-key') + assertNoAimlAttribution(capturedInit.headers) + }) +} + +test('omits AI/ML attribution headers for other built-in providers', async (t) => { + const config = createConfig({ + providerSecrets: { + openrouter: 'sk-or-test', + }, + }) + const session = { + modelName: 'openRouter_auto', + conversationRecords: [], + isRetry: false, + } + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, 'https://openrouter.ai/api/v1/chat/completions') + assertNoAimlAttribution(capturedInit.headers) + assert.equal(capturedInit.headers['X-OpenRouter-Title'], 'ChatGPTBox') +}) diff --git a/tests/unit/services/apis/provider-registry.test.mjs b/tests/unit/services/apis/provider-registry.test.mjs index 722db6a67..ecdc12674 100644 --- a/tests/unit/services/apis/provider-registry.test.mjs +++ b/tests/unit/services/apis/provider-registry.test.mjs @@ -100,6 +100,28 @@ test('resolveProviderIdForSession does not treat raw Gemini model IDs as legacy assert.equal(resolveProviderIdForSession({ modelName: 'gemini-2.5-flash' }), null) }) +test('AI/ML built-in provider only declares the chat completions endpoint', () => { + const config = { + providerSecrets: { + aiml: 'aiml-key', + }, + } + + assert.equal(getProviderById(config, 'aiml').completionsPath, undefined) + + const resolved = resolveOpenAICompatibleRequest(config, { + apiMode: { + groupName: 'aimlModelKeys', + itemName: 'aiml_openai_gpt_5_5', + providerId: '', + }, + }) + + assert.equal(resolved.providerId, 'aiml') + assert.equal(resolved.endpointType, 'chat') + assert.equal(resolved.requestUrl, 'https://api.aimlapi.com/v1/chat/completions') +}) + test('resolveOpenAICompatibleRequest resolves custom provider from normalized session provider id', () => { const config = { customOpenAIProviders: [