Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f8d0b06ee | |||
| 4be5fedeec | |||
| 163e016759 |
@@ -120,9 +120,12 @@ export default class ModernAiProvider {
|
||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||
}
|
||||
|
||||
// 每次请求时重新生成配置以确保API key轮换生效
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||
logger.debug('Generated provider config for completions', this.config)
|
||||
// Config is now set in constructor, ApiService handles key rotation before passing provider
|
||||
if (!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 是否存在
|
||||
if (!this.config) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||
import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio'
|
||||
import { getAssistantSettings } from '@renderer/services/AssistantService'
|
||||
import type { RootState } from '@renderer/store'
|
||||
import type {
|
||||
Assistant,
|
||||
GenerateImageParams,
|
||||
@@ -246,20 +245,23 @@ export abstract class BaseApiClient<
|
||||
|
||||
protected getVerbosity(model?: Model): OpenAIVerbosity {
|
||||
try {
|
||||
const state = window.store?.getState() as RootState
|
||||
const state = window.store?.getState()
|
||||
const verbosity = state?.settings?.openAI?.verbosity
|
||||
|
||||
// If model is provided, check if the verbosity is supported by the model
|
||||
if (model) {
|
||||
const supportedVerbosity = getModelSupportedVerbosity(model)
|
||||
// Use user's verbosity if supported, otherwise use the first supported option
|
||||
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
|
||||
if (verbosity && ['low', 'medium', 'high'].includes(verbosity)) {
|
||||
// If model is provided, check if the verbosity is supported by the model
|
||||
if (model) {
|
||||
const supportedVerbosity = getModelSupportedVerbosity(model)
|
||||
// Use user's verbosity if supported, otherwise use the first supported option
|
||||
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
|
||||
}
|
||||
return verbosity
|
||||
}
|
||||
return verbosity
|
||||
} catch (error) {
|
||||
logger.warn('Failed to get verbosity from state. Fallback to undefined.', error as Error)
|
||||
return undefined
|
||||
logger.warn('Failed to get verbosity from state:', error as Error)
|
||||
}
|
||||
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
protected getTimeout(model: Model) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
isSupportedThinkingTokenModel,
|
||||
isSupportedThinkingTokenQwenModel,
|
||||
isSupportedThinkingTokenZhipuModel,
|
||||
isSupportVerbosityModel,
|
||||
isVisionModel,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT,
|
||||
ZHIPU_RESULT_TOKENS
|
||||
@@ -713,8 +714,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
...modalities,
|
||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||
// verbosity. getVerbosity ensure the returned value is valid.
|
||||
verbosity: this.getVerbosity(model),
|
||||
...(isSupportVerbosityModel(model)
|
||||
? {
|
||||
text: {
|
||||
verbosity: this.getVerbosity(model)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
...reasoningEffort,
|
||||
// ...getOpenAIWebSearchParams(model, enableWebSearch),
|
||||
|
||||
@@ -29,32 +29,6 @@ import { azureAnthropicProviderCreator } from './config/azure-anthropic'
|
||||
import { COPILOT_DEFAULT_HEADERS } from './constants'
|
||||
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的转换逻辑
|
||||
*/
|
||||
@@ -161,7 +135,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
|
||||
const baseConfig = {
|
||||
baseURL: baseURL,
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
apiKey: actualProvider.apiKey
|
||||
}
|
||||
|
||||
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
|
||||
|
||||
@@ -222,22 +222,18 @@ describe('model utils', () => {
|
||||
|
||||
describe('getModelSupportedVerbosity', () => {
|
||||
it('returns only "high" for GPT-5 Pro models', () => {
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, null, 'high'])
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([
|
||||
undefined,
|
||||
null,
|
||||
'high'
|
||||
])
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, 'high'])
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([undefined, 'high'])
|
||||
})
|
||||
|
||||
it('returns all levels for non-Pro GPT-5 models', () => {
|
||||
const previewModel = createModel({ id: 'gpt-5-preview' })
|
||||
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, null, 'low', 'medium', 'high'])
|
||||
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
|
||||
})
|
||||
|
||||
it('returns all levels for GPT-5.1 models', () => {
|
||||
const gpt51Model = createModel({ id: 'gpt-5.1-preview' })
|
||||
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, null, 'low', 'medium', 'high'])
|
||||
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, 'low', 'medium', 'high'])
|
||||
})
|
||||
|
||||
it('returns only undefined for non-GPT-5 models', () => {
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
isGPT51SeriesModel,
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isOpenAIReasoningModel,
|
||||
isSupportVerbosityModel
|
||||
isOpenAIReasoningModel
|
||||
} from './openai'
|
||||
import { isQwenMTModel } from './qwen'
|
||||
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
|
||||
@@ -155,10 +154,10 @@ const MODEL_SUPPORTED_VERBOSITY: readonly {
|
||||
* For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported.
|
||||
* For GPT-5.1 series models, 'low', 'medium', and 'high' are supported.
|
||||
* @param model - The model to check
|
||||
* @returns An array of supported verbosity levels, always including `undefined` as the first element and `null` when applicable
|
||||
* @returns An array of supported verbosity levels, always including `undefined` as the first element
|
||||
*/
|
||||
export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => {
|
||||
if (!model || !isSupportVerbosityModel(model)) {
|
||||
if (!model) {
|
||||
return [undefined]
|
||||
}
|
||||
|
||||
@@ -166,7 +165,7 @@ export const getModelSupportedVerbosity = (model: Model | undefined | null): Ope
|
||||
|
||||
for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) {
|
||||
if (validator(model)) {
|
||||
supportedValues = [null, ...values]
|
||||
supportedValues = [...values]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
type VerbosityOption = {
|
||||
value: NonNullable<OpenAIVerbosity> | 'undefined' | 'null'
|
||||
value: NonNullable<OpenAIVerbosity> | 'undefined'
|
||||
label: string
|
||||
}
|
||||
|
||||
type SummaryTextOption = {
|
||||
value: NonNullable<OpenAISummaryText> | 'undefined' | 'null'
|
||||
value: NonNullable<OpenAISummaryText> | 'undefined'
|
||||
label: string
|
||||
}
|
||||
|
||||
@@ -85,10 +85,6 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
||||
value: 'undefined',
|
||||
label: t('common.ignore')
|
||||
},
|
||||
{
|
||||
value: 'null',
|
||||
label: t('common.off')
|
||||
},
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('settings.openai.summary_text_mode.auto')
|
||||
@@ -109,10 +105,6 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
||||
value: 'undefined',
|
||||
label: t('common.ignore')
|
||||
},
|
||||
{
|
||||
value: 'null',
|
||||
label: t('common.off')
|
||||
},
|
||||
{
|
||||
value: 'low',
|
||||
label: t('settings.openai.verbosity.low')
|
||||
@@ -211,9 +203,9 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={toOptionValue(summaryText)}
|
||||
value={summaryText}
|
||||
onChange={(value) => {
|
||||
setSummaryText(toRealValue(value))
|
||||
setSummaryText(value as OpenAISummaryText)
|
||||
}}
|
||||
options={summaryTextOptions}
|
||||
/>
|
||||
@@ -230,9 +222,9 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={toOptionValue(verbosity)}
|
||||
value={verbosity}
|
||||
onChange={(value) => {
|
||||
setVerbosity(toRealValue(value))
|
||||
setVerbosity(value as OpenAIVerbosity)
|
||||
}}
|
||||
options={verbosityOptions}
|
||||
/>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import type { FetchChatCompletionParams } 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 Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import type { Message, ResponseError } from '@renderer/types/newMessage'
|
||||
@@ -22,7 +22,8 @@ import { purifyMarkdownImages } from '@renderer/utils/markdown'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt'
|
||||
import { isEmpty, takeRight } from 'lodash'
|
||||
import { NOT_SUPPORT_API_KEY_PROVIDERS } from '@renderer/utils/provider'
|
||||
import { cloneDeep, isEmpty, takeRight } from 'lodash'
|
||||
|
||||
import type { ModernAiProviderConfig } from '../aiCore/index_new'
|
||||
import AiProviderNew from '../aiCore/index_new'
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
// } from './MessagesService'
|
||||
// import WebSearchService from './WebSearchService'
|
||||
|
||||
// FIXME: 这里太多重复逻辑,需要重构
|
||||
|
||||
const logger = loggerService.withContext('ApiService')
|
||||
|
||||
export async function fetchMcpTools(assistant: Assistant) {
|
||||
@@ -95,7 +98,15 @@ export async function fetchChatCompletion({
|
||||
modelId: assistant.model?.id,
|
||||
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 mcpTools: MCPTool[] = []
|
||||
@@ -172,7 +183,13 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
|
||||
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 || ''
|
||||
|
||||
@@ -271,7 +288,13 @@ export async function fetchNoteSummary({ content, assistant }: { content: string
|
||||
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
|
||||
const truncatedContent = content.substring(0, 2000)
|
||||
@@ -359,7 +382,13 @@ export async function fetchGenerate({
|
||||
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()
|
||||
assistant.model = model
|
||||
@@ -404,28 +433,44 @@ export async function fetchGenerate({
|
||||
|
||||
export function hasApiKey(provider: Provider) {
|
||||
if (!provider) return false
|
||||
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true
|
||||
if (isSystemProvider(provider) && NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) return true
|
||||
return !isEmpty(provider.apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first available embedding model from enabled providers
|
||||
* 获取轮询的API key
|
||||
* 复用legacy架构的多key轮询逻辑
|
||||
*/
|
||||
// function getFirstEmbeddingModel() {
|
||||
// const providers = store.getState().llm.providers.filter((p) => p.enabled)
|
||||
function getRotatedApiKey(provider: Provider): string {
|
||||
const keys = provider.apiKey.split(',').map((key) => key.trim())
|
||||
const keyName = `provider:${provider.id}:last_used_key`
|
||||
|
||||
// for (const provider of providers) {
|
||||
// const embeddingModel = provider.models.find((model) => isEmbeddingModel(model))
|
||||
// if (embeddingModel) {
|
||||
// return embeddingModel
|
||||
// }
|
||||
// }
|
||||
if (keys.length === 1) {
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// return undefined
|
||||
// }
|
||||
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
|
||||
}
|
||||
|
||||
export async function fetchModels(provider: Provider): Promise<SdkModel[]> {
|
||||
const AI = new AiProviderNew(provider)
|
||||
// Apply API key rotation
|
||||
const providerWithRotatedKey = {
|
||||
...cloneDeep(provider),
|
||||
apiKey: getRotatedApiKey(provider)
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(providerWithRotatedKey)
|
||||
|
||||
try {
|
||||
return await AI.models()
|
||||
@@ -435,12 +480,7 @@ export async function fetchModels(provider: Provider): Promise<SdkModel[]> {
|
||||
}
|
||||
|
||||
export function checkApiProvider(provider: Provider): void {
|
||||
if (
|
||||
provider.id !== 'ollama' &&
|
||||
provider.id !== 'lmstudio' &&
|
||||
provider.type !== 'vertexai' &&
|
||||
provider.id !== 'copilot'
|
||||
) {
|
||||
if (isSystemProvider(provider) && !NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) {
|
||||
if (!provider.apiKey) {
|
||||
window.toast.error(i18n.t('message.error.enter.api.label'))
|
||||
throw new Error(i18n.t('message.error.enter.api.label'))
|
||||
@@ -461,8 +501,7 @@ export function checkApiProvider(provider: Provider): void {
|
||||
export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> {
|
||||
checkApiProvider(provider)
|
||||
|
||||
// Don't pass in provider parameter. We need auto-format URL
|
||||
const ai = new AiProviderNew(model)
|
||||
const ai = new AiProviderNew(model, provider)
|
||||
|
||||
const assistant = getDefaultAssistant()
|
||||
assistant.model = model
|
||||
|
||||
@@ -2906,23 +2906,6 @@ const migrateConfig = {
|
||||
logger.error('migrate 179 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'180': (state: RootState) => {
|
||||
try {
|
||||
// @ts-expect-error
|
||||
if (state.settings.openAI.summaryText === 'undefined') {
|
||||
state.settings.openAI.summaryText = undefined
|
||||
}
|
||||
// @ts-expect-error
|
||||
if (state.settings.openAI.verbosity === 'undefined') {
|
||||
state.settings.openAI.summaryText = undefined
|
||||
}
|
||||
logger.info('migrate 180 success')
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 180 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type OpenAI from '@cherrystudio/openai'
|
||||
import type { NotUndefined } from '@types'
|
||||
import type { NotNull, NotUndefined } from '@types'
|
||||
import type { ImageModel, LanguageModel } from 'ai'
|
||||
import type { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
|
||||
import * as z from 'zod'
|
||||
@@ -32,15 +32,17 @@ export type GenerateObjectParams = Omit<Parameters<typeof generateObject>[0], 'm
|
||||
export type AiSdkModel = LanguageModel | ImageModel
|
||||
|
||||
// The original type unite both undefined and null.
|
||||
// I pick undefined as the unique falsy type since they seem like share the same meaning according to OpenAI API docs.
|
||||
// Parameter would not be passed into request if it's undefined.
|
||||
export type OpenAIVerbosity = OpenAI.Responses.ResponseTextConfig['verbosity']
|
||||
export type OpenAIVerbosity = NotNull<OpenAI.Responses.ResponseTextConfig['verbosity']>
|
||||
export type ValidOpenAIVerbosity = NotUndefined<OpenAIVerbosity>
|
||||
|
||||
export type OpenAIReasoningEffort = OpenAI.ReasoningEffort
|
||||
|
||||
// The original type unite both undefined and null.
|
||||
// I pick undefined as the unique falsy type since they seem like share the same meaning according to OpenAI API docs.
|
||||
// Parameter would not be passed into request if it's undefined.
|
||||
export type OpenAISummaryText = OpenAI.Reasoning['summary']
|
||||
export type OpenAISummaryText = NotNull<OpenAI.Reasoning['summary']>
|
||||
|
||||
const AiSdkParamsSchema = z.enum([
|
||||
'maxOutputTokens',
|
||||
|
||||
@@ -128,6 +128,10 @@ export type OpenAIExtraBody = {
|
||||
source_lang: 'auto'
|
||||
target_lang: string
|
||||
}
|
||||
// for gpt-5 series models verbosity control
|
||||
text?: {
|
||||
verbosity?: 'low' | 'medium' | 'high'
|
||||
}
|
||||
}
|
||||
// image is for openrouter. audio is ignored for now
|
||||
export type OpenAIModality = OpenAI.ChatCompletionModality | 'image'
|
||||
|
||||
@@ -183,3 +183,11 @@ export const isSupportAPIVersionProvider = (provider: Provider) => {
|
||||
}
|
||||
return provider.apiOptions?.isNotSupportAPIVersion !== false
|
||||
}
|
||||
|
||||
export const NOT_SUPPORT_API_KEY_PROVIDERS: readonly SystemProviderId[] = [
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'vertexai',
|
||||
'aws-bedrock',
|
||||
'copilot'
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user