Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 72 additions & 21 deletions src/config/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register the new AI/ML labels in the English locale

When these new model descriptions are rendered, modelNameToDesc passes them through i18next, but src/_locales/en/main.json contains none of the new or renamed AI/ML (...) keys, so they bypass the English source of truth and cannot participate in the normal localization fallback/translation workflow. Add all labels introduced in this block to the English locale; the root AGENTS.md explicitly requires new source strings there before other locale work.

AGENTS.md reference: AGENTS.md:L211-L213

Useful? React with 👍 / 👎.

},
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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This preset uses an unlisted AI/ML model ID, so Kimi K3 requests can fail model validation. Use a supported Kimi K3 ID, and update the label if the selected variant is K3.1 Pro.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/config/index.mjs, line 745:

<comment>This preset uses an unlisted AI/ML model ID, so Kimi K3 requests can fail model validation. Use a supported Kimi K3 ID, and update the label if the selected variant is K3.1 Pro.</comment>

<file context>
@@ -643,69 +653,109 @@ export const Models = {
+    desc: 'AI/ML (Gemini 2.5 Flash)',
+  },
+  aiml_moonshot_kimi_k3: {
+    value: 'moonshot/kimi-k3',
+    desc: 'AI/ML (Kimi K3)',
   },
</file context>

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',
Expand Down Expand Up @@ -796,6 +846,7 @@ export const defaultApiModeIds = [
'mistralMediumLatest',
'openRouter_auto',
'openRouter_free',
'aiml_openai_gpt_6_1_sol',
'nvidiaNim_nemotron_3_super',
]

Expand Down
7 changes: 4 additions & 3 deletions src/config/model-key-migrations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
23 changes: 22 additions & 1 deletion src/services/apis/openai-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,6 +120,22 @@ function getOpenRouterAttributionHeaders(requestUrl) {
return OPENROUTER_ATTRIBUTION_HEADERS
}

function getAimlAttributionHeaders(requestUrl) {
try {
if (new URL(requestUrl).hostname !== AIML_API_HOSTNAME) return {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Redirected requests tag other hosts 🐞 Bug ≡ Correctness

getAimlAttributionHeaders checks only the initial request URL, while fetchSSE follows redirects
without rechecking the destination or removing the attribution headers. If an AI/ML endpoint
redirects to another hostname, the redirected request can carry both headers to a host that the
exact-hostname check would otherwise exclude.
Agent Prompt
## Issue description
AI/ML attribution is scoped to the initial request URL, but automatically followed redirects can carry its headers to another host.

## Fix Focus Areas
- src/services/apis/openai-api.mjs[123-136]
- src/services/apis/openai-compatible-core.mjs[125-130]
- src/utils/fetch-sse.mjs[65-69]

## Recommended Fix
For requests carrying AI/ML attribution, either reject redirects or revalidate each destination and remove both attribution headers when the hostname differs. Preserve redirect behavior for unrelated providers and add a redirect regression test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Reject redirects for requests carrying AI/ML attribution, or remove these headers before following a cross-origin redirect. fetch follows redirects by default, so an AI/ML endpoint can otherwise forward both headers to another host.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/services/apis/openai-api.mjs, line 125:

<comment>Reject redirects for requests carrying AI/ML attribution, or remove these headers before following a cross-origin redirect. `fetch` follows redirects by default, so an AI/ML endpoint can otherwise forward both headers to another host.</comment>

<file context>
@@ -115,6 +120,22 @@ function getOpenRouterAttributionHeaders(requestUrl) {
 
+function getAimlAttributionHeaders(requestUrl) {
+  try {
+    if (new URL(requestUrl).hostname !== AIML_API_HOSTNAME) return {}
+  } catch {
+    return {}
</file context>

} catch {
return {}
}
return AIML_ATTRIBUTION_HEADERS
}

function getAppAttributionHeaders(requestUrl) {
return {
...getOpenRouterAttributionHeaders(requestUrl),
...getAimlAttributionHeaders(requestUrl),
}
}

function resolveOllamaKeepAliveBaseUrl(request) {
const requestUrl = normalizeBaseUrl(request?.requestUrl)
if (requestUrl) {
Expand Down Expand Up @@ -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,
})

Expand Down
1 change: 0 additions & 1 deletion src/services/apis/provider-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
19 changes: 18 additions & 1 deletion tests/unit/config/config-predicates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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')
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/config/migrate-user-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading