diff --git a/packages/adapter-gemini/src/client.ts b/packages/adapter-gemini/src/client.ts index c75ec47a5..64e9ff80e 100644 --- a/packages/adapter-gemini/src/client.ts +++ b/packages/adapter-gemini/src/client.ts @@ -24,7 +24,8 @@ import { RunnableConfig } from '@langchain/core/runnables' import { GeminiModelInfo } from './types' import { createGeminiCapabilities, - expandModelVariants, + getModelVariantSuffixes, + isGeminiModelName, shouldFilterOutGeminiModel } from './utils' @@ -115,20 +116,18 @@ export class GeminiClient extends PlatformModelAndEmbeddingsClient // #region refreshModels /** - * 从 API 获取模型列表,并将每个模型展开为对应的所有变体: - * - 图片生成模型:分辨率 + 搜索后缀变体 - * - gemini-2.5 系列:-thinking / -non-thinking 变体 - * - gemini-3 系列:thinking 等级变体(low / medium / high / minimal) - * - 其他模型:直接加入,不展开 + * 从配置与 API 获取模型列表,并将特定系列展开为变体 + * (图片分辨率 / 搜索后缀、thinking 开关与等级)。 */ async refreshModels(config?: RunnableConfig): Promise { - // --- 获取原始模型列表 --- let rawModels: GeminiModelInfo[] = [] try { - rawModels = await this._requester.getModels(config) + rawModels = this._config.pullModels + ? await this._requester.getModels(config) + : [] - if (rawModels.length === 0) { + if (this._config.pullModels && rawModels.length === 0) { throw new ChatLunaError( ChatLunaErrorCode.MODEL_INIT_ERROR, new Error('No model found') @@ -139,38 +138,66 @@ export class GeminiClient extends PlatformModelAndEmbeddingsClient throw new ChatLunaError(ChatLunaErrorCode.MODEL_INIT_ERROR, e) } - // --- 将原始模型转换并展开为变体列表 --- - const models: ModelInfo[] = [] + const items: ModelInfo[] = this._config.additionalModels.map( + (model) => { + const name = model.model.toLowerCase() + const isEmbedding = model.modelType === 'Embeddings 嵌入模型' + + return { + name: model.model, + maxTokens: model.contextSize, + type: isEmbedding ? ModelType.embeddings : ModelType.llm, + capabilities: isEmbedding + ? [] + : isGeminiModelName(name) + ? createGeminiCapabilities(name, false) + : model.modelCapabilities + } + } + ) for (const model of rawModels) { - const modelNameLower = model.name.toLowerCase() + const name = model.name.toLowerCase() - if (shouldFilterOutGeminiModel(modelNameLower)) { - continue - } + if (shouldFilterOutGeminiModel(name)) continue - const isEmbedding = modelNameLower.includes('embedding') + const isEmbedding = name.includes('embedding') - const baseInfo: ModelInfo = { + items.push({ name: model.name, maxTokens: model.inputTokenLimit, type: isEmbedding ? ModelType.embeddings : ModelType.llm, - capabilities: createGeminiCapabilities( - modelNameLower, - isEmbedding - ) + capabilities: createGeminiCapabilities(name, isEmbedding) + }) + } + + const models: ModelInfo[] = [] + const names = new Set() + const addModel = (model: ModelInfo) => { + const key = model.name.toLowerCase() + if (!names.has(key)) { + names.add(key) + models.push(model) } + } - // 尝试展开特殊变体;未命中则直接加入 - if ( - !expandModelVariants( - models, - baseInfo, - this._config.imageModelSearch - ) - ) { - models.push(baseInfo) + for (const model of items) { + const suffixes = getModelVariantSuffixes( + model.name.toLowerCase(), + this._config.imageModelSearch + ) + + for (const suffix of suffixes) { + addModel({ ...model, name: model.name + suffix }) } + addModel(model) + } + + if (models.length === 0) { + throw new ChatLunaError( + ChatLunaErrorCode.MODEL_INIT_ERROR, + new Error('No model configured') + ) } return models diff --git a/packages/adapter-gemini/src/index.ts b/packages/adapter-gemini/src/index.ts index da2f7d205..add3427a1 100644 --- a/packages/adapter-gemini/src/index.ts +++ b/packages/adapter-gemini/src/index.ts @@ -2,6 +2,7 @@ import { ChatLunaPlugin } from 'koishi-plugin-chatluna/services/chat' import { Context, Logger, Schema } from 'koishi' import { GeminiClient } from './client' import { createLogger } from 'koishi-plugin-chatluna/utils/logger' +import { ModelCapabilities } from 'koishi-plugin-chatluna/llm-core/platform/types' export let logger: Logger export const reusable = true @@ -38,6 +39,13 @@ export function apply(ctx: Context, config: Config) { export interface Config extends ChatLunaPlugin.Config { apiKeys: [string, string, boolean][] + pullModels: boolean + additionalModels: { + model: string + modelType: string + modelCapabilities: ModelCapabilities[] + contextSize: number + }[] maxContextRatio: number platform: string temperature: number @@ -58,6 +66,35 @@ export const Config: Schema = Schema.intersect([ ChatLunaPlugin.Config, Schema.object({ platform: Schema.string().default('gemini'), + pullModels: Schema.boolean().default(true), + additionalModels: Schema.array( + Schema.object({ + model: Schema.string(), + modelType: Schema.union([ + 'LLM 大语言模型', + 'Embeddings 嵌入模型' + ]).default('LLM 大语言模型'), + modelCapabilities: Schema.array( + Schema.union([ + ModelCapabilities.TextInput, + ModelCapabilities.ToolCall, + ModelCapabilities.ImageInput, + ModelCapabilities.ImageGeneration, + ModelCapabilities.AudioInput, + ModelCapabilities.VideoInput, + ModelCapabilities.FileInput + ]) + ) + .default([ + ModelCapabilities.TextInput, + ModelCapabilities.ToolCall + ]) + .role('checkbox'), + contextSize: Schema.number().default(128000) + }) + ) + .default([]) + .role('table'), apiKeys: Schema.array( Schema.tuple([ Schema.string().role('secret').default(''), diff --git a/packages/adapter-gemini/src/locales/en-US.schema.yml b/packages/adapter-gemini/src/locales/en-US.schema.yml index 1aff08c74..33ec53f29 100644 --- a/packages/adapter-gemini/src/locales/en-US.schema.yml +++ b/packages/adapter-gemini/src/locales/en-US.schema.yml @@ -2,6 +2,23 @@ $inner: - {} - $desc: 'API Configuration' platform: 'Adapter platform name. (Do not modify if you do not understand)' + pullModels: 'Auto-fetch model list. If disabled, use only specified models.' + additionalModels: + $desc: 'Additional model list.' + $inner: + model: 'Model name.' + modelType: 'Model type.' + contextSize: 'Context size.' + modelCapabilities: + $desc: 'Model capabilities.' + $inner: + - 'Text input' + - 'Tool calling' + - 'Visual image input' + - 'Image generation' + - 'Audio input' + - 'Video input' + - 'File input' apiKeys: $inner: - 'Gemini API Key' diff --git a/packages/adapter-gemini/src/locales/zh-CN.schema.yml b/packages/adapter-gemini/src/locales/zh-CN.schema.yml index 4175289b8..684b147aa 100644 --- a/packages/adapter-gemini/src/locales/zh-CN.schema.yml +++ b/packages/adapter-gemini/src/locales/zh-CN.schema.yml @@ -2,6 +2,23 @@ $inner: - {} - $desc: '请求选项' platform: '适配器的平台名。(不懂请不要修改)' + pullModels: '是否自动拉取模型列表。关闭后将仅使用下方指定的模型。' + additionalModels: + $desc: '额外模型列表。' + $inner: + model: '模型名称。' + modelType: '模型类型。' + contextSize: '模型上下文大小。' + modelCapabilities: + $desc: '模型支持的能力。' + $inner: + - '文本输入' + - '工具调用' + - '图片视觉输入' + - '图像生成' + - '音频输入' + - '视频输入' + - '文件输入' apiKeys: $inner: - 'Gemini API Key' diff --git a/packages/adapter-gemini/src/utils.ts b/packages/adapter-gemini/src/utils.ts index 3f8ce0e1d..4fc40dbfb 100644 --- a/packages/adapter-gemini/src/utils.ts +++ b/packages/adapter-gemini/src/utils.ts @@ -34,10 +34,7 @@ import { isZodSchemaV3 } from '@langchain/core/utils/types' import { generateSchema } from '@anatine/zod-openapi' import { deepAssign } from 'koishi-plugin-chatluna/utils/object' import { ClientConfig } from 'koishi-plugin-chatluna/llm-core/platform/config' -import { - ModelCapabilities, - ModelInfo -} from 'koishi-plugin-chatluna/llm-core/platform/types' +import { ModelCapabilities } from 'koishi-plugin-chatluna/llm-core/platform/types' export async function langchainMessageToGeminiMessage( messages: BaseMessage[], @@ -547,6 +544,22 @@ export function prepareModelConfig( let imageSize: string | undefined let forceGoogleSearch = false + if ( + pluginConfig.additionalModels.some( + (item) => item.model === model && !isGeminiModelName(item.model) + ) + ) { + return { + model, + enabledThinking, + thinkingBudget: pluginConfig.thinkingBudget ?? -1, + imageGeneration: pluginConfig.imageGeneration ?? false, + thinkingLevel: undefined, + imageSize, + forceGoogleSearch + } + } + if (model.toLowerCase().endsWith('-search')) { forceGoogleSearch = true model = model.slice(0, -'-search'.length) @@ -746,6 +759,11 @@ export function isChatResponse(response: any): response is ChatResponse { // #region refreshModels helpers +export function isGeminiModelName(model: string): boolean { + const name = model.toLowerCase().split('/').pop() ?? model.toLowerCase() + return /^gemini(?:-|$)/.test(name) +} + export function createGeminiCapabilities( modelNameLower: string, isEmbedding: boolean @@ -810,162 +828,49 @@ export function shouldFilterOutGeminiModel(modelNameLower: string): boolean { ) } -/** 支持 thinking 开关(-thinking / -non-thinking)的模型前缀 */ -const THINKING_MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash'] as const - -/** 支持 thinking 等级(-low/medium/high/minimal-thinking)的模型前缀 */ -const THINKING_LEVEL_MODELS = [ - 'gemini-3-pro', - 'gemini-3-flash', - 'gemini-3.1-pro' -] as const - -/** - * 带分辨率 / 搜索后缀变体的图片生成模型配置。 - * resolutions: 该模型支持的分辨率后缀列表 - * supportSearch: 是否同时生成 -search 变体 - */ -export const IMAGE_VARIANT_MODELS = [ - { - name: 'gemini-3-pro-image', - resolutions: ['2k', '4k'], - supportSearch: true - }, - { - name: 'gemini-3.1-flash-image', - resolutions: ['0.5k', '2k', '4k'], - supportSearch: true - } -] as const - -/** 判断 haystack 中是否包含 needles 里的任意一项 */ -export function includesAny( - needles: readonly string[], - haystack: string -): boolean { - return needles.some((name) => haystack.includes(name)) -} - -/** - * 将 base 模型连同所有 suffixes 变体一起压入 out 数组。 - * 变体先入,base 最后入,保持列表顺序直观。 - */ -export function pushExpanded( - out: ModelInfo[], - base: ModelInfo, - suffixes: readonly string[] -): void { - for (const suffix of suffixes) { - out.push({ ...base, name: base.name + suffix }) - } - out.push(base) -} - -/** - * 查找模型名是否命中 IMAGE_VARIANT_MODELS 中的某一项。 - * 命中则返回该配置,否则返回 undefined。 - */ -export function getImageVariantConfig(modelName: string) { - return IMAGE_VARIANT_MODELS.find((item) => modelName.includes(item.name)) -} - -/** - * 为图片生成模型生成所有分辨率变体并压入 out。 - * 仅当 imageModelSearch 为 true 且该模型配置 supportSearch 时, - * 才额外生成 -search / --search 后缀变体。 - */ -export function pushImageVariants( - out: ModelInfo[], - base: ModelInfo, - resolutions: readonly string[], - supportSearch: boolean, - imageModelSearch: boolean -): void { - const resolutionSuffixes = resolutions.map((r) => `-${r}`) - const searchSuffixes = - supportSearch && imageModelSearch - ? ['-search', ...resolutions.map((r) => `-${r}-search`)] - : [] - - pushExpanded(out, base, [...resolutionSuffixes, ...searchSuffixes]) -} - /** 判断是否属于 gemini-3-pro / gemini-3.1-pro 系列(影响 thinking 等级列表) */ export function isGemini3ProFamily(modelName: string): boolean { return /gemini-3(\.1)?-pro/.test(modelName) } -/** - * 判断模型是否支持 thinking 开关(gemini-2.5 系列,且不是图片生成模型)。 - */ -export function isThinkingModel(modelNameLower: string): boolean { - return ( - includesAny(THINKING_MODELS, modelNameLower) && - !modelNameLower.includes('image') - ) -} - -/** - * 判断模型是否支持 thinking 等级(gemini-3 系列,且不是图片生成模型)。 - */ -export function isThinkingLevelModel(modelNameLower: string): boolean { - return ( - includesAny(THINKING_LEVEL_MODELS, modelNameLower) && - !modelNameLower.includes('image') - ) -} +/** 图片生成模型支持的分辨率变体 */ +const IMAGE_MODEL_RESOLUTIONS: [string, string[]][] = [ + ['gemini-3-pro-image', ['-2k', '-4k']], + ['gemini-3.1-flash-image', ['-0.5k', '-2k', '-4k']] +] -/** - * 根据模型类型,将模型展开为所有变体后压入 models 数组。 - * imageModelSearch 控制图片模型是否生成 -search 后缀变体。 - * 返回 true 表示已处理(调用方应 continue),false 表示未命中任何特殊类型。 - */ -export function expandModelVariants( - models: ModelInfo[], - base: ModelInfo, +/** 计算模型需要展开的变体后缀(图片分辨率 / 搜索、thinking 开关与等级) */ +export function getModelVariantSuffixes( + name: string, imageModelSearch: boolean -): boolean { - const nameLower = base.name.toLowerCase() - - // 图片生成模型:展开分辨率变体,按配置决定是否附加搜索变体 - const imageVariantConfig = getImageVariantConfig(nameLower) - if (imageVariantConfig) { - pushImageVariants( - models, - base, - imageVariantConfig.resolutions, - imageVariantConfig.supportSearch, - imageModelSearch - ) - return true +): string[] { + const resolutions = IMAGE_MODEL_RESOLUTIONS.find(([model]) => + name.includes(model) + )?.[1] + + if (resolutions) { + if (!imageModelSearch) return resolutions + return [ + ...resolutions, + '-search', + ...resolutions.map((r) => `${r}-search`) + ] } - // gemini-2.5 系列:展开 -thinking / -non-thinking 变体 - if (isThinkingModel(nameLower)) { - if (nameLower.includes('-thinking')) { - // 已经是 thinking 变体,直接加入 - models.push(base) - } else { - pushExpanded(models, base, ['-non-thinking', '-thinking']) - } - return true - } + if (name.includes('image')) return [] - // gemini-3 系列:展开 thinking 等级变体 - if (isThinkingLevelModel(nameLower)) { - const suffixes = isGemini3ProFamily(nameLower) - ? ['-low-thinking', '-high-thinking', '-minimal-thinking'] - : [ - '-low-thinking', - '-high-thinking', - '-minimal-thinking', - '-medium-thinking' - ] - pushExpanded(models, base, suffixes) - return true + if (name.includes('gemini-2.5')) { + return name.includes('-thinking') ? [] : ['-non-thinking', '-thinking'] } - return false + if (!/gemini-3(-pro|-flash|\.5-flash|\.1-pro)/.test(name)) return [] + + // gemini-3-pro(不含 3.1)不提供 medium 等级 + return ( + /(^|\/)gemini-3-pro/.test(name) + ? ['low', 'high', 'minimal'] + : ['low', 'high', 'minimal', 'medium'] + ).map((level) => `-${level}-thinking`) } // #endregion