fix: enhance provider handling and API key rotation logic in AiProvider (#11586)

* fix: enhance provider handling and API key rotation logic in AiProvider

* fix

* fix(api): enhance API key handling and logging for providers
This commit is contained in:
SuYao 2025-12-05 13:25:54 +08:00 committed by GitHub
parent a566cd65f4
commit 92bb05950d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 120 additions and 57 deletions

View File

@ -120,9 +120,12 @@ export default class ModernAiProvider {
throw new Error('Model is required for completions. Please use constructor with model parameter.') throw new Error('Model is required for completions. Please use constructor with model parameter.')
} }
// 每次请求时重新生成配置以确保API key轮换生效 // Config is now set in constructor, ApiService handles key rotation before passing provider
this.config = providerToAiSdkConfig(this.actualProvider, this.model) if (!this.config) {
logger.debug('Generated provider config for completions', this.config) // If config wasn't set in constructor (when provider only), generate it now
this.config = providerToAiSdkConfig(this.actualProvider, this.model!)
}
logger.debug('Using provider config for completions', this.config)
// 检查 config 是否存在 // 检查 config 是否存在
if (!this.config) { if (!this.config) {

View File

@ -37,32 +37,6 @@ import { azureAnthropicProviderCreator } from './config/azure-anthropic'
import { COPILOT_DEFAULT_HEADERS } from './constants' import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory' import { getAiSdkProviderId } from './factory'
/**
* API key
* legacy架构的多key轮询逻辑
*/
function getRotatedApiKey(provider: Provider): string {
const keys = provider.apiKey.split(',').map((key) => key.trim())
const keyName = `provider:${provider.id}:last_used_key`
if (keys.length === 1) {
return keys[0]
}
const lastUsedKey = window.keyv.get(keyName)
if (!lastUsedKey) {
window.keyv.set(keyName, keys[0])
return keys[0]
}
const currentIndex = keys.indexOf(lastUsedKey)
const nextIndex = (currentIndex + 1) % keys.length
const nextKey = keys[nextIndex]
window.keyv.set(keyName, nextKey)
return nextKey
}
/** /**
* provider的转换逻辑 * provider的转换逻辑
*/ */
@ -171,7 +145,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost) const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
const baseConfig = { const baseConfig = {
baseURL: baseURL, baseURL: baseURL,
apiKey: getRotatedApiKey(actualProvider) apiKey: actualProvider.apiKey
} }
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot

View File

@ -8,8 +8,8 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod
import { getStoreSetting } from '@renderer/hooks/useSettings' import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import type { FetchChatCompletionParams } from '@renderer/types'
import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types' import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
import { type FetchChatCompletionParams, isSystemProvider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { type Chunk, ChunkType } from '@renderer/types/chunk' import { type Chunk, ChunkType } from '@renderer/types/chunk'
import type { Message, ResponseError } from '@renderer/types/newMessage' import type { Message, ResponseError } from '@renderer/types/newMessage'
@ -21,7 +21,8 @@ import { purifyMarkdownImages } from '@renderer/utils/markdown'
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools' import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt' import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt'
import { isEmpty, takeRight } from 'lodash' import { NOT_SUPPORT_API_KEY_PROVIDER_TYPES, NOT_SUPPORT_API_KEY_PROVIDERS } from '@renderer/utils/provider'
import { cloneDeep, isEmpty, takeRight } from 'lodash'
import type { ModernAiProviderConfig } from '../aiCore/index_new' import type { ModernAiProviderConfig } from '../aiCore/index_new'
import AiProviderNew from '../aiCore/index_new' import AiProviderNew from '../aiCore/index_new'
@ -42,6 +43,8 @@ import {
// } from './MessagesService' // } from './MessagesService'
// import WebSearchService from './WebSearchService' // import WebSearchService from './WebSearchService'
// FIXME: 这里太多重复逻辑,需要重构
const logger = loggerService.withContext('ApiService') const logger = loggerService.withContext('ApiService')
export async function fetchMcpTools(assistant: Assistant) { export async function fetchMcpTools(assistant: Assistant) {
@ -94,7 +97,15 @@ export async function fetchChatCompletion({
modelId: assistant.model?.id, modelId: assistant.model?.id,
modelName: assistant.model?.name modelName: assistant.model?.name
}) })
const AI = new AiProviderNew(assistant.model || getDefaultModel())
// Get base provider and apply API key rotation
const baseProvider = getProviderByModel(assistant.model || getDefaultModel())
const providerWithRotatedKey = {
...cloneDeep(baseProvider),
apiKey: getRotatedApiKey(baseProvider)
}
const AI = new AiProviderNew(assistant.model || getDefaultModel(), providerWithRotatedKey)
const provider = AI.getActualProvider() const provider = AI.getActualProvider()
const mcpTools: MCPTool[] = [] const mcpTools: MCPTool[] = []
@ -171,7 +182,13 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
return null return null
} }
const AI = new AiProviderNew(model) // Apply API key rotation
const providerWithRotatedKey = {
...cloneDeep(provider),
apiKey: getRotatedApiKey(provider)
}
const AI = new AiProviderNew(model, providerWithRotatedKey)
const topicId = messages?.find((message) => message.topicId)?.topicId || '' const topicId = messages?.find((message) => message.topicId)?.topicId || ''
@ -270,7 +287,13 @@ export async function fetchNoteSummary({ content, assistant }: { content: string
return null return null
} }
const AI = new AiProviderNew(model) // Apply API key rotation
const providerWithRotatedKey = {
...cloneDeep(provider),
apiKey: getRotatedApiKey(provider)
}
const AI = new AiProviderNew(model, providerWithRotatedKey)
// only 2000 char and no images // only 2000 char and no images
const truncatedContent = content.substring(0, 2000) const truncatedContent = content.substring(0, 2000)
@ -358,7 +381,13 @@ export async function fetchGenerate({
return '' return ''
} }
const AI = new AiProviderNew(model) // Apply API key rotation
const providerWithRotatedKey = {
...cloneDeep(provider),
apiKey: getRotatedApiKey(provider)
}
const AI = new AiProviderNew(model, providerWithRotatedKey)
const assistant = getDefaultAssistant() const assistant = getDefaultAssistant()
assistant.model = model assistant.model = model
@ -403,43 +432,91 @@ export async function fetchGenerate({
export function hasApiKey(provider: Provider) { export function hasApiKey(provider: Provider) {
if (!provider) return false if (!provider) return false
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true if (provider.id === 'cherryai') return true
if (
(isSystemProvider(provider) && NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) ||
NOT_SUPPORT_API_KEY_PROVIDER_TYPES.includes(provider.type)
)
return true
return !isEmpty(provider.apiKey) return !isEmpty(provider.apiKey)
} }
/** /**
* Get the first available embedding model from enabled providers * Get rotated API key for providers that support multiple keys
* Returns empty string for providers that don't require API keys
*/ */
// function getFirstEmbeddingModel() { function getRotatedApiKey(provider: Provider): string {
// const providers = store.getState().llm.providers.filter((p) => p.enabled) // Handle providers that don't require API keys
if (!provider.apiKey || provider.apiKey.trim() === '') {
return ''
}
// for (const provider of providers) { const keys = provider.apiKey
// const embeddingModel = provider.models.find((model) => isEmbeddingModel(model)) .split(',')
// if (embeddingModel) { .map((key) => key.trim())
// return embeddingModel .filter(Boolean)
// }
// }
// return undefined if (keys.length === 0) {
// } return ''
}
const keyName = `provider:${provider.id}:last_used_key`
// If only one key, return it directly
if (keys.length === 1) {
return keys[0]
}
const lastUsedKey = window.keyv.get(keyName)
if (!lastUsedKey) {
window.keyv.set(keyName, keys[0])
return keys[0]
}
const currentIndex = keys.indexOf(lastUsedKey)
// Log when the last used key is no longer in the list
if (currentIndex === -1) {
logger.debug('Last used API key no longer found in provider keys, falling back to first key', {
providerId: provider.id,
lastUsedKey: lastUsedKey.substring(0, 8) + '...' // Only log first 8 chars for security
})
}
const nextIndex = (currentIndex + 1) % keys.length
const nextKey = keys[nextIndex]
window.keyv.set(keyName, nextKey)
return nextKey
}
export async function fetchModels(provider: Provider): Promise<Model[]> { export async function fetchModels(provider: Provider): Promise<Model[]> {
const AI = new AiProviderNew(provider) // Apply API key rotation
const providerWithRotatedKey = {
...cloneDeep(provider),
apiKey: getRotatedApiKey(provider)
}
const AI = new AiProviderNew(providerWithRotatedKey)
try { try {
return await AI.models() return await AI.models()
} catch (error) { } catch (error) {
logger.error('Failed to fetch models from provider', {
providerId: provider.id,
providerName: provider.name,
error: error as Error
})
return [] return []
} }
} }
export function checkApiProvider(provider: Provider): void { export function checkApiProvider(provider: Provider): void {
if ( const isExcludedProvider =
provider.id !== 'ollama' && (isSystemProvider(provider) && NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) ||
provider.id !== 'lmstudio' && NOT_SUPPORT_API_KEY_PROVIDER_TYPES.includes(provider.type)
provider.type !== 'vertexai' &&
provider.id !== 'copilot' if (!isExcludedProvider) {
) {
if (!provider.apiKey) { if (!provider.apiKey) {
window.toast.error(i18n.t('message.error.enter.api.label')) window.toast.error(i18n.t('message.error.enter.api.label'))
throw new Error(i18n.t('message.error.enter.api.label')) throw new Error(i18n.t('message.error.enter.api.label'))
@ -460,8 +537,7 @@ export function checkApiProvider(provider: Provider): void {
export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> { export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> {
checkApiProvider(provider) checkApiProvider(provider)
// Don't pass in provider parameter. We need auto-format URL const ai = new AiProviderNew(model, provider)
const ai = new AiProviderNew(model)
const assistant = getDefaultAssistant() const assistant = getDefaultAssistant()
assistant.model = model assistant.model = model

View File

@ -187,3 +187,13 @@ export const isSupportAPIVersionProvider = (provider: Provider) => {
} }
return provider.apiOptions?.isNotSupportAPIVersion !== false return provider.apiOptions?.isNotSupportAPIVersion !== false
} }
export const NOT_SUPPORT_API_KEY_PROVIDERS: readonly SystemProviderId[] = [
'ollama',
'lmstudio',
'vertexai',
'aws-bedrock',
'copilot'
]
export const NOT_SUPPORT_API_KEY_PROVIDER_TYPES: readonly ProviderType[] = ['vertexai', 'aws-bedrock']