Skip to content
Merged
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
15 changes: 7 additions & 8 deletions src/components/settings/ProvidersSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '@/stores/providers';
import {
PROVIDER_TYPE_INFO,
getProviderDocsUrl,
type ProviderType,
getProviderIconUrl,
resolveProviderApiKeyForSave,
Expand Down Expand Up @@ -318,6 +319,7 @@ function ProviderCard({
const [saving, setSaving] = useState(false);

const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === account.vendorId);
const providerDocsUrl = getProviderDocsUrl(typeInfo, i18n.language);
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField);

Expand Down Expand Up @@ -522,12 +524,10 @@ function ProviderCard({

{isEditing && (
<div className="space-y-6 mt-4 pt-4 border-t border-black/5 dark:border-white/5">
{account.vendorId === 'custom' && (
{providerDocsUrl && (
<div className="flex justify-end -mt-2 mb-2">
<a
href={i18n.language.startsWith('zh')
? 'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#IWQCdfe5fobGU3xf3UGcgbLynGh'
: 'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#Ee1ldfvKJoVGvfxc32mcILwenth'}
href={providerDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[12px] text-blue-500 hover:text-blue-600 font-medium inline-flex items-center gap-1"
Expand Down Expand Up @@ -799,6 +799,7 @@ function AddProviderDialog({
const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('apikey');

const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType);
const providerDocsUrl = getProviderDocsUrl(typeInfo, i18n.language);
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
const isOAuth = typeInfo?.isOAuth ?? false;
const supportsApiKey = typeInfo?.supportsApiKey ?? false;
Expand Down Expand Up @@ -1092,13 +1093,11 @@ function AddProviderDialog({
>
{t('aiProviders.dialog.change')}
</button>
{selectedType === 'custom' && (
{providerDocsUrl && (
<>
<span className="mx-2 text-foreground/20">|</span>
<a
href={i18n.language.startsWith('zh')
? 'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#IWQCdfe5fobGU3xf3UGcgbLynGh'
: 'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#Ee1ldfvKJoVGvfxc32mcILwenth'}
href={providerDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] text-blue-500 hover:text-blue-600 font-medium inline-flex items-center gap-1"
Expand Down
52 changes: 44 additions & 8 deletions src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface ProviderTypeInfo {
isOAuth?: boolean;
supportsApiKey?: boolean;
apiKeyUrl?: string;
docsUrl?: string;
docsUrlZh?: string;
}

export type ProviderAuthMode =
Expand Down Expand Up @@ -121,7 +123,15 @@ import { providerIcons } from '@/assets/providers';

/** All supported provider types with UI metadata */
export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true },
{
id: 'anthropic',
name: 'Anthropic',
icon: '🤖',
placeholder: 'sk-ant-api03-...',
model: 'Claude',
requiresApiKey: true,
docsUrl: 'https://platform.claude.com/docs/en/api/overview',
},
{
id: 'openai',
name: 'OpenAI',
Expand All @@ -145,15 +155,26 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
defaultModelId: 'gemini-3.1-pro-preview',
apiKeyUrl: 'https://aistudio.google.com/app/apikey',
},
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3' },
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://intl.minimaxi.com/' },
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'openai/gpt-5.4', defaultModelId: 'openai/gpt-5.4', docsUrl: 'https://openrouter.ai/models' },
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://platform.minimaxi.com/' },
{ id: 'qwen-portal', name: 'Qwen', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' },
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.5', apiKeyUrl: 'https://intl.minimaxi.com/' },
{ id: 'qwen-portal', name: 'Qwen (Global)', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: false, isOAuth: true, defaultModelId: 'coder-model' },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/' },
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required', requiresApiKey: false, defaultBaseUrl: 'http://localhost:11434/v1', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'qwen3:latest' },
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...', requiresApiKey: true, showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'your-provider/model-id' },
{
id: 'custom',
name: 'Custom',
icon: '⚙️',
placeholder: 'API key...',
requiresApiKey: true,
showBaseUrl: true,
showModelId: true,
modelIdPlaceholder: 'your-provider/model-id',
docsUrl: 'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#Ee1ldfvKJoVGvfxc32mcILwenth',
docsUrlZh: 'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#IWQCdfe5fobGU3xf3UGcgbLynGh',
},
];

/** Get the SVG logo URL for a provider type, falls back to undefined */
Expand All @@ -174,6 +195,21 @@ export function getProviderTypeInfo(type: ProviderType): ProviderTypeInfo | unde
return PROVIDER_TYPE_INFO.find((t) => t.id === type);
}

export function getProviderDocsUrl(
provider: Pick<ProviderTypeInfo, 'docsUrl' | 'docsUrlZh'> | undefined,
language: string
): string | undefined {
if (!provider?.docsUrl) {
return undefined;
}

if (language.startsWith('zh') && provider.docsUrlZh) {
return provider.docsUrlZh;
}

return provider.docsUrl;
}

export function shouldShowProviderModelId(
provider: Pick<ProviderTypeInfo, 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
devModeUnlocked: boolean
Expand Down
19 changes: 17 additions & 2 deletions src/pages/Setup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
type ProviderAccount,
type ProviderType,
type ProviderTypeInfo,
getProviderDocsUrl,
getProviderIconUrl,
resolveProviderApiKeyForSave,
resolveProviderModelForSave,
Expand Down Expand Up @@ -713,7 +714,7 @@ function ProviderContent({
onApiKeyChange,
onConfiguredChange,
}: ProviderContentProps) {
const { t } = useTranslation(['setup', 'settings']);
const { t, i18n } = useTranslation(['setup', 'settings']);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
Expand Down Expand Up @@ -975,6 +976,7 @@ function ProviderContent({
}, [providerMenuOpen]);

const selectedProviderData = providers.find((p) => p.id === selectedProvider);
const providerDocsUrl = getProviderDocsUrl(selectedProviderData, i18n.language);
const selectedProviderIconUrl = selectedProviderData
? getProviderIconUrl(selectedProviderData.id)
: undefined;
Expand Down Expand Up @@ -1139,7 +1141,20 @@ function ProviderContent({
<div className="space-y-6">
{/* Provider selector — dropdown */}
<div className="space-y-2">
<Label>{t('provider.label')}</Label>
<div className="flex items-center justify-between gap-3">
<Label>{t('provider.label')}</Label>
{selectedProvider && providerDocsUrl && (
<a
href={providerDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] text-blue-500 hover:text-blue-600 font-medium inline-flex items-center gap-1"
>
{t('settings:aiProviders.dialog.customDoc')}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="relative" ref={providerMenuRef}>
<button
type="button"
Expand Down
31 changes: 28 additions & 3 deletions tests/unit/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
PROVIDER_TYPES,
PROVIDER_TYPE_INFO,
getProviderDocsUrl,
resolveProviderApiKeyForSave,
resolveProviderModelForSave,
shouldShowProviderModelId,
Expand Down Expand Up @@ -72,13 +73,37 @@ describe('provider metadata', () => {
);
});

it('exposes provider documentation links', () => {
const anthropic = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'anthropic');
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const moonshot = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'moonshot');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');
const ark = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'ark');
const custom = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'custom');

expect(anthropic).toMatchObject({
docsUrl: 'https://platform.claude.com/docs/en/api/overview',
});
expect(getProviderDocsUrl(anthropic, 'en')).toBe('https://platform.claude.com/docs/en/api/overview');
expect(getProviderDocsUrl(openrouter, 'en')).toBe('https://openrouter.ai/models');
expect(getProviderDocsUrl(moonshot, 'en')).toBe('https://platform.moonshot.cn/');
expect(getProviderDocsUrl(siliconflow, 'en')).toBe('https://docs.siliconflow.cn/cn/userguide/introduction');
expect(getProviderDocsUrl(ark, 'en')).toBe('https://www.volcengine.com/');
expect(getProviderDocsUrl(custom, 'en')).toBe(
'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#Ee1ldfvKJoVGvfxc32mcILwenth'
);
expect(getProviderDocsUrl(custom, 'zh-CN')).toBe(
'https://icnnp7d0dymg.feishu.cn/wiki/BmiLwGBcEiloZDkdYnGc8RWnn6d#IWQCdfe5fobGU3xf3UGcgbLynGh'
);
});

it('exposes OpenRouter and SiliconFlow model overrides by default', () => {
const openrouter = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'openrouter');
const siliconflow = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'siliconflow');

expect(openrouter).toMatchObject({
showModelId: true,
defaultModelId: 'anthropic/claude-opus-4.6',
defaultModelId: 'openai/gpt-5.4',
});
expect(siliconflow).toMatchObject({
showModelId: true,
Expand All @@ -102,8 +127,8 @@ describe('provider metadata', () => {
expect(resolveProviderModelForSave(openrouter, 'openai/gpt-5', true)).toBe('openai/gpt-5');
expect(resolveProviderModelForSave(siliconflow, 'Qwen/Qwen3-Coder-480B-A35B-Instruct', true)).toBe('Qwen/Qwen3-Coder-480B-A35B-Instruct');

expect(resolveProviderModelForSave(openrouter, ' ', false)).toBe('anthropic/claude-opus-4.6');
expect(resolveProviderModelForSave(openrouter, ' ', true)).toBe('anthropic/claude-opus-4.6');
expect(resolveProviderModelForSave(openrouter, ' ', false)).toBe('openai/gpt-5.4');
expect(resolveProviderModelForSave(openrouter, ' ', true)).toBe('openai/gpt-5.4');
expect(resolveProviderModelForSave(siliconflow, ' ', true)).toBe('deepseek-ai/DeepSeek-V3');
expect(resolveProviderModelForSave(ark, ' ep-custom-model ', false)).toBe('ep-custom-model');
});
Expand Down
Loading