diff --git a/packages/desktop/src/common/api/ClientFactory.ts b/packages/desktop/src/common/api/ClientFactory.ts index 4f68da5d12..8da07966f4 100644 --- a/packages/desktop/src/common/api/ClientFactory.ts +++ b/packages/desktop/src/common/api/ClientFactory.ts @@ -13,6 +13,49 @@ import type { RotatingApiClientOptions } from './RotatingApiClient'; import { getProviderAuthType } from '../utils/platformAuthType'; import { isNewApiPlatform } from '../utils/platformConstants'; +/** + * 应用级归因头,发送给所有 OpenAI 兼容服务 + * App-level attribution headers, sent to every OpenAI-compatible service. + */ +const APP_ATTRIBUTION_HEADERS: Readonly> = { + 'HTTP-Referer': 'https://aionui.com', + 'X-Title': 'AionUi', +}; + +/** + * 按 API 源站划分的额外归因头 + * Extra attribution headers, keyed by API origin. + * + * Keying on the request origin — not on the configured platform name — is what + * keeps a vendor's headers off every other vendor's request, including a proxy + * that merely fronts the same API under a different base URL. + */ +const ATTRIBUTION_HEADERS_BY_API_ORIGIN: Readonly>>> = { + 'https://api.aimlapi.com': { + 'X-AIMLAPI-Partner-ID': 'part_UJK4IAHBjvT9g4cPDrb7B7KT', + 'X-AIMLAPI-Source': 'agent/aionui', + }, +}; + +/** + * 构建某个 base URL 对应的默认请求头 + * Build the default headers for a given base URL. + * + * Returns a fresh object on every call so the module-level constants above are + * never mutated by a caller merging into the result. + */ +export function buildDefaultHeaders(base_url?: string): Record { + let originHeaders: Readonly> | undefined; + if (base_url) { + try { + originHeaders = ATTRIBUTION_HEADERS_BY_API_ORIGIN[new URL(base_url).origin]; + } catch { + // Not a parsable absolute URL — app-level headers only. + } + } + return { ...APP_ATTRIBUTION_HEADERS, ...originHeaders }; +} + export interface ClientOptions { timeout?: number; proxy?: string; @@ -72,14 +115,14 @@ export class ClientFactory { switch (authType) { case AuthType.USE_OPENAI: { + const openaiBaseConfig = options.baseConfig as OpenAIClientConfig | undefined; const clientConfig: OpenAIClientConfig = { baseURL: base_url, timeout: options.timeout, - defaultHeaders: { - 'HTTP-Referer': 'https://aionui.com', - 'X-Title': 'AionUi', - }, - ...(options.baseConfig as OpenAIClientConfig), + ...openaiBaseConfig, + // Merged, not assigned: a caller's own headers must survive, and the + // attribution headers must survive a caller that sets unrelated ones. + defaultHeaders: { ...buildDefaultHeaders(base_url), ...openaiBaseConfig?.defaultHeaders }, }; // 添加代理配置(如果提供) @@ -123,14 +166,14 @@ export class ClientFactory { default: { // 默认使用OpenAI兼容协议 + const openaiBaseConfig = options.baseConfig as OpenAIClientConfig | undefined; const clientConfig: OpenAIClientConfig = { baseURL: base_url, timeout: options.timeout, - defaultHeaders: { - 'HTTP-Referer': 'https://aionui.com', - 'X-Title': 'AionUi', - }, - ...(options.baseConfig as OpenAIClientConfig), + ...openaiBaseConfig, + // Merged, not assigned: a caller's own headers must survive, and the + // attribution headers must survive a caller that sets unrelated ones. + defaultHeaders: { ...buildDefaultHeaders(base_url), ...openaiBaseConfig?.defaultHeaders }, }; // 添加代理配置(如果提供) diff --git a/packages/desktop/src/renderer/assets/logos/aimlapi.svg b/packages/desktop/src/renderer/assets/logos/aimlapi.svg new file mode 100644 index 0000000000..74366d6eb5 --- /dev/null +++ b/packages/desktop/src/renderer/assets/logos/aimlapi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/desktop/src/renderer/utils/model/modelPlatforms.ts b/packages/desktop/src/renderer/utils/model/modelPlatforms.ts index 0cb425d03f..af01e6032c 100644 --- a/packages/desktop/src/renderer/utils/model/modelPlatforms.ts +++ b/packages/desktop/src/renderer/utils/model/modelPlatforms.ts @@ -13,6 +13,7 @@ */ import { resolveBackendAssetUrl } from '@/renderer/utils/platform'; +import aimlapiLogo from '@/renderer/assets/logos/aimlapi.svg'; const buildLogoAssetUrl = (path: string): string => { return resolveBackendAssetUrl(`/api/assets/logos/${path}`) ?? `/api/assets/logos/${path}`; @@ -49,14 +50,29 @@ export interface PlatformConfig { * * 顺序: * 1. 自定义(需要用户输入 base url) - * 2. Moonshot/Kimi(战略合作,置顶展示) - * 3. New API / Gemini 官方平台 - * 4+ 预设供应商 + * 2. aimlapi.com(合作伙伴,供应商列表首位) + * 3. Moonshot/Kimi(战略合作,置顶展示) + * 4. New API / Gemini 官方平台 + * 5+ 预设供应商 */ export const MODEL_PLATFORMS: PlatformConfig[] = [ // 自定义选项(需要用户输入 base url)/ Custom option (requires user to input base url) { name: 'Custom', value: 'custom', logo: null, platform: 'custom', i18nKey: 'settings.platformCustom' }, + // aimlapi.com 合作伙伴,置于供应商列表首位 / Partner pinned to the top of the provider list + { + // Aggregator exposing many vendors behind one OpenAI-compatible endpoint. + // The brand is written lowercase with the TLD, so the display name is the + // domain itself; the stored `value` stays a plain identifier. + // Logo is bundled locally because the backend logo service has no + // `ai-cloud/aimlapi.svg` asset to serve. + name: 'aimlapi.com', + value: 'AIMLAPI', + logo: aimlapiLogo, + platform: 'custom', + base_url: 'https://api.aimlapi.com/v1', + }, + // Moonshot/Kimi 战略合作伙伴,紧随 Custom 置顶 / Strategic partner pinned right after Custom { name: 'Moonshot (China)', diff --git a/tests/unit/providers/ClientFactory.test.ts b/tests/unit/providers/ClientFactory.test.ts index 45a91055c4..ec3413e607 100644 --- a/tests/unit/providers/ClientFactory.test.ts +++ b/tests/unit/providers/ClientFactory.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ClientFactory, normalizeNewApiBaseUrl } from '@/common/api/ClientFactory'; +import { buildDefaultHeaders, ClientFactory, normalizeNewApiBaseUrl } from '@/common/api/ClientFactory'; import { OpenAIRotatingClient } from '@/common/api/OpenAIRotatingClient'; import { GeminiRotatingClient } from '@/common/api/GeminiRotatingClient'; import { AnthropicRotatingClient } from '@/common/api/AnthropicRotatingClient'; @@ -207,4 +207,78 @@ describe('ClientFactory', () => { expect(OpenAIRotatingClient).toHaveBeenCalled(); }); }); + + describe('buildDefaultHeaders', () => { + // A malformed partner id is never rejected by the gateway — the request + // succeeds and the attribution is silently dropped — so the shape has to be + // asserted here or a typo would go unnoticed forever. + const PARTNER_ID_PATTERN = /^part_[A-Za-z0-9]{1,64}$/; + + it('sends a well-formed partner id to aimlapi.com', () => { + const headers = buildDefaultHeaders('https://api.aimlapi.com/v1'); + expect(headers['X-AIMLAPI-Partner-ID']).toMatch(PARTNER_ID_PATTERN); + expect(headers['X-AIMLAPI-Source']).toBe('agent/aionui'); + }); + + it('identifies the host app, not the vendor, in HTTP-Referer and X-Title', () => { + const headers = buildDefaultHeaders('https://api.aimlapi.com/v1'); + expect(headers['HTTP-Referer']).toBe('https://aionui.com'); + expect(headers['X-Title']).toBe('AionUi'); + }); + + it('keeps vendor headers off other origins, including a proxy that fronts the same API', () => { + for (const url of [ + 'https://api.openai.com/v1', + 'https://openrouter.ai/api/v1', + 'https://my-gateway.example.com/aimlapi/v1', + 'http://localhost:3000/v1', + undefined, + 'not-a-url', + ]) { + const headers = buildDefaultHeaders(url); + expect(headers).toEqual({ 'HTTP-Referer': 'https://aionui.com', 'X-Title': 'AionUi' }); + } + }); + + it('returns a fresh object so the shared constants cannot be mutated', () => { + const first = buildDefaultHeaders('https://api.aimlapi.com/v1'); + first['X-Title'] = 'mutated'; + delete first['X-AIMLAPI-Partner-ID']; + const second = buildDefaultHeaders('https://api.aimlapi.com/v1'); + expect(second['X-Title']).toBe('AionUi'); + expect(second['X-AIMLAPI-Partner-ID']).toMatch(PARTNER_ID_PATTERN); + }); + }); + + describe('attribution headers on the created client', () => { + const aimlapiProvider = { + id: 'aimlapi-provider', + platform: 'custom', + api_key: 'sk-test-key', + base_url: 'https://api.aimlapi.com/v1', + use_model: 'openai/gpt-4o-mini', + authType: AuthType.USE_OPENAI, + }; + + it('attaches the aimlapi.com attribution headers to the OpenAI client', async () => { + await ClientFactory.createRotatingClient(aimlapiProvider); + const config = vi.mocked(OpenAIRotatingClient).mock.calls[0][1]; + expect(config.defaultHeaders).toEqual({ + 'HTTP-Referer': 'https://aionui.com', + 'X-Title': 'AionUi', + 'X-AIMLAPI-Partner-ID': 'part_UJK4IAHBjvT9g4cPDrb7B7KT', + 'X-AIMLAPI-Source': 'agent/aionui', + }); + }); + + it('merges caller headers instead of dropping the attribution ones', async () => { + await ClientFactory.createRotatingClient(aimlapiProvider, { + baseConfig: { defaultHeaders: { 'X-Custom': 'caller', 'X-Title': 'caller wins' } }, + }); + const config = vi.mocked(OpenAIRotatingClient).mock.calls[0][1]; + expect(config.defaultHeaders['X-Custom']).toBe('caller'); + expect(config.defaultHeaders['X-Title']).toBe('caller wins'); + expect(config.defaultHeaders['X-AIMLAPI-Partner-ID']).toBe('part_UJK4IAHBjvT9g4cPDrb7B7KT'); + }); + }); }); diff --git a/tests/unit/renderer/modelPlatforms.test.ts b/tests/unit/renderer/modelPlatforms.test.ts index 9c71d920b3..9534c23e34 100644 --- a/tests/unit/renderer/modelPlatforms.test.ts +++ b/tests/unit/renderer/modelPlatforms.test.ts @@ -9,14 +9,15 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_PLATFORM_VALUE, MODEL_PLATFORMS } from '@renderer/utils/model/modelPlatforms'; +import { DEFAULT_PLATFORM_VALUE, getPresetProviders, MODEL_PLATFORMS } from '@renderer/utils/model/modelPlatforms'; describe('MODEL_PLATFORMS ordering', () => { - it('keeps Custom first and pins both Moonshot entries right after it', () => { + it('keeps Custom first, then aimlapi.com, then both Moonshot entries', () => { const values = MODEL_PLATFORMS.map((p) => p.value); expect(values[0]).toBe('custom'); - expect(values[1]).toBe('Moonshot'); - expect(values[2]).toBe('Moonshot-Global'); + expect(values[1]).toBe('AIMLAPI'); + expect(values[2]).toBe('Moonshot'); + expect(values[3]).toBe('Moonshot-Global'); }); it('defaults the add-model modal platform to the first list entry', () => { @@ -33,3 +34,33 @@ describe('MODEL_PLATFORMS ordering', () => { ]); }); }); + +describe('aimlapi.com preset provider', () => { + const entry = MODEL_PLATFORMS.find((p) => p.value === 'AIMLAPI'); + + it('shows the brand exactly as users know it', () => { + // The brand is the domain, lowercase. It is not translated, so it carries + // no i18nKey and the raw `name` is what the picker renders. + expect(entry?.name).toBe('aimlapi.com'); + expect(entry?.i18nKey).toBeUndefined(); + }); + + it('points at the OpenAI-compatible endpoint', () => { + // /v1/completions does not exist on this API, so the OpenAI-compatible + // chat surface at /v1 is the only correct base URL. + expect(entry?.base_url).toBe('https://api.aimlapi.com/v1'); + expect(entry?.platform).toBe('custom'); + }); + + it('is offered as a preset provider with a logo', () => { + expect(getPresetProviders()).toContain(entry); + expect(entry?.logo).toBeTruthy(); + }); + + it('leads the provider list, behind only the Custom placeholder', () => { + // Custom is not a provider — it is the "type your own base URL" row, and + // DEFAULT_PLATFORM_VALUE reads index 0 — so index 1 is the top of the + // provider list proper. + expect(MODEL_PLATFORMS.indexOf(entry!)).toBe(1); + }); +});