diff --git a/src/main/apiServer/utils/__tests__/provider-alias.test.ts b/src/main/apiServer/utils/__tests__/provider-alias.test.ts new file mode 100644 index 0000000000..d7aad83b23 --- /dev/null +++ b/src/main/apiServer/utils/__tests__/provider-alias.test.ts @@ -0,0 +1,118 @@ +import { CacheService } from '@main/services/CacheService' +import type { Model, Provider } from '@types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { selectMock } = vi.hoisted(() => ({ + selectMock: vi.fn() +})) + +vi.mock('@main/services/ReduxService', () => ({ + reduxService: { + select: selectMock + } +})) + +import { getProviderByModel, transformModelToOpenAI, validateModelId } from '..' + +describe('api server provider alias', () => { + beforeEach(() => { + CacheService.clear() + selectMock.mockReset() + }) + + it('formats model id using provider apiIdentifier when present', () => { + const provider: Provider = { + id: 'test-provider-uuid', + apiIdentifier: 'short', + type: 'openai', + name: 'Custom Provider', + apiKey: 'test-key', + apiHost: 'https://example.com/v1', + models: [], + enabled: true + } + + const model: Model = { + id: 'glm-4.6', + provider: provider.id, + name: 'glm-4.6', + group: 'glm' + } + + const apiModel = transformModelToOpenAI(model, provider) + expect(apiModel.id).toBe('short:glm-4.6') + expect(apiModel.provider).toBe('test-provider-uuid') + }) + + it('resolves provider by apiIdentifier for model routing', async () => { + const provider: Provider = { + id: 'test-provider-uuid', + apiIdentifier: 'short', + type: 'openai', + name: 'Custom Provider', + apiKey: 'test-key', + apiHost: 'https://example.com/v1', + models: [], + enabled: true + } + + selectMock.mockResolvedValue([provider]) + + const resolved = await getProviderByModel('short:glm-4.6') + expect(resolved?.id).toBe('test-provider-uuid') + }) + + it('validates model ids with apiIdentifier prefix', async () => { + const provider: Provider = { + id: 'test-provider-uuid', + apiIdentifier: 'short', + type: 'openai', + name: 'Custom Provider', + apiKey: 'test-key', + apiHost: 'https://example.com/v1', + models: [ + { + id: 'glm-4.6', + provider: 'test-provider-uuid', + name: 'glm-4.6', + group: 'glm' + } + ], + enabled: true + } + + selectMock.mockResolvedValue([provider]) + + const result = await validateModelId('short:glm-4.6') + expect(result.valid).toBe(true) + expect(result.provider?.id).toBe('test-provider-uuid') + expect(result.modelId).toBe('glm-4.6') + }) + + it('still supports provider.id prefix', async () => { + const provider: Provider = { + id: 'test-provider-uuid', + apiIdentifier: 'short', + type: 'openai', + name: 'Custom Provider', + apiKey: 'test-key', + apiHost: 'https://example.com/v1', + models: [ + { + id: 'glm-4.6', + provider: 'test-provider-uuid', + name: 'glm-4.6', + group: 'glm' + } + ], + enabled: true + } + + selectMock.mockResolvedValue([provider]) + + const result = await validateModelId('test-provider-uuid:glm-4.6') + expect(result.valid).toBe(true) + expect(result.provider?.id).toBe('test-provider-uuid') + expect(result.modelId).toBe('glm-4.6') + }) +}) diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index e25b49e750..072d9ecf54 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -82,16 +82,24 @@ export async function getProviderByModel(model: string): Promise p.id === providerId) + const matchingProviders = providers.filter((p: Provider) => p.id === providerId || p.apiIdentifier === providerId) + const provider = matchingProviders.find((p) => p.id === providerId) ?? matchingProviders[0] if (!provider) { logger.warn('Provider not found for model', { providerId, - available: providers.map((p) => p.id) + available: providers.map((p) => ({ id: p.id, apiIdentifier: p.apiIdentifier })) }) return undefined } + if (matchingProviders.length > 1) { + logger.warn('Multiple providers matched for model prefix', { + providerId, + matches: matchingProviders.map((p) => ({ id: p.id, apiIdentifier: p.apiIdentifier })) + }) + } + logger.debug('Provider resolved for model', { providerId, model }) return provider } catch (error: any) { @@ -200,8 +208,9 @@ export async function validateModelId(model: string): Promise<{ export function transformModelToOpenAI(model: Model, provider?: Provider): ApiModel { const providerDisplayName = provider?.name + const providerModelPrefix = provider?.apiIdentifier?.trim() || provider?.id || model.provider return { - id: `${model.provider}:${model.id}`, + id: `${providerModelPrefix}:${model.id}`, object: 'model', name: model.name, created: Math.floor(Date.now() / 1000), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f4012363e3..25dd0b4b44 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4469,6 +4469,16 @@ "api_host_no_valid": "API address is invalid", "api_host_preview": "Preview: {{url}}", "api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.", + "api_identifier": { + "error": { + "duplicate": "Identifier is already used by another provider", + "invalid": "Only letters, numbers, '-' and '_' are allowed, and ':' is not allowed" + }, + "label": "API Identifier", + "placeholder": "Example: my-provider", + "preview": "Example: {{model}}", + "tip": "Used as the model prefix in API relay. Leave empty to use the internal UUID." + }, "api_key": { "label": "API Key", "tip": "Use commas to separate multiple keys" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0e5b2f60e7..a1dbfefc78 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4469,6 +4469,16 @@ "api_host_no_valid": "API 地址不合法", "api_host_preview": "预览:{{url}}", "api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。", + "api_identifier": { + "error": { + "duplicate": "该标识符已被其他提供商使用", + "invalid": "仅支持字母、数字、“-”、“_”,且不能包含“:”" + }, + "label": "API 标识符", + "placeholder": "例如:my-provider", + "preview": "示例:{{model}}", + "tip": "用于 API Relay 的模型前缀。留空则使用内部 UUID。" + }, "api_key": { "label": "API 密钥", "tip": "多个密钥使用逗号分隔" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9625c68386..301077da62 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4469,6 +4469,16 @@ "api_host_no_valid": "API 位址不合法", "api_host_preview": "預覽:{{url}}", "api_host_tooltip": "僅在供應商需要自訂的 OpenAI 相容端點時才覆蓋。", + "api_identifier": { + "error": { + "duplicate": "此識別碼已被其他供應商使用", + "invalid": "僅支援字母、數字、“-”、“_”,且不能包含“:”" + }, + "label": "API 識別碼", + "placeholder": "例如:my-provider", + "preview": "範例:{{model}}", + "tip": "用於 API Relay 的模型前綴。留空則使用內部 UUID。" + }, "api_key": { "label": "API 金鑰", "tip": "多個金鑰使用逗號分隔" diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 049c14c0d1..981297cd7d 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -92,6 +92,8 @@ const isAnthropicCompatibleProviderId = (id: string): id is AnthropicCompatibleP type HostField = 'apiHost' | 'anthropicApiHost' +const API_IDENTIFIER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/ + const ProviderSetting: FC = ({ providerId }) => { const { provider, updateProvider, models } = useProvider(providerId) const allProviders = useAllProviders() @@ -122,6 +124,7 @@ const ProviderSetting: FC = ({ providerId }) => { const fancyProviderName = getFancyProviderName(provider) const [localApiKey, setLocalApiKey] = useState(provider.apiKey) + const [apiIdentifier, setApiIdentifier] = useState(provider.apiIdentifier ?? '') const [apiKeyConnectivity, setApiKeyConnectivity] = useState({ status: HealthStatus.NOT_CHECKED, checking: false @@ -147,6 +150,10 @@ const ProviderSetting: FC = ({ providerId }) => { setApiKeyConnectivity({ status: HealthStatus.NOT_CHECKED }) }, [provider.apiKey]) + useEffect(() => { + setApiIdentifier(provider.apiIdentifier ?? '') + }, [provider.apiIdentifier]) + // 同步 localApiKey 到 provider.apiKey(防抖) useEffect(() => { if (localApiKey !== provider.apiKey) { @@ -385,6 +392,41 @@ const ProviderSetting: FC = ({ providerId }) => { const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth' + const onUpdateApiIdentifier = useCallback(() => { + const normalizedIdentifier = apiIdentifier.trim() + + if (!normalizedIdentifier) { + updateProvider({ apiIdentifier: undefined }) + setApiIdentifier('') + return + } + + if (!API_IDENTIFIER_PATTERN.test(normalizedIdentifier) || normalizedIdentifier.includes(':')) { + window.toast.error(t('settings.provider.api_identifier.error.invalid')) + setApiIdentifier(provider.apiIdentifier ?? '') + return + } + + const conflictProvider = allProviders.find((p) => { + if (p.id === provider.id) { + return false + } + if (p.id === normalizedIdentifier) { + return true + } + return p.apiIdentifier?.trim() === normalizedIdentifier + }) + + if (conflictProvider) { + window.toast.error(t('settings.provider.api_identifier.error.duplicate')) + setApiIdentifier(provider.apiIdentifier ?? '') + return + } + + updateProvider({ apiIdentifier: normalizedIdentifier }) + setApiIdentifier(normalizedIdentifier) + }, [allProviders, apiIdentifier, provider.apiIdentifier, provider.id, t, updateProvider]) + return ( @@ -418,6 +460,34 @@ const ProviderSetting: FC = ({ providerId }) => { /> + {!isSystemProvider(provider) && ( + <> + + {t('settings.provider.api_identifier.label')} + + + setApiIdentifier(e.target.value)} + onBlur={onUpdateApiIdentifier} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + onUpdateApiIdentifier() + } + }} + spellCheck={false} + maxLength={32} + /> + + + {t('settings.provider.api_identifier.preview', { + model: `${apiIdentifier.trim() || provider.id}:glm-4.6` + })} + + + + )} {isProviderSupportAuth(provider) && } {provider.id === 'openai' && } {provider.id === 'ovms' && } diff --git a/src/renderer/src/types/provider.ts b/src/renderer/src/types/provider.ts index edab3a7305..313e0eb7a2 100644 --- a/src/renderer/src/types/provider.ts +++ b/src/renderer/src/types/provider.ts @@ -98,6 +98,11 @@ export type Provider = { id: string type: ProviderType name: string + /** + * Optional short identifier used by the built-in API relay. + * When set, models can be referenced as `${apiIdentifier}:${modelId}` instead of `${id}:${modelId}`. + */ + apiIdentifier?: string apiKey: string apiHost: string anthropicApiHost?: string