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
63 changes: 53 additions & 10 deletions packages/desktop/src/common/api/ClientFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
'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<Record<string, Readonly<Record<string, string>>>> = {
'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<string, string> {
let originHeaders: Readonly<Record<string, string>> | 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;
Expand Down Expand Up @@ -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 },
};

// 添加代理配置(如果提供)
Expand Down Expand Up @@ -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 },
};

// 添加代理配置(如果提供)
Expand Down
5 changes: 5 additions & 0 deletions packages/desktop/src/renderer/assets/logos/aimlapi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 19 additions & 3 deletions packages/desktop/src/renderer/utils/model/modelPlatforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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)',
Expand Down
76 changes: 75 additions & 1 deletion tests/unit/providers/ClientFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
});
39 changes: 35 additions & 4 deletions tests/unit/renderer/modelPlatforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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);
});
});