Provider Config & anthropic-web-fetch (#10808)
* fix: update AI SDK dependencies to latest versions * feat: Update provider configurations and API handling - Refactor provider configuration to support new API types and enhance API host formatting. - Introduce new utility functions for handling API versions and formatting Azure OpenAI hosts. - Update system models to include new capabilities and adjust provider types for CherryIN and VertexAI. - Enhance provider settings UI to accommodate new API types and improve user experience. - Implement migration logic for provider type updates and default API host settings. - Update translations for API host configuration tips across multiple languages. - Fix various type checks and utility functions to ensure compatibility with new provider types. * fix: update unsupported API version providers and add longcat to compatible provider IDs * fix: 移除不再使用的 Azure OpenAI API 版本参数,优化 API 主机格式化逻辑 feat: 在选择器组件中添加样式属性,增强可定制性 feat: 更新提供者设置,支持动态选择 API 主机字段 * refactor: 优化测试用例 * 修复: 更新工具调用处理器以支持新的工具调用类型 * feat: 添加TODO注释以改进基于AI SDK的供应商内置工具展示和类型安全处理 * feat: 添加对Google SDK的支持,更新流式参数构建逻辑以包含Google工具的上下文 * feat: 更新web搜索模型判断逻辑,使用SystemProviderIds常量替代硬编码字符串 * feat: 添加对@renderer/store的mock以支持测试环境 * feat: 添加API主机地址验证功能,更新相关逻辑以支持端点提取 * fix: i18n * fix(i18n): Auto update translations for PR #10808 * Apply suggestion from @EurFelux Co-authored-by: Phantom <eurfelux@gmail.com> * Apply suggestion from @EurFelux Co-authored-by: Phantom <eurfelux@gmail.com> * Apply suggestion from @EurFelux Co-authored-by: Phantom <eurfelux@gmail.com> * refactor: Simplify provider type migration logic and enhance API version validation * fix: Correct variable name from configedApiHost to configuredApiHost for consistency * fix: Update package.json to remove deprecated @ai-sdk/google version and streamline @ai-sdk/openai versioning * fix: 更新 hasAPIVersion 函数中的正则表达式以更准确地匹配 API 版本路径 * fix(api): 简化 validateApiHost 函数逻辑以始终返回 true fix(yarn): 更新 @ai-sdk/openai 版本至 2.0.53 并添加依赖项 * fix(api): 修正 validateApiHost 函数在使用哈希后缀时的验证逻辑 --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Phantom <eurfelux@gmail.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
|
||||
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
|
||||
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
|
||||
import { ApiClient } from '@types'
|
||||
|
||||
import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
@@ -9,7 +8,7 @@ import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
|
||||
const { model, provider, apiKey, baseURL } = embedApiClient
|
||||
if (provider === 'voyageai') {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
@@ -38,16 +37,7 @@ export default class EmbeddingsFactory {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (apiVersion !== undefined) {
|
||||
return new AzureOpenAiEmbeddings({
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIApiVersion: apiVersion,
|
||||
azureOpenAIApiDeploymentName: model,
|
||||
azureOpenAIEndpoint: baseURL,
|
||||
dimensions,
|
||||
batchSize
|
||||
})
|
||||
}
|
||||
// NOTE: Azure OpenAI 也走 OpenAIEmbeddings, baseURL是https://xxxx.openai.azure.com/openai/v1
|
||||
return new OpenAiEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
|
||||
import { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
|
||||
import {
|
||||
BaseTool,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
MCPToolResultContent,
|
||||
NormalToolResponse
|
||||
} from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
|
||||
|
||||
@@ -254,6 +261,7 @@ export class ToolCallChunkHandler {
|
||||
type: 'tool-result'
|
||||
} & TypedToolResult<ToolSet>
|
||||
): void {
|
||||
// TODO: 基于AI SDK为供应商内置工具做更好的展示和类型安全处理
|
||||
const { toolCallId, output, input } = chunk
|
||||
|
||||
if (!toolCallId) {
|
||||
@@ -299,12 +307,7 @@ export class ToolCallChunkHandler {
|
||||
responses: [toolResponse]
|
||||
})
|
||||
|
||||
const images: string[] = []
|
||||
for (const content of toolResponse.response?.content || []) {
|
||||
if (content.type === 'image' && content.data) {
|
||||
images.push(`data:${content.mimeType};base64,${content.data}`)
|
||||
}
|
||||
}
|
||||
const images = extractImagesFromToolOutput(toolResponse.response)
|
||||
|
||||
if (images.length) {
|
||||
this.onChunk({
|
||||
@@ -351,3 +354,41 @@ export class ToolCallChunkHandler {
|
||||
}
|
||||
|
||||
export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
|
||||
|
||||
function extractImagesFromToolOutput(output: unknown): string[] {
|
||||
if (!output) {
|
||||
return []
|
||||
}
|
||||
|
||||
const contents: unknown[] = []
|
||||
|
||||
if (isMcpCallToolResponse(output)) {
|
||||
contents.push(...output.content)
|
||||
} else if (Array.isArray(output)) {
|
||||
contents.push(...output)
|
||||
} else if (hasContentArray(output)) {
|
||||
contents.push(...output.content)
|
||||
}
|
||||
|
||||
return contents
|
||||
.filter(isMcpImageContent)
|
||||
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
|
||||
}
|
||||
|
||||
function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse {
|
||||
return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content)
|
||||
}
|
||||
|
||||
function hasContentArray(value: unknown): value is { content: unknown[] } {
|
||||
return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content)
|
||||
}
|
||||
|
||||
function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } {
|
||||
if (typeof content !== 'object' || content === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resultContent = content as MCPToolResultContent
|
||||
|
||||
return resultContent.type === 'image' && typeof resultContent.data === 'string'
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
|
||||
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
|
||||
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
|
||||
@@ -77,7 +78,7 @@ export default class ModernAiProvider {
|
||||
return this.actualProvider
|
||||
}
|
||||
|
||||
public async completions(modelId: string, params: StreamTextParams, config: ModernAiProviderConfig) {
|
||||
public async completions(modelId: string, params: StreamTextParams, providerConfig: ModernAiProviderConfig) {
|
||||
// 检查model是否存在
|
||||
if (!this.model) {
|
||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||
@@ -85,7 +86,10 @@ export default class ModernAiProvider {
|
||||
|
||||
// 每次请求时重新生成配置以确保API key轮换生效
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||
|
||||
logger.debug('Generated provider config for completions', this.config)
|
||||
if (SUPPORTED_IMAGE_ENDPOINT_LIST.includes(this.config.options.endpoint)) {
|
||||
providerConfig.isImageGenerationEndpoint = true
|
||||
}
|
||||
// 准备特殊配置
|
||||
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
||||
|
||||
@@ -96,13 +100,13 @@ export default class ModernAiProvider {
|
||||
|
||||
// 提前构建中间件
|
||||
const middlewares = buildAiSdkMiddlewares({
|
||||
...config,
|
||||
...providerConfig,
|
||||
provider: this.actualProvider,
|
||||
assistant: config.assistant
|
||||
assistant: providerConfig.assistant
|
||||
})
|
||||
logger.debug('Built middlewares in completions', {
|
||||
middlewareCount: middlewares.length,
|
||||
isImageGeneration: config.isImageGenerationEndpoint
|
||||
isImageGeneration: providerConfig.isImageGenerationEndpoint
|
||||
})
|
||||
if (!this.localProvider) {
|
||||
throw new Error('Local provider not created')
|
||||
@@ -110,7 +114,7 @@ export default class ModernAiProvider {
|
||||
|
||||
// 根据endpoint类型创建对应的模型
|
||||
let model: AiSdkModel | undefined
|
||||
if (config.isImageGenerationEndpoint) {
|
||||
if (providerConfig.isImageGenerationEndpoint) {
|
||||
model = this.localProvider.imageModel(modelId)
|
||||
} else {
|
||||
model = this.localProvider.languageModel(modelId)
|
||||
@@ -126,15 +130,15 @@ export default class ModernAiProvider {
|
||||
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
|
||||
}
|
||||
|
||||
if (config.topicId && getEnableDeveloperMode()) {
|
||||
if (providerConfig.topicId && getEnableDeveloperMode()) {
|
||||
// TypeScript类型窄化:确保topicId是string类型
|
||||
const traceConfig = {
|
||||
...config,
|
||||
topicId: config.topicId
|
||||
...providerConfig,
|
||||
topicId: providerConfig.topicId
|
||||
}
|
||||
return await this._completionsForTrace(model, params, traceConfig)
|
||||
} else {
|
||||
return await this._completionsOrImageGeneration(model, params, config)
|
||||
return await this._completionsOrImageGeneration(model, params, providerConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Provider } from '@renderer/types'
|
||||
import { isOpenAIProvider } from '@renderer/utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
|
||||
@@ -202,36 +201,4 @@ describe('ApiClientFactory', () => {
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenAIProvider', () => {
|
||||
it('should return true for openai type', () => {
|
||||
const provider = createTestProvider('openai', 'openai')
|
||||
expect(isOpenAIProvider(provider)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for azure-openai type', () => {
|
||||
const provider = createTestProvider('azure-openai', 'azure-openai')
|
||||
expect(isOpenAIProvider(provider)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for unknown type (fallback to OpenAI)', () => {
|
||||
const provider = createTestProvider('unknown', 'unknown')
|
||||
expect(isOpenAIProvider(provider)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for vertexai type', () => {
|
||||
const provider = createTestProvider('vertex', 'vertexai')
|
||||
expect(isOpenAIProvider(provider)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for anthropic type', () => {
|
||||
const provider = createTestProvider('anthropic', 'anthropic')
|
||||
expect(isOpenAIProvider(provider)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for gemini type', () => {
|
||||
const provider = createTestProvider('gemini', 'gemini')
|
||||
expect(isOpenAIProvider(provider)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AiPlugin } from '@cherrystudio/ai-core'
|
||||
import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { loggerService } from '@logger'
|
||||
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { Assistant } from '@renderer/types'
|
||||
@@ -68,9 +68,9 @@ export function buildPlugins(
|
||||
)
|
||||
}
|
||||
|
||||
if (middlewareConfig.enableUrlContext) {
|
||||
plugins.push(googleToolsPlugin({ urlContext: true }))
|
||||
}
|
||||
// if (middlewareConfig.enableUrlContext && middlewareConfig.) {
|
||||
// plugins.push(googleToolsPlugin({ urlContext: true }))
|
||||
// }
|
||||
|
||||
logger.debug(
|
||||
'Final plugin list:',
|
||||
|
||||
@@ -114,7 +114,7 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model):
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OpenAI大文件上传
|
||||
* 处理OpenAI兼容大文件上传
|
||||
*/
|
||||
export async function handleOpenAILargeFileUpload(
|
||||
file: FileMetadata,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 构建AI SDK的流式和非流式参数
|
||||
*/
|
||||
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
|
||||
import { vertex } from '@ai-sdk/google-vertex/edge'
|
||||
import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
@@ -97,10 +99,6 @@ export async function buildStreamTextParams(
|
||||
|
||||
let tools = setupToolsConfig(mcpTools)
|
||||
|
||||
// if (webSearchProviderId) {
|
||||
// tools['builtin_web_search'] = webSearchTool(webSearchProviderId)
|
||||
// }
|
||||
|
||||
// 构建真正的 providerOptions
|
||||
const webSearchConfig: CherryWebSearchConfig = {
|
||||
maxResults: store.getState().websearch.maxResults,
|
||||
@@ -143,12 +141,34 @@ export async function buildStreamTextParams(
|
||||
}
|
||||
}
|
||||
|
||||
// google-vertex
|
||||
if (enableUrlContext && aiSdkProviderId === 'google-vertex') {
|
||||
if (enableUrlContext) {
|
||||
if (!tools) {
|
||||
tools = {}
|
||||
}
|
||||
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
|
||||
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
|
||||
|
||||
switch (aiSdkProviderId) {
|
||||
case 'google-vertex':
|
||||
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
|
||||
break
|
||||
case 'google':
|
||||
tools.url_context = google.tools.urlContext({}) as ProviderDefinedTool
|
||||
break
|
||||
case 'anthropic':
|
||||
case 'google-vertex-anthropic':
|
||||
tools.web_fetch = (
|
||||
aiSdkProviderId === 'anthropic'
|
||||
? anthropic.tools.webFetch_20250910({
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
})
|
||||
: vertexAnthropic.tools.webFetch_20250910({
|
||||
maxUses: webSearchConfig.maxResults,
|
||||
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
|
||||
})
|
||||
) as ProviderDefinedTool
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 构建基础参数
|
||||
|
||||
@@ -32,7 +32,8 @@ const AIHUBMIX_RULES: RuleSet = {
|
||||
match: (model) =>
|
||||
(startsWith('gemini')(model) || startsWith('imagen')(model)) &&
|
||||
!model.id.endsWith('-nothink') &&
|
||||
!model.id.endsWith('-search'),
|
||||
!model.id.endsWith('-search') &&
|
||||
!model.id.includes('embedding'),
|
||||
provider: (provider: Provider) => {
|
||||
return extraProviderConfig({
|
||||
...provider,
|
||||
|
||||
@@ -6,26 +6,28 @@ import {
|
||||
type ProviderSettingsMap
|
||||
} from '@cherrystudio/ai-core/provider'
|
||||
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
|
||||
import { isNewApiProvider } from '@renderer/config/providers'
|
||||
import {
|
||||
isAnthropicProvider,
|
||||
isAzureOpenAIProvider,
|
||||
isGeminiProvider,
|
||||
isNewApiProvider
|
||||
} from '@renderer/config/providers'
|
||||
import {
|
||||
getAwsBedrockAccessKeyId,
|
||||
getAwsBedrockRegion,
|
||||
getAwsBedrockSecretAccessKey
|
||||
} from '@renderer/hooks/useAwsBedrock'
|
||||
import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI'
|
||||
import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import store from '@renderer/store'
|
||||
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { cloneDeep, trim } from 'lodash'
|
||||
import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types'
|
||||
import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||
import { COPILOT_DEFAULT_HEADERS } from './constants'
|
||||
import { getAiSdkProviderId } from './factory'
|
||||
|
||||
const logger = loggerService.withContext('ProviderConfigProcessor')
|
||||
|
||||
/**
|
||||
* 获取轮询的API key
|
||||
* 复用legacy架构的多key轮询逻辑
|
||||
@@ -56,13 +58,6 @@ function getRotatedApiKey(provider: Provider): string {
|
||||
* 处理特殊provider的转换逻辑
|
||||
*/
|
||||
function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
// if (provider.type === 'vertexai' && !isVertexProvider(provider)) {
|
||||
// if (!isVertexAIConfigured()) {
|
||||
// throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.')
|
||||
// }
|
||||
// return createVertexProvider(provider)
|
||||
// }
|
||||
|
||||
if (isNewApiProvider(provider)) {
|
||||
return newApiResolverCreator(model, provider)
|
||||
}
|
||||
@@ -79,43 +74,30 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化provider的API Host
|
||||
* 主要用来对齐AISdk的BaseURL格式
|
||||
* @param provider
|
||||
* @returns
|
||||
*/
|
||||
function formatAnthropicApiHost(host: string): string {
|
||||
const trimmedHost = host?.trim()
|
||||
|
||||
if (!trimmedHost) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (trimmedHost.endsWith('/')) {
|
||||
return trimmedHost
|
||||
}
|
||||
|
||||
if (trimmedHost.endsWith('/v1')) {
|
||||
return `${trimmedHost}/`
|
||||
}
|
||||
|
||||
return formatApiHost(trimmedHost)
|
||||
}
|
||||
|
||||
function formatProviderApiHost(provider: Provider): Provider {
|
||||
const formatted = { ...provider }
|
||||
if (formatted.anthropicApiHost) {
|
||||
formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost)
|
||||
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
|
||||
}
|
||||
|
||||
if (formatted.type === 'anthropic') {
|
||||
if (isAnthropicProvider(provider)) {
|
||||
const baseHost = formatted.anthropicApiHost || formatted.apiHost
|
||||
formatted.apiHost = formatAnthropicApiHost(baseHost)
|
||||
formatted.apiHost = formatApiHost(baseHost)
|
||||
if (!formatted.anthropicApiHost) {
|
||||
formatted.anthropicApiHost = formatted.apiHost
|
||||
}
|
||||
} else if (formatted.id === 'copilot') {
|
||||
const trimmed = trim(formatted.apiHost)
|
||||
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||||
} else if (formatted.type === 'gemini') {
|
||||
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
|
||||
} else if (formatted.id === SystemProviderIds.copilot || formatted.id === SystemProviderIds.github) {
|
||||
formatted.apiHost = formatApiHost(formatted.apiHost, false)
|
||||
} else if (isGeminiProvider(formatted)) {
|
||||
formatted.apiHost = formatApiHost(formatted.apiHost, true, 'v1beta')
|
||||
} else if (isAzureOpenAIProvider(formatted)) {
|
||||
formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost)
|
||||
} else if (isVertexProvider(formatted)) {
|
||||
formatted.apiHost = formatVertexApiHost(formatted)
|
||||
} else {
|
||||
formatted.apiHost = formatApiHost(formatted.apiHost)
|
||||
}
|
||||
@@ -149,15 +131,15 @@ export function providerToAiSdkConfig(
|
||||
options: ProviderSettingsMap[keyof ProviderSettingsMap]
|
||||
} {
|
||||
const aiSdkProviderId = getAiSdkProviderId(actualProvider)
|
||||
logger.debug('providerToAiSdkConfig', { aiSdkProviderId })
|
||||
|
||||
// 构建基础配置
|
||||
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
|
||||
const baseConfig = {
|
||||
baseURL: trim(actualProvider.apiHost),
|
||||
baseURL: baseURL,
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
}
|
||||
|
||||
const isCopilotProvider = actualProvider.id === 'copilot'
|
||||
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
|
||||
if (isCopilotProvider) {
|
||||
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
|
||||
@@ -178,6 +160,7 @@ export function providerToAiSdkConfig(
|
||||
|
||||
// 处理OpenAI模式
|
||||
const extraOptions: any = {}
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai') {
|
||||
@@ -199,13 +182,11 @@ export function providerToAiSdkConfig(
|
||||
}
|
||||
// azure
|
||||
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
||||
extraOptions.apiVersion = actualProvider.apiVersion
|
||||
baseConfig.baseURL += '/openai'
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview') {
|
||||
extraOptions.mode = 'responses'
|
||||
} else {
|
||||
extraOptions.mode = 'chat'
|
||||
extraOptions.useDeploymentBasedUrls = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,22 +208,7 @@ export function providerToAiSdkConfig(
|
||||
...googleCredentials,
|
||||
privateKey: formatPrivateKey(googleCredentials.privateKey)
|
||||
}
|
||||
// extraOptions.headers = window.api.vertexAI.getAuthHeaders({
|
||||
// projectId: project,
|
||||
// serviceAccount: {
|
||||
// privateKey: googleCredentials.privateKey,
|
||||
// clientEmail: googleCredentials.clientEmail
|
||||
// }
|
||||
// })
|
||||
if (baseConfig.baseURL.endsWith('/v1/')) {
|
||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -4)
|
||||
} else if (baseConfig.baseURL.endsWith('/v1')) {
|
||||
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
|
||||
}
|
||||
|
||||
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
|
||||
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
|
||||
}
|
||||
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
|
||||
}
|
||||
|
||||
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
|
||||
|
||||
@@ -5,6 +5,16 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DraggableList } from '../'
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
// mock @hello-pangea/dnd 组件
|
||||
vi.mock('@hello-pangea/dnd', () => {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,16 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DraggableVirtualList } from '../'
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock 依赖项
|
||||
vi.mock('@hello-pangea/dnd', () => ({
|
||||
__esModule: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils'
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ interface BaseSelectorProps<V = string | number> {
|
||||
options: SelectorOption<V>[]
|
||||
placeholder?: string
|
||||
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
|
||||
style?: React.CSSProperties
|
||||
/** 字体大小 */
|
||||
size?: number
|
||||
/** 是否禁用 */
|
||||
@@ -43,6 +44,7 @@ const Selector = <V extends string | number>({
|
||||
placement = 'bottomRight',
|
||||
size = 13,
|
||||
placeholder,
|
||||
style,
|
||||
disabled = false,
|
||||
multiple = false
|
||||
}: SelectorProps<V>) => {
|
||||
@@ -135,7 +137,7 @@ const Selector = <V extends string | number>({
|
||||
placement={placement}
|
||||
open={open && !disabled}
|
||||
onOpenChange={handleOpenChange}>
|
||||
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
|
||||
<Label style={style} $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
|
||||
{label}
|
||||
<LabelIcon size={size + 3} />
|
||||
</Label>
|
||||
|
||||
@@ -23,6 +23,16 @@ const mocks = vi.hoisted(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock antd components to prevent flaky snapshot tests
|
||||
vi.mock('antd', () => {
|
||||
const MockSpaceCompact: React.FC<React.PropsWithChildren<{ style?: React.CSSProperties }>> = ({
|
||||
|
||||
@@ -18,6 +18,15 @@ describe('Qwen Model Detection', () => {
|
||||
vi.mock('@renderer/services/AssistantService', () => ({
|
||||
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
|
||||
}))
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
test('isQwenReasoningModel', () => {
|
||||
expect(isQwenReasoningModel({ id: 'qwen3-thinking' } as Model)).toBe(true)
|
||||
|
||||
@@ -2,6 +2,16 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isLingReasoningModel } from '../models/reasoning'
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
|
||||
vi.mock('@renderer/services/AssistantService.ts', () => ({
|
||||
getDefaultAssistant: () => {
|
||||
|
||||
@@ -1741,6 +1741,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
id: 'DeepSeek-R1',
|
||||
provider: 'cephalon',
|
||||
name: 'DeepSeek-R1满血版',
|
||||
capabilities: [{ type: 'reasoning' }],
|
||||
group: 'DeepSeek'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Model, SystemProviderIds } from '@renderer/types'
|
||||
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
||||
|
||||
import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider } from '../providers'
|
||||
import { isEmbeddingModel, isRerankModel } from './embedding'
|
||||
import { isAnthropicModel } from './utils'
|
||||
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
|
||||
@@ -65,12 +66,16 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
|
||||
// 不管哪个供应商都判断了
|
||||
if (isAnthropicModel(model)) {
|
||||
// bedrock和vertex不支持
|
||||
if (
|
||||
isAnthropicModel(model) &&
|
||||
(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
|
||||
) {
|
||||
return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
if (provider.type === 'openai-response') {
|
||||
// TODO: 当其他供应商采用Response端点时,这个地方逻辑需要改进
|
||||
if (isOpenAIProvider(provider)) {
|
||||
if (isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
@@ -78,11 +83,11 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider.id === 'perplexity') {
|
||||
if (provider.id === SystemProviderIds.perplexity) {
|
||||
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
|
||||
}
|
||||
|
||||
if (provider.id === 'aihubmix') {
|
||||
if (provider.id === SystemProviderIds.aihubmix) {
|
||||
// modelId 不以-search结尾
|
||||
if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) {
|
||||
return true
|
||||
@@ -95,13 +100,13 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (isOpenAICompatibleProvider(provider) || isNewApiProvider(provider)) {
|
||||
if (GEMINI_SEARCH_REGEX.test(modelId) || isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.id === 'gemini' || provider?.type === 'gemini' || provider.type === 'vertexai') {
|
||||
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
|
||||
return GEMINI_SEARCH_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
|
||||
import {
|
||||
AtLeast,
|
||||
AzureOpenAIProvider,
|
||||
isSystemProvider,
|
||||
OpenAIServiceTiers,
|
||||
Provider,
|
||||
@@ -355,7 +356,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
name: 'VertexAI',
|
||||
type: 'vertexai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://aiplatform.googleapis.com',
|
||||
apiHost: '',
|
||||
models: SYSTEM_MODELS.vertexai,
|
||||
isSystem: true,
|
||||
enabled: false,
|
||||
@@ -1295,7 +1296,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
vertexai: {
|
||||
api: {
|
||||
url: 'https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/overview'
|
||||
url: ''
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cloud.google.com/vertex-ai',
|
||||
@@ -1375,7 +1376,8 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
|
||||
'baichuan',
|
||||
'minimax',
|
||||
'xirang',
|
||||
'poe'
|
||||
'poe',
|
||||
'cephalon'
|
||||
] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
@@ -1440,10 +1442,15 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
|
||||
const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
|
||||
'gemini',
|
||||
'vertexai',
|
||||
'anthropic',
|
||||
'new-api'
|
||||
] as const satisfies ProviderType[]
|
||||
|
||||
export const isSupportUrlContextProvider = (provider: Provider) => {
|
||||
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
|
||||
return SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
|
||||
@@ -1456,3 +1463,37 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
|
||||
export const isNewApiProvider = (provider: Provider) => {
|
||||
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 OpenAI 兼容的提供商
|
||||
* @param {Provider} provider 提供商对象
|
||||
* @returns {boolean} 是否为 OpenAI 兼容提供商
|
||||
*/
|
||||
export function isOpenAICompatibleProvider(provider: Provider): boolean {
|
||||
return ['openai', 'new-api', 'mistral'].includes(provider.type)
|
||||
}
|
||||
|
||||
export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider {
|
||||
return provider.type === 'azure-openai'
|
||||
}
|
||||
|
||||
export function isOpenAIProvider(provider: Provider): boolean {
|
||||
return provider.type === 'openai-response'
|
||||
}
|
||||
|
||||
export function isAnthropicProvider(provider: Provider): boolean {
|
||||
return provider.type === 'anthropic'
|
||||
}
|
||||
|
||||
export function isGeminiProvider(provider: Provider): boolean {
|
||||
return provider.type === 'gemini'
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
|
||||
|
||||
export const isSupportAPIVersionProvider = (provider: Provider) => {
|
||||
if (isSystemProvider(provider)) {
|
||||
return !NOT_SUPPORT_API_VERSION_PROVIDERS.some((pid) => pid === provider.id)
|
||||
}
|
||||
return provider.apiOptions?.isNotSupportAPIVersion !== false
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function getVertexAIServiceAccount() {
|
||||
* 类型守卫:检查 Provider 是否为 VertexProvider
|
||||
*/
|
||||
export function isVertexProvider(provider: Provider): provider is VertexProvider {
|
||||
return provider.type === 'vertexai' && 'googleCredentials' in provider
|
||||
return provider.type === 'vertexai'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic API Host",
|
||||
"anthropic_api_host_preview": "Anthropic preview: {{url}}",
|
||||
"anthropic_api_host_tip": "Only configure this when your provider exposes an Anthropic-compatible endpoint. Ending with / ignores v1, ending with # forces use of input address.",
|
||||
"anthropic_api_host_tooltip": "Use only when the provider offers a Claude-compatible base URL.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Preview: {{url}}",
|
||||
"reset": "Reset",
|
||||
"tip": "Ending with / ignores v1, ending with # forces use of input address"
|
||||
"tip": "ending with # forces use of input address"
|
||||
}
|
||||
},
|
||||
"api_host": "API Host",
|
||||
"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_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic API 地址",
|
||||
"anthropic_api_host_preview": "Anthropic 预览:{{url}}",
|
||||
"anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1,以 # 结尾则强制使用原始地址。",
|
||||
"anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "预览: {{url}}",
|
||||
"reset": "重置",
|
||||
"tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址"
|
||||
"tip": "# 结尾强制使用输入地址"
|
||||
}
|
||||
},
|
||||
"api_host": "API 地址",
|
||||
"api_host_no_valid": "API 地址不合法",
|
||||
"api_host_preview": "预览:{{url}}",
|
||||
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic API 主機地址",
|
||||
"anthropic_api_host_preview": "Anthropic 預覽:{{url}}",
|
||||
"anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1,以 # 結尾則強制使用原始地址。",
|
||||
"anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "預覽:{{url}}",
|
||||
"reset": "重設",
|
||||
"tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址"
|
||||
"tip": "# 結尾強制使用輸入位址"
|
||||
}
|
||||
},
|
||||
"api_host": "API 主機地址",
|
||||
"api_host_no_valid": "API 位址不合法",
|
||||
"api_host_preview": "預覽:{{url}}",
|
||||
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic API-Adresse",
|
||||
"anthropic_api_host_preview": "Anthropic-Vorschau: {{url}}",
|
||||
"anthropic_api_host_tip": "Nur bei Anbietern, die ein Anthropic-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
|
||||
"anthropic_api_host_tooltip": "Nur bei Anbietern, die ein Claude-kompatibles Basis-Endpunkt anbieten.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Vorschau: {{url}}",
|
||||
"reset": "Zurücksetzen",
|
||||
"tip": "/ am Ende ignorieren v1-Version, # am Ende erzwingt die Verwendung der Eingabe-Adresse"
|
||||
"tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse"
|
||||
}
|
||||
},
|
||||
"api_host": "API-Adresse",
|
||||
"api_host_no_valid": "API-Adresse ist ungültig",
|
||||
"api_host_preview": "Vorschau: {{url}}",
|
||||
"api_host_tooltip": "Nur bei Anbietern, die ein OpenAI-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Διεύθυνση API Anthropic",
|
||||
"anthropic_api_host_preview": "Προεπισκόπηση Anthropic: {{url}}",
|
||||
"anthropic_api_host_tip": "Συμπληρώστε μόνο εάν ο πάροχος προσφέρει συμβατή με Anthropic διεύθυνση. Η λήξη με / αγνοεί το v1 που προστίθεται αυτόματα, η λήξη με # επιβάλλει τη χρήση της αρχικής διεύθυνσης.",
|
||||
"anthropic_api_host_tooltip": "Συμπληρώστε μόνο όταν ο πάροχος παρέχει βασική διεύθυνση συμβατή με Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Προεπισκόπηση: {{url}}",
|
||||
"reset": "Επαναφορά",
|
||||
"tip": "/τέλος αγνόηση v1 έκδοσης, #τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
}
|
||||
},
|
||||
"api_host": "Διεύθυνση API",
|
||||
"api_host_no_valid": "Η διεύθυνση API δεν είναι έγκυρη",
|
||||
"api_host_preview": "Προεπισκόπηση: {{url}}",
|
||||
"api_host_tooltip": "Αντικατάσταση μόνο όταν ο πάροχος απαιτεί προσαρμοσμένη διεύθυνση συμβατή με OpenAI.",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Dirección API de Anthropic",
|
||||
"anthropic_api_host_preview": "Vista previa de Anthropic: {{url}}",
|
||||
"anthropic_api_host_tip": "Rellenar solo si el proveedor ofrece una dirección compatible con Anthropic. Terminar con / ignora el v1 añadido automáticamente, terminar con # fuerza el uso de la dirección original.",
|
||||
"anthropic_api_host_tooltip": "Rellenar solo cuando el proveedor proporcione una dirección base compatible con Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Vista previa: {{url}}",
|
||||
"reset": "Restablecer",
|
||||
"tip": "Ignorar v1 al final con /, forzar uso de dirección de entrada con # al final"
|
||||
"tip": "forzar uso de dirección de entrada con # al final"
|
||||
}
|
||||
},
|
||||
"api_host": "Dirección API",
|
||||
"api_host_no_valid": "La dirección de la API no es válida",
|
||||
"api_host_preview": "Vista previa: {{url}}",
|
||||
"api_host_tooltip": "Sobrescribir solo cuando el proveedor necesite una dirección compatible con OpenAI personalizada.",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Adresse API Anthropic",
|
||||
"anthropic_api_host_preview": "Aperçu Anthropic : {{url}}",
|
||||
"anthropic_api_host_tip": "Remplir seulement si le fournisseur propose une adresse compatible Anthropic. Se terminant par / ignore le v1 ajouté automatiquement, se terminant par # force l'utilisation de l'adresse originale.",
|
||||
"anthropic_api_host_tooltip": "Remplir seulement lorsque le fournisseur propose une adresse de base compatible Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Aperçu : {{url}}",
|
||||
"reset": "Réinitialiser",
|
||||
"tip": "Ignorer la version v1 si terminé par /, forcer l'utilisation de l'adresse d'entrée si terminé par #"
|
||||
"tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #"
|
||||
}
|
||||
},
|
||||
"api_host": "Adresse API",
|
||||
"api_host_no_valid": "Adresse API invalide",
|
||||
"api_host_preview": "Aperçu : {{url}}",
|
||||
"api_host_tooltip": "Remplacer seulement lorsque le fournisseur nécessite une adresse compatible OpenAI personnalisée.",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic APIアドレス",
|
||||
"anthropic_api_host_preview": "Anthropic プレビュー:{{url}}",
|
||||
"anthropic_api_host_tip": "サービスプロバイダーがAnthropic互換のアドレスを提供する場合のみ入力してください。/で終わる場合は自動追加されるv1を無視し、#で終わる場合は元のアドレスを強制的に使用します。",
|
||||
"anthropic_api_host_tooltip": "サービスプロバイダーがClaude互換のベースアドレスを提供する場合のみ入力してください。",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "プレビュー: {{url}}",
|
||||
"reset": "リセット",
|
||||
"tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
}
|
||||
},
|
||||
"api_host": "APIホスト",
|
||||
"api_host_no_valid": "APIアドレスが無効です",
|
||||
"api_host_preview": "プレビュー:{{url}}",
|
||||
"api_host_tooltip": "サービスプロバイダーがカスタムOpenAI互換アドレスを必要とする場合のみ上書きしてください。",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Endereço da API Anthropic",
|
||||
"anthropic_api_host_preview": "Pré-visualização Anthropic: {{url}}",
|
||||
"anthropic_api_host_tip": "Preencher apenas se o fornecedor oferecer um endereço compatível com Anthropic. Terminar com / ignora o v1 adicionado automaticamente, terminar com # força o uso do endereço original.",
|
||||
"anthropic_api_host_tooltip": "Preencher apenas quando o fornecedor fornece um endereço base compatível com Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Pré-visualização: {{url}}",
|
||||
"reset": "Redefinir",
|
||||
"tip": "Ignorar v1 na versão finalizada com /, usar endereço de entrada forçado se terminar com #"
|
||||
"tip": "e forçar o uso do endereço original quando terminar com '#'"
|
||||
}
|
||||
},
|
||||
"api_host": "Endereço API",
|
||||
"api_host_no_valid": "O endereço da API é inválido",
|
||||
"api_host_preview": "Pré-visualização: {{url}}",
|
||||
"api_host_tooltip": "Substituir apenas quando o fornecedor necessita de um endereço compatível com OpenAI personalizado.",
|
||||
"api_key": {
|
||||
|
||||
@@ -4148,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Адрес API Anthropic",
|
||||
"anthropic_api_host_preview": "Предпросмотр Anthropic: {{url}}",
|
||||
"anthropic_api_host_tip": "Заполняйте только если провайдер предоставляет совместимый с Anthropic адрес. Окончание на / игнорирует автоматически добавляемое v1, окончание на # принудительно использует оригинальный адрес.",
|
||||
"anthropic_api_host_tooltip": "Заполняйте только когда провайдер предоставляет базовый адрес, совместимый с Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4193,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Предпросмотр: {{url}}",
|
||||
"reset": "Сброс",
|
||||
"tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес"
|
||||
"tip": "заканчивая на # принудительно использует введенный адрес"
|
||||
}
|
||||
},
|
||||
"api_host": "Хост API",
|
||||
"api_host_no_valid": "Недопустимый адрес API",
|
||||
"api_host_preview": "Предпросмотр: {{url}}",
|
||||
"api_host_tooltip": "Переопределяйте только когда провайдер требует пользовательский адрес, совместимый с OpenAI.",
|
||||
"api_key": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import {
|
||||
isAnthropicModel,
|
||||
isGeminiModel,
|
||||
isGenerateImageModel,
|
||||
isMandatoryWebSearchModel,
|
||||
@@ -385,7 +386,7 @@ const InputbarTools = ({
|
||||
label: t('chat.input.url_context'),
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
||||
condition:
|
||||
isGeminiModel(model) &&
|
||||
(isGeminiModel(model) || isAnthropicModel(model)) &&
|
||||
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,11 +2,22 @@ import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { PROVIDER_URLS } from '@renderer/config/providers'
|
||||
import {
|
||||
isAnthropicProvider,
|
||||
isAzureOpenAIProvider,
|
||||
isGeminiProvider,
|
||||
isNewApiProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isOpenAIProvider,
|
||||
isSupportAPIVersionProvider,
|
||||
PROVIDER_URLS
|
||||
} from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { isVertexProvider } from '@renderer/hooks/useVertexAI'
|
||||
import i18n from '@renderer/i18n'
|
||||
import AnthropicSettings from '@renderer/pages/settings/ProviderSettings/AnthropicSettings'
|
||||
import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList'
|
||||
@@ -14,14 +25,15 @@ import { checkApi } from '@renderer/services/ApiService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { updateWebSearchProvider } from '@renderer/store/websearch'
|
||||
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
|
||||
import { isSystemProvider, isSystemProviderId, SystemProviderId, SystemProviderIds } from '@renderer/types'
|
||||
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import {
|
||||
formatApiHost,
|
||||
formatApiKeys,
|
||||
formatAzureOpenAIApiHost,
|
||||
formatVertexApiHost,
|
||||
getFancyProviderName,
|
||||
isAnthropicProvider,
|
||||
isOpenAIProvider
|
||||
validateApiHost
|
||||
} from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
|
||||
@@ -63,7 +75,9 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
|
||||
SystemProviderIds.dashscope,
|
||||
SystemProviderIds.modelscope,
|
||||
SystemProviderIds.aihubmix,
|
||||
SystemProviderIds.grok
|
||||
SystemProviderIds.grok,
|
||||
SystemProviderIds.cherryin,
|
||||
SystemProviderIds.longcat
|
||||
] as const
|
||||
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
|
||||
|
||||
@@ -72,6 +86,8 @@ const isAnthropicCompatibleProviderId = (id: string): id is AnthropicCompatibleP
|
||||
return ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET.has(id)
|
||||
}
|
||||
|
||||
type HostField = 'apiHost' | 'anthropicApiHost'
|
||||
|
||||
const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const { provider, updateProvider, models } = useProvider(providerId)
|
||||
const allProviders = useAllProviders()
|
||||
@@ -79,19 +95,23 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const [apiHost, setApiHost] = useState(provider.apiHost)
|
||||
const [anthropicApiHost, setAnthropicHost] = useState<string | undefined>(provider.anthropicApiHost)
|
||||
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
|
||||
const [activeHostField, setActiveHostField] = useState<HostField>('apiHost')
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
|
||||
const isAzureOpenAI = isAzureOpenAIProvider(provider)
|
||||
const isDmxapi = provider.id === 'dmxapi'
|
||||
const hideApiInput = ['vertexai', 'aws-bedrock'].includes(provider.id)
|
||||
const noAPIInputProviders = ['aws-bedrock'] as const satisfies SystemProviderId[]
|
||||
const hideApiInput = noAPIInputProviders.some((id) => id === provider.id)
|
||||
const noAPIKeyInputProviders = ['copilot', 'vertexai'] as const satisfies SystemProviderId[]
|
||||
const hideApiKeyInput = noAPIKeyInputProviders.some((id) => id === provider.id)
|
||||
|
||||
const providerConfig = PROVIDER_URLS[provider.id]
|
||||
const officialWebsite = providerConfig?.websites?.official
|
||||
const apiKeyWebsite = providerConfig?.websites?.apiKey
|
||||
const configedApiHost = providerConfig?.api?.url
|
||||
const configuredApiHost = providerConfig?.api?.url
|
||||
|
||||
const fancyProviderName = getFancyProviderName(provider)
|
||||
|
||||
@@ -151,7 +171,12 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
)
|
||||
|
||||
const onUpdateApiHost = () => {
|
||||
if (apiHost.trim()) {
|
||||
if (!validateApiHost(apiHost)) {
|
||||
setApiHost(provider.apiHost)
|
||||
window.toast.error(t('settings.provider.api_host_no_valid'))
|
||||
return
|
||||
}
|
||||
if (isVertexProvider(provider) || apiHost.trim()) {
|
||||
updateProvider({ apiHost })
|
||||
} else {
|
||||
setApiHost(provider.apiHost)
|
||||
@@ -238,27 +263,46 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
setApiHost(configedApiHost)
|
||||
updateProvider({ apiHost: configedApiHost })
|
||||
}
|
||||
const onReset = useCallback(() => {
|
||||
setApiHost(configuredApiHost)
|
||||
updateProvider({ apiHost: configuredApiHost })
|
||||
}, [configuredApiHost, updateProvider])
|
||||
|
||||
const isApiHostResettable = useMemo(() => {
|
||||
return !isEmpty(configuredApiHost) && apiHost !== configuredApiHost
|
||||
}, [configuredApiHost, apiHost])
|
||||
|
||||
const hostPreview = () => {
|
||||
if (apiHost.endsWith('#')) {
|
||||
return apiHost.replace('#', '')
|
||||
}
|
||||
if (provider.type === 'openai') {
|
||||
return formatApiHost(apiHost) + 'chat/completions'
|
||||
|
||||
if (isOpenAICompatibleProvider(provider)) {
|
||||
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions'
|
||||
}
|
||||
|
||||
if (provider.type === 'azure-openai') {
|
||||
return formatApiHost(apiHost) + 'openai/v1'
|
||||
if (isAzureOpenAIProvider(provider)) {
|
||||
const apiVersion = provider.apiVersion
|
||||
const path = !['preview', 'v1'].includes(apiVersion)
|
||||
? `/v1/chat/completion?apiVersion=v1`
|
||||
: `/v1/responses?apiVersion=v1`
|
||||
return formatAzureOpenAIApiHost(apiHost) + path
|
||||
}
|
||||
|
||||
if (provider.type === 'anthropic') {
|
||||
return formatApiHost(apiHost) + 'messages'
|
||||
if (isAnthropicProvider(provider)) {
|
||||
return formatApiHost(apiHost) + '/messages'
|
||||
}
|
||||
return formatApiHost(apiHost) + 'responses'
|
||||
|
||||
if (isGeminiProvider(provider)) {
|
||||
return formatApiHost(apiHost, true, 'v1beta') + '/models'
|
||||
}
|
||||
if (isOpenAIProvider(provider)) {
|
||||
return formatApiHost(apiHost) + '/responses'
|
||||
}
|
||||
if (isVertexProvider(provider)) {
|
||||
return formatVertexApiHost(provider) + '/publishers/google'
|
||||
}
|
||||
return formatApiHost(apiHost)
|
||||
}
|
||||
|
||||
// API key 连通性检查状态指示器,目前仅在失败时显示
|
||||
@@ -286,31 +330,44 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
}, [provider.anthropicApiHost])
|
||||
|
||||
const canConfigureAnthropicHost = useMemo(() => {
|
||||
if (isNewApiProvider(provider)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
provider.type !== 'anthropic' && isSystemProviderId(provider.id) && isAnthropicCompatibleProviderId(provider.id)
|
||||
)
|
||||
}, [provider])
|
||||
|
||||
const anthropicHostPreview = useMemo(() => {
|
||||
const rawHost = (anthropicApiHost ?? provider.anthropicApiHost)?.trim()
|
||||
if (!rawHost) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/\/messages\/?$/.test(rawHost)) {
|
||||
return rawHost.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
let normalizedHost = rawHost
|
||||
if (/\/v\d+(?:\/)?$/i.test(normalizedHost)) {
|
||||
normalizedHost = normalizedHost.replace(/\/$/, '')
|
||||
} else {
|
||||
normalizedHost = formatApiHost(normalizedHost).replace(/\/$/, '')
|
||||
}
|
||||
const rawHost = anthropicApiHost ?? provider.anthropicApiHost
|
||||
const normalizedHost = formatApiHost(rawHost)
|
||||
|
||||
return `${normalizedHost}/messages`
|
||||
}, [anthropicApiHost, provider.anthropicApiHost])
|
||||
|
||||
const hostSelectorOptions = useMemo(() => {
|
||||
const options: { value: HostField; label: string }[] = [
|
||||
{ value: 'apiHost', label: t('settings.provider.api_host') }
|
||||
]
|
||||
|
||||
if (canConfigureAnthropicHost) {
|
||||
options.push({ value: 'anthropicApiHost', label: t('settings.provider.anthropic_api_host') })
|
||||
}
|
||||
|
||||
return options
|
||||
}, [canConfigureAnthropicHost, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canConfigureAnthropicHost && activeHostField === 'anthropicApiHost') {
|
||||
setActiveHostField('apiHost')
|
||||
}
|
||||
}, [canConfigureAnthropicHost, activeHostField])
|
||||
|
||||
const hostSelectorTooltip =
|
||||
activeHostField === 'anthropicApiHost'
|
||||
? t('settings.provider.anthropic_api_host_tooltip')
|
||||
: t('settings.provider.api_host_tooltip')
|
||||
|
||||
const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth'
|
||||
|
||||
return (
|
||||
@@ -367,105 +424,122 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
)}
|
||||
{!hideApiInput && !isAnthropicOAuth() && (
|
||||
<>
|
||||
<SettingSubtitle
|
||||
style={{
|
||||
marginTop: 5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
{t('settings.provider.api_key.label')}
|
||||
{provider.id !== 'copilot' && (
|
||||
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
|
||||
<Button type="text" onClick={openApiKeyList} icon={<Settings2 size={16} />} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input.Password
|
||||
value={localApiKey}
|
||||
placeholder={t('settings.provider.api_key.label')}
|
||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
|
||||
disabled={provider.id === 'copilot'}
|
||||
suffix={renderStatusIndicator()}
|
||||
/>
|
||||
<Button
|
||||
type={isApiKeyConnectable ? 'primary' : 'default'}
|
||||
ghost={isApiKeyConnectable}
|
||||
onClick={onCheckApi}
|
||||
disabled={!apiHost || apiKeyConnectivity.checking}>
|
||||
{apiKeyConnectivity.checking ? (
|
||||
<LoadingIcon />
|
||||
) : apiKeyConnectivity.status === 'success' ? (
|
||||
<Check size={16} className="lucide-custom" />
|
||||
) : (
|
||||
t('settings.provider.check')
|
||||
)}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{apiKeyWebsite && !isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
{!isDmxapi && !isAnthropicOAuth() && (
|
||||
{!hideApiKeyInput && (
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tooltip title={t('settings.provider.api_host_tooltip')} mouseEnterDelay={0.3}>
|
||||
<SubtitleLabel>{t('settings.provider.api_host')}</SubtitleLabel>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => CustomHeaderPopup.show({ provider })}
|
||||
icon={<Settings2 size={16} />}
|
||||
/>
|
||||
<SettingSubtitle
|
||||
style={{
|
||||
marginTop: 5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
{t('settings.provider.api_key.label')}
|
||||
{provider.id !== 'copilot' && (
|
||||
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
|
||||
<Button type="text" onClick={openApiKeyList} icon={<Settings2 size={16} />} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
<Input.Password
|
||||
value={localApiKey}
|
||||
placeholder={t('settings.provider.api_key.label')}
|
||||
onChange={(e) => setLocalApiKey(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
|
||||
disabled={provider.id === 'copilot'}
|
||||
suffix={renderStatusIndicator()}
|
||||
/>
|
||||
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
|
||||
<Button danger onClick={onReset}>
|
||||
{t('settings.provider.api.url.reset')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type={isApiKeyConnectable ? 'primary' : 'default'}
|
||||
ghost={isApiKeyConnectable}
|
||||
onClick={onCheckApi}
|
||||
disabled={!apiHost || apiKeyConnectivity.checking}>
|
||||
{apiKeyConnectivity.checking ? (
|
||||
<LoadingIcon />
|
||||
) : apiKeyConnectivity.status === 'success' ? (
|
||||
<Check size={16} className="lucide-custom" />
|
||||
) : (
|
||||
t('settings.provider.check')
|
||||
)}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{(isOpenAIProvider(provider) || isAnthropicProvider(provider)) && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpText
|
||||
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
{t('settings.provider.api_host_preview', { url: hostPreview() })}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText style={{ minWidth: 'fit-content' }}>
|
||||
{t('settings.provider.api.url.tip')}
|
||||
</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{apiKeyWebsite && !isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
)}
|
||||
{!isDmxapi && (
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||
<Selector
|
||||
size={14}
|
||||
value={activeHostField}
|
||||
onChange={(value) => setActiveHostField(value as HostField)}
|
||||
options={hostSelectorOptions}
|
||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => CustomHeaderPopup.show({ provider })}
|
||||
icon={<Settings2 size={16} />}
|
||||
/>
|
||||
</div>
|
||||
</SettingSubtitle>
|
||||
{activeHostField === 'apiHost' && (
|
||||
<>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
/>
|
||||
{isApiHostResettable && (
|
||||
<Button danger onClick={onReset}>
|
||||
{t('settings.provider.api.url.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</Space.Compact>
|
||||
{isVertexProvider(provider) && (
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.vertex_ai.api_host_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{(isOpenAICompatibleProvider(provider) ||
|
||||
isAzureOpenAIProvider(provider) ||
|
||||
isAnthropicProvider(provider) ||
|
||||
isGeminiProvider(provider) ||
|
||||
isVertexProvider(provider) ||
|
||||
isOpenAIProvider(provider)) && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpText
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
marginRight: '1em',
|
||||
whiteSpace: 'break-spaces',
|
||||
wordBreak: 'break-all'
|
||||
}}>
|
||||
{t('settings.provider.api_host_preview', { url: hostPreview() })}
|
||||
</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canConfigureAnthropicHost && (
|
||||
{activeHostField === 'anthropicApiHost' && canConfigureAnthropicHost && (
|
||||
<>
|
||||
<SettingSubtitle
|
||||
style={{
|
||||
marginTop: 5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Tooltip title={t('settings.provider.anthropic_api_host_tooltip')} mouseEnterDelay={0.3}>
|
||||
<SubtitleLabel>{t('settings.provider.anthropic_api_host')}</SubtitleLabel>
|
||||
</Tooltip>
|
||||
</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
value={anthropicApiHost ?? ''}
|
||||
@@ -480,9 +554,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
url: anthropicHostPreview || '—'
|
||||
})}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText style={{ marginLeft: 6 }}>
|
||||
{t('settings.provider.anthropic_api_host_tip')}
|
||||
</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
)}
|
||||
@@ -512,21 +583,12 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{provider.id === 'gpustack' && <GPUStackSettings />}
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
|
||||
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings providerId={provider.id} />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings />}
|
||||
<ModelList providerId={provider.id} />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const SubtitleLabel = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
`
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { PROVIDER_URLS } from '@renderer/config/providers'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { useVertexAISettings } from '@renderer/hooks/useVertexAI'
|
||||
import { Alert, Input, Space } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { Alert, Input } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
|
||||
|
||||
interface Props {
|
||||
providerId: string
|
||||
}
|
||||
|
||||
const VertexAISettings: FC<Props> = ({ providerId }) => {
|
||||
const VertexAISettings = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
projectId,
|
||||
@@ -27,16 +22,9 @@ const VertexAISettings: FC<Props> = ({ providerId }) => {
|
||||
const [localProjectId, setLocalProjectId] = useState(projectId)
|
||||
const [localLocation, setLocalLocation] = useState(location)
|
||||
|
||||
const { provider, updateProvider } = useProvider(providerId)
|
||||
const [apiHost, setApiHost] = useState(provider.apiHost)
|
||||
|
||||
const providerConfig = PROVIDER_URLS['vertexai']
|
||||
const apiKeyWebsite = providerConfig?.websites?.apiKey
|
||||
|
||||
const onUpdateApiHost = () => {
|
||||
updateProvider({ apiHost })
|
||||
}
|
||||
|
||||
const handleProjectIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalProjectId(e.target.value)
|
||||
}
|
||||
@@ -72,18 +60,6 @@ const VertexAISettings: FC<Props> = ({ providerId }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.vertex_ai.api_host_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>
|
||||
{t('settings.provider.vertex_ai.service_account.title')}
|
||||
</SettingSubtitle>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Span } from '@opentelemetry/api'
|
||||
import AiProvider from '@renderer/aiCore'
|
||||
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant'
|
||||
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
|
||||
import { isGeminiProvider } from '@renderer/config/providers'
|
||||
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import store from '@renderer/store'
|
||||
import {
|
||||
@@ -40,7 +41,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
|
||||
|
||||
let host = aiProvider.getBaseURL()
|
||||
const rerankHost = rerankAiProvider.getBaseURL()
|
||||
if (provider.type === 'gemini') {
|
||||
if (isGeminiProvider(provider)) {
|
||||
host = host + '/v1beta/openai/'
|
||||
}
|
||||
|
||||
|
||||
@@ -2716,7 +2716,6 @@ const migrateConfig = {
|
||||
}
|
||||
},
|
||||
'166': (state: RootState) => {
|
||||
// added after 1.6.5 and 1.7.0-beta.2
|
||||
try {
|
||||
if (state.assistants.presets === undefined) {
|
||||
state.assistants.presets = []
|
||||
@@ -2733,6 +2732,18 @@ const migrateConfig = {
|
||||
if (dashscopeProvider) {
|
||||
dashscopeProvider.anthropicApiHost = 'https://dashscope.aliyuncs.com/apps/anthropic'
|
||||
}
|
||||
|
||||
state.llm.providers.forEach((provider) => {
|
||||
if (provider.id === SystemProviderIds['new-api'] && provider.type !== 'new-api') {
|
||||
provider.type = 'new-api'
|
||||
}
|
||||
if (provider.id === SystemProviderIds.longcat) {
|
||||
// https://longcat.chat/platform/docs/zh/#anthropic-api-%E6%A0%BC%E5%BC%8F
|
||||
if (!provider.anthropicApiHost) {
|
||||
provider.anthropicApiHost = 'https://api.longcat.chat/anthropic'
|
||||
}
|
||||
}
|
||||
})
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 166 error', error as Error)
|
||||
|
||||
@@ -41,7 +41,7 @@ export type Assistant = {
|
||||
/** enableWebSearch 代表使用模型内置网络搜索功能 */
|
||||
enableWebSearch?: boolean
|
||||
webSearchProviderId?: WebSearchProvider['id']
|
||||
// enableUrlContext 是 Gemini 的特有功能
|
||||
// enableUrlContext 是 Gemini/Anthropic 的特有功能
|
||||
enableUrlContext?: boolean
|
||||
enableGenerateImage?: boolean
|
||||
mcpServers?: MCPServer[]
|
||||
|
||||
@@ -6,7 +6,6 @@ export const ProviderTypeSchema = z.enum([
|
||||
'openai-response',
|
||||
'anthropic',
|
||||
'gemini',
|
||||
'qwenlm',
|
||||
'azure-openai',
|
||||
'vertexai',
|
||||
'mistral',
|
||||
@@ -37,6 +36,8 @@ export type ProviderApiOptions = {
|
||||
isSupportServiceTier?: boolean
|
||||
/** 是否不支持 enable_thinking 参数 */
|
||||
isNotSupportEnableThinking?: boolean
|
||||
/** 是否不支持 APIVersion */
|
||||
isNotSupportAPIVersion?: boolean
|
||||
}
|
||||
|
||||
export const OpenAIServiceTiers = {
|
||||
@@ -187,6 +188,11 @@ export type VertexProvider = Provider & {
|
||||
location: string
|
||||
}
|
||||
|
||||
export type AzureOpenAIProvider = Provider & {
|
||||
type: 'azure-openai'
|
||||
apiVersion: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为系统内置的提供商。比直接使用`provider.isSystem`更好,因为该数据字段不会随着版本更新而变化。
|
||||
* @param provider - Provider对象,包含提供商的信息
|
||||
|
||||
@@ -1,31 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import store from '@renderer/store'
|
||||
import type { VertexProvider } from '@renderer/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatApiHost, maskApiKey, splitApiKeyString } from '../api'
|
||||
import {
|
||||
formatApiHost,
|
||||
formatApiKeys,
|
||||
formatAzureOpenAIApiHost,
|
||||
formatVertexApiHost,
|
||||
hasAPIVersion,
|
||||
maskApiKey,
|
||||
routeToEndpoint,
|
||||
splitApiKeyString,
|
||||
validateApiHost
|
||||
} from '../api'
|
||||
|
||||
vi.mock('@renderer/store', () => {
|
||||
const getState = vi.fn()
|
||||
return {
|
||||
default: {
|
||||
getState
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getStateMock = store.getState as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const createVertexProvider = (apiHost: string): VertexProvider => ({
|
||||
id: 'vertex-provider',
|
||||
type: 'vertexai',
|
||||
name: 'Vertex AI',
|
||||
apiKey: '',
|
||||
apiHost,
|
||||
models: [],
|
||||
googleCredentials: {
|
||||
privateKey: '',
|
||||
clientEmail: ''
|
||||
},
|
||||
project: '',
|
||||
location: ''
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
getStateMock.mockReset()
|
||||
getStateMock.mockReturnValue({
|
||||
llm: {
|
||||
settings: {
|
||||
vertexai: {
|
||||
projectId: 'test-project',
|
||||
location: 'us-central1'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('api', () => {
|
||||
describe('formatApiHost', () => {
|
||||
it('should return original host when it ends with a slash', () => {
|
||||
expect(formatApiHost('https://api.example.com/')).toBe('https://api.example.com/')
|
||||
expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/')
|
||||
})
|
||||
|
||||
it('should return original host when it ends with volces.com/api/v3', () => {
|
||||
expect(formatApiHost('https://api.volces.com/api/v3')).toBe('https://api.volces.com/api/v3')
|
||||
expect(formatApiHost('http://volces.com/api/v3')).toBe('http://volces.com/api/v3')
|
||||
})
|
||||
|
||||
it('should append /v1/ to hosts that do not match special conditions', () => {
|
||||
expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1/')
|
||||
expect(formatApiHost('http://localhost:5173')).toBe('http://localhost:5173/v1/')
|
||||
expect(formatApiHost('https://api.openai.com')).toBe('https://api.openai.com/v1/')
|
||||
})
|
||||
|
||||
it('should not modify hosts that already have a path but do not end with a slash', () => {
|
||||
expect(formatApiHost('https://api.example.com/custom')).toBe('https://api.example.com/custom/v1/')
|
||||
})
|
||||
|
||||
it('should handle empty string gracefully', () => {
|
||||
it('returns empty string for falsy host', () => {
|
||||
expect(formatApiHost('')).toBe('')
|
||||
expect(formatApiHost(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('appends api version when missing', () => {
|
||||
expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1')
|
||||
expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/v1')
|
||||
expect(formatApiHost(' https://api.openai.com ')).toBe('https://api.openai.com/v1')
|
||||
})
|
||||
|
||||
it('keeps original host when api version already present', () => {
|
||||
expect(formatApiHost('https://api.volces.com/api/v3')).toBe('https://api.volces.com/api/v3')
|
||||
expect(formatApiHost('http://localhost:5173/v2beta')).toBe('http://localhost:5173/v2beta')
|
||||
})
|
||||
|
||||
it('supports custom api version parameter', () => {
|
||||
expect(formatApiHost('https://api.example.com', true, 'v2')).toBe('https://api.example.com/v2')
|
||||
})
|
||||
|
||||
it('keeps host untouched when api version unsupported', () => {
|
||||
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAPIVersion', () => {
|
||||
it('detects numeric version suffix', () => {
|
||||
expect(hasAPIVersion('https://api.example.com/v1')).toBe(true)
|
||||
expect(hasAPIVersion('http://localhost:3000/v2beta')).toBe(true)
|
||||
expect(hasAPIVersion('/v3alpha/resources')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no version found', () => {
|
||||
expect(hasAPIVersion('https://api.example.com')).toBe(false)
|
||||
expect(hasAPIVersion('')).toBe(false)
|
||||
expect(hasAPIVersion(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('return flase when starting without v character', () => {
|
||||
expect(hasAPIVersion('https://api.example.com/a1v')).toBe(false)
|
||||
expect(hasAPIVersion('/av1/users')).toBe(false)
|
||||
})
|
||||
|
||||
it('return flase when starting with v- word', () => {
|
||||
expect(hasAPIVersion('https://api.example.com/vendor')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,4 +198,122 @@ describe('api', () => {
|
||||
expect(result).toEqual(['key1', 'key2,withcomma', 'key3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateApiHost', () => {
|
||||
it('accepts empty or whitespace-only host', () => {
|
||||
expect(validateApiHost('')).toBe(true)
|
||||
expect(validateApiHost(' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unsupported protocols', () => {
|
||||
expect(validateApiHost('ftp://api.example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('validates supported endpoint fragments when using hash suffix', () => {
|
||||
expect(validateApiHost('https://api.example.com/v1/chat/completions#')).toBe(true)
|
||||
expect(validateApiHost('https://api.example.com/v1/unknown#')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeToEndpoint', () => {
|
||||
it('returns host without endpoint when not using hash suffix', () => {
|
||||
expect(routeToEndpoint(' https://api.example.com/v1 ')).toEqual({
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
endpoint: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts known endpoint and base url when using hash suffix', () => {
|
||||
expect(routeToEndpoint('https://api.example.com/v1/chat/completions#')).toEqual({
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
endpoint: 'chat/completions'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty endpoint when unsupported endpoint fragment is provided', () => {
|
||||
expect(routeToEndpoint('https://api.example.com/v1/custom#')).toEqual({
|
||||
baseURL: 'https://api.example.com/v1/custom',
|
||||
endpoint: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the most specific endpoint match when multiple matches exist', () => {
|
||||
expect(routeToEndpoint('https://api.example.com/v1/streamGenerateContent#')).toEqual({
|
||||
baseURL: 'https://api.example.com/v1',
|
||||
endpoint: 'streamGenerateContent'
|
||||
})
|
||||
})
|
||||
|
||||
it('extract OpenAI images generations endpoint', () => {
|
||||
expect(routeToEndpoint('https://open.cherryin.net/v1/images/generations#')).toEqual({
|
||||
baseURL: 'https://open.cherryin.net/v1',
|
||||
endpoint: 'images/generations'
|
||||
})
|
||||
})
|
||||
|
||||
it('extract Gemini images generation endpoint', () => {
|
||||
expect(routeToEndpoint('https://open.cherryin.net/v1beta/models/imagen-4.0-generate-001:predict#')).toEqual({
|
||||
baseURL: 'https://open.cherryin.net/v1beta/models/imagen-4.0-generate-001',
|
||||
endpoint: 'predict'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatApiKeys', () => {
|
||||
it('normalizes chinese commas and new lines', () => {
|
||||
expect(formatApiKeys('key1,key2\nkey3')).toBe('key1,key2,key3')
|
||||
})
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(formatApiKeys('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAzureOpenAIApiHost', () => {
|
||||
it('normalizes trailing segments and disables auto version append', () => {
|
||||
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/')).toBe(
|
||||
'https://example.openai.azure.com/openai'
|
||||
)
|
||||
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/openai/')).toBe(
|
||||
'https://example.openai.azure.com/openai'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatVertexApiHost', () => {
|
||||
it('builds default google endpoint when host absent', () => {
|
||||
expect(formatVertexApiHost(createVertexProvider(''))).toBe(
|
||||
'https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers default endpoint when host ends with google domain', () => {
|
||||
expect(formatVertexApiHost(createVertexProvider('https://aiplatform.googleapis.com'))).toBe(
|
||||
'https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends api version to custom host', () => {
|
||||
expect(formatVertexApiHost(createVertexProvider('https://custom.googleapis.com/vertex'))).toBe(
|
||||
'https://custom.googleapis.com/vertex/v1'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses global endpoint when location equals global', () => {
|
||||
getStateMock.mockReturnValueOnce({
|
||||
llm: {
|
||||
settings: {
|
||||
vertexai: {
|
||||
projectId: 'global-project',
|
||||
location: 'global'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(formatVertexApiHost(createVertexProvider(''))).toBe(
|
||||
'https://aiplatform.googleapis.com/v1/projects/global-project/locations/global'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { runAsyncFunction } from '../index'
|
||||
import { hasPath, isValidProxyUrl, removeQuotes, removeSpecialCharacters } from '../index'
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Unclassified Utils', () => {
|
||||
describe('runAsyncFunction', () => {
|
||||
it('should execute async function', async () => {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isJSON, parseJSON } from '../index'
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
llm: {
|
||||
settings: {}
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('json', () => {
|
||||
describe('isJSON', () => {
|
||||
it('should return true for valid JSON string', () => {
|
||||
|
||||
+157
-17
@@ -1,3 +1,7 @@
|
||||
import store from '@renderer/store'
|
||||
import { VertexProvider } from '@renderer/types'
|
||||
import { trim } from 'lodash'
|
||||
|
||||
/**
|
||||
* 格式化 API key 字符串。
|
||||
*
|
||||
@@ -9,30 +13,166 @@ export function formatApiKeys(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 API 主机地址。
|
||||
* 判断 host 的 path 中是否包含形如版本的字符串(例如 /v1、/v2beta 等),
|
||||
*
|
||||
* 根据传入的 host 判断是否需要在其末尾加 `/v1/`。
|
||||
* - 不加:host 以 `/` 结尾,或以 `volces.com/api/v3` 结尾。
|
||||
* - 要加:其余情况。
|
||||
*
|
||||
* @param {string} host - 需要格式化的 API 主机地址。
|
||||
* @param {string} apiVersion - 需要添加的 API 版本。
|
||||
* @returns {string} 格式化后的 API 主机地址。
|
||||
* @param host - 要检查的 host 或 path 字符串
|
||||
* @returns 如果 path 中包含版本字符串则返回 true,否则 false
|
||||
*/
|
||||
export function formatApiHost(host: string, apiVersion: string = 'v1'): string {
|
||||
if (!host) {
|
||||
export function hasAPIVersion(host?: string): boolean {
|
||||
if (!host) return false
|
||||
|
||||
// 匹配路径中以 `/v<number>` 开头并可选跟随 `alpha` 或 `beta` 的版本段,
|
||||
// 该段后面可以跟 `/` 或字符串结束(用于匹配诸如 `/v3alpha/resources` 的情况)。
|
||||
const versionRegex = /\/v\d+(?:alpha|beta)?(?=\/|$)/i
|
||||
|
||||
try {
|
||||
const url = new URL(host)
|
||||
return versionRegex.test(url.pathname)
|
||||
} catch {
|
||||
// 若无法作为完整 URL 解析,则当作路径直接检测
|
||||
return versionRegex.test(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the trailing slash from a URL string if it exists.
|
||||
*
|
||||
* @template T - The string type to preserve type safety
|
||||
* @param {T} url - The URL string to process
|
||||
* @returns {T} The URL string without a trailing slash
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* withoutTrailingSlash('https://example.com/') // 'https://example.com'
|
||||
* withoutTrailingSlash('https://example.com') // 'https://example.com'
|
||||
* ```
|
||||
*/
|
||||
export function withoutTrailingSlash<T extends string>(url: T): T {
|
||||
return url.replace(/\/$/, '') as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an API host URL by normalizing it and optionally appending an API version.
|
||||
*
|
||||
* @param host - The API host URL to format. Leading/trailing whitespace will be trimmed and trailing slashes removed.
|
||||
* @param isSupportedAPIVerion - Whether the API version is supported. Defaults to `true`.
|
||||
* @param apiVersion - The API version to append if needed. Defaults to `'v1'`.
|
||||
*
|
||||
* @returns The formatted API host URL. If the host is empty after normalization, returns an empty string.
|
||||
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host as-is.
|
||||
* Otherwise, returns the host with the API version appended.
|
||||
*
|
||||
* @example
|
||||
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
|
||||
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#'
|
||||
* formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2'
|
||||
*/
|
||||
export function formatApiHost(host?: string, isSupportedAPIVerion: boolean = true, apiVersion: string = 'v1'): string {
|
||||
const normalizedHost = withoutTrailingSlash(trim(host))
|
||||
if (!normalizedHost) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const forceUseOriginalHost = () => {
|
||||
if (host.endsWith('/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return host.endsWith('volces.com/api/v3')
|
||||
if (normalizedHost.endsWith('#') || !isSupportedAPIVerion || hasAPIVersion(normalizedHost)) {
|
||||
return normalizedHost
|
||||
}
|
||||
return `${normalizedHost}/${apiVersion}`
|
||||
}
|
||||
|
||||
return forceUseOriginalHost() ? host : `${host}/${apiVersion}/`
|
||||
/**
|
||||
* 格式化 Azure OpenAI 的 API 主机地址。
|
||||
*/
|
||||
export function formatAzureOpenAIApiHost(host: string): string {
|
||||
const normalizedHost = withoutTrailingSlash(host)
|
||||
?.replace(/\/v1$/, '')
|
||||
.replace(/\/openai$/, '')
|
||||
// NOTE: AISDK会添加上`v1`
|
||||
return formatApiHost(normalizedHost + '/openai', false)
|
||||
}
|
||||
|
||||
export function formatVertexApiHost(provider: VertexProvider): string {
|
||||
const { apiHost } = provider
|
||||
const { projectId: project, location } = store.getState().llm.settings.vertexai
|
||||
const trimmedHost = withoutTrailingSlash(trim(apiHost))
|
||||
if (!trimmedHost || trimmedHost.endsWith('aiplatform.googleapis.com')) {
|
||||
const host =
|
||||
location == 'global' ? 'https://aiplatform.googleapis.com' : `https://${location}-aiplatform.googleapis.com`
|
||||
return `${formatApiHost(host)}/projects/${project}/locations/${location}`
|
||||
}
|
||||
return formatApiHost(trimmedHost)
|
||||
}
|
||||
|
||||
// 目前对话界面只支持这些端点
|
||||
export const SUPPORTED_IMAGE_ENDPOINT_LIST = ['images/generations', 'images/edits', 'predict'] as const
|
||||
export const SUPPORTED_ENDPOINT_LIST = [
|
||||
'chat/completions',
|
||||
'responses',
|
||||
'messages',
|
||||
'generateContent',
|
||||
'streamGenerateContent',
|
||||
...SUPPORTED_IMAGE_ENDPOINT_LIST
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Converts an API host URL into separate base URL and endpoint components.
|
||||
*
|
||||
* @param apiHost - The API host string to parse. Expected to be a trimmed URL that may end with '#' followed by an endpoint identifier.
|
||||
* @returns An object containing:
|
||||
* - `baseURL`: The base URL without the endpoint suffix
|
||||
* - `endpoint`: The matched endpoint identifier, or empty string if no match found
|
||||
*
|
||||
* @description
|
||||
* This function extracts endpoint information from a composite API host string.
|
||||
* If the host ends with '#', it attempts to match the preceding part against the supported endpoint list.
|
||||
* The '#' delimiter is removed before processing.
|
||||
*
|
||||
* @example
|
||||
* routeToEndpoint('https://api.example.com/openai/chat/completions#')
|
||||
* // Returns: { baseURL: 'https://api.example.com/v1', endpoint: 'chat/completions' }
|
||||
*
|
||||
* @example
|
||||
* routeToEndpoint('https://api.example.com/v1')
|
||||
* // Returns: { baseURL: 'https://api.example.com/v1', endpoint: '' }
|
||||
*/
|
||||
export function routeToEndpoint(apiHost: string): { baseURL: string; endpoint: string } {
|
||||
const trimmedHost = trim(apiHost)
|
||||
// 前面已经确保apiHost合法
|
||||
if (!trimmedHost.endsWith('#')) {
|
||||
return { baseURL: trimmedHost, endpoint: '' }
|
||||
}
|
||||
// 去掉结尾的 #
|
||||
const host = trimmedHost.slice(0, -1)
|
||||
const endpointMatch = SUPPORTED_ENDPOINT_LIST.find((endpoint) => host.endsWith(endpoint))
|
||||
if (!endpointMatch) {
|
||||
const baseURL = withoutTrailingSlash(host)
|
||||
return { baseURL, endpoint: '' }
|
||||
}
|
||||
const baseSegment = host.slice(0, host.length - endpointMatch.length)
|
||||
const baseURL = withoutTrailingSlash(baseSegment).replace(/:$/, '') // 去掉结尾可能存在的冒号(gemini的特殊情况)
|
||||
return { baseURL, endpoint: endpointMatch }
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API 主机地址是否合法。
|
||||
*
|
||||
* @param {string} apiHost - 需要验证的 API 主机地址。
|
||||
* @returns {boolean} 如果是合法的 URL 则返回 true,否则返回 false。
|
||||
*/
|
||||
export function validateApiHost(apiHost: string): boolean {
|
||||
// 允许apiHost为空
|
||||
if (!apiHost || !trim(apiHost)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const url = new URL(trim(apiHost))
|
||||
// 验证协议是否为 http 或 https
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { Model, ModelType, Provider } from '@renderer/types'
|
||||
import { Model, ModelType } from '@renderer/types'
|
||||
import { ModalFuncProps } from 'antd'
|
||||
import { isEqual } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -196,19 +196,6 @@ export function getMcpConfigSampleFromReadme(readme: string): Record<string, any
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 OpenAI 兼容的提供商
|
||||
* @param {Provider} provider 提供商对象
|
||||
* @returns {boolean} 是否为 OpenAI 兼容提供商
|
||||
*/
|
||||
export function isOpenAIProvider(provider: Provider): boolean {
|
||||
return !['anthropic', 'gemini', 'vertexai'].includes(provider.type)
|
||||
}
|
||||
|
||||
export function isAnthropicProvider(provider: Provider): boolean {
|
||||
return provider.type === 'anthropic'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模型是否为用户手动选择
|
||||
* @param {Model} model 模型对象
|
||||
|
||||
Reference in New Issue
Block a user