diff --git a/packages/adapter-gemini/src/client.ts b/packages/adapter-gemini/src/client.ts index c75ec47a5..e7c703038 100644 --- a/packages/adapter-gemini/src/client.ts +++ b/packages/adapter-gemini/src/client.ts @@ -126,9 +126,11 @@ export class GeminiClient extends PlatformModelAndEmbeddingsClient 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') @@ -142,6 +144,32 @@ export class GeminiClient extends PlatformModelAndEmbeddingsClient // --- 将原始模型转换并展开为变体列表 --- const models: ModelInfo[] = [] + for (const model of this._config.additionalModels) { + const modelNameLower = model.model.toLowerCase() + const isEmbedding = model.modelType === 'Embeddings 嵌入模型' + + const baseInfo: ModelInfo = { + name: model.model, + maxTokens: model.contextSize, + type: isEmbedding ? ModelType.embeddings : ModelType.llm, + capabilities: isEmbedding + ? [] + : modelNameLower.includes('gemini') + ? createGeminiCapabilities(modelNameLower, false) + : model.modelCapabilities + } + + if ( + !expandModelVariants( + models, + baseInfo, + this._config.imageModelSearch + ) + ) { + models.push(baseInfo) + } + } + for (const model of rawModels) { const modelNameLower = model.name.toLowerCase() @@ -162,14 +190,21 @@ export class GeminiClient extends PlatformModelAndEmbeddingsClient } // 尝试展开特殊变体;未命中则直接加入 + const items: ModelInfo[] = [] if ( !expandModelVariants( - models, + items, baseInfo, this._config.imageModelSearch ) ) { - models.push(baseInfo) + items.push(baseInfo) + } + + for (const item of items) { + if (models.findIndex((info) => info.name === item.name) < 0) { + models.push(item) + } } } 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..86161bbd5 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 models. Gemini models use adapter suffix variants automatically, and non-Gemini models are requested with the exact configured name.' + $inner: + model: 'Model name.' + modelType: 'Model type.' + contextSize: 'Context size.' + modelCapabilities: + $desc: 'Capabilities for non-Gemini models.' + $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..5c28cd4f6 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: '额外模型列表。Gemini 模型会自动展开适配器支持的后缀变体,非 Gemini 模型会按填写的名称原样请求。' + $inner: + model: '模型名称。' + modelType: '模型类型。' + contextSize: '模型上下文大小。' + modelCapabilities: + $desc: '非 Gemini 模型支持的能力。' + $inner: + - '文本输入' + - '工具调用' + - '图片视觉输入' + - '图像生成' + - '音频输入' + - '视频输入' + - '文件输入' apiKeys: $inner: - 'Gemini API Key' diff --git a/packages/adapter-gemini/src/utils.ts b/packages/adapter-gemini/src/utils.ts index 3f8ce0e1d..0141241fa 100644 --- a/packages/adapter-gemini/src/utils.ts +++ b/packages/adapter-gemini/src/utils.ts @@ -547,6 +547,23 @@ export function prepareModelConfig( let imageSize: string | undefined let forceGoogleSearch = false + const extra = pluginConfig.additionalModels.find( + (item) => + item.model === model && !item.model.toLowerCase().includes('gemini') + ) + + if (extra) { + 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) diff --git a/packages/adapter-gemini/tests/client.spec.ts b/packages/adapter-gemini/tests/client.spec.ts new file mode 100644 index 000000000..9e82b304e --- /dev/null +++ b/packages/adapter-gemini/tests/client.spec.ts @@ -0,0 +1,119 @@ +/// + +import { assert } from 'chai' +import { + ModelCapabilities, + ModelType +} from 'koishi-plugin-chatluna/llm-core/platform/types' +import { GeminiClient } from '../src/client' +import { prepareModelConfig } from '../src/utils' + +function client(cfg, raw = []) { + let calls = 0 + const item = Object.create(GeminiClient.prototype) + + Object.assign(item, { + _config: cfg, + _requester: { + getModels: async () => { + calls++ + return raw + } + } + }) + + return { + item, + get calls() { + return calls + } + } +} + +it('GeminiClient expands additional Gemini models without pulling remote models', async () => { + const cfg = { + pullModels: false, + imageModelSearch: false, + additionalModels: [ + { + model: 'gemini-2.5-pro', + modelType: 'LLM 大语言模型', + modelCapabilities: [ModelCapabilities.ToolCall], + contextSize: 8192 + } + ] + } + const mock = client(cfg) + + const models = await mock.item.refreshModels() + + assert.equal(mock.calls, 0) + assert.deepEqual( + models.map((model) => model.name), + [ + 'gemini-2.5-pro-non-thinking', + 'gemini-2.5-pro-thinking', + 'gemini-2.5-pro' + ] + ) + assert.equal(models[0].type, ModelType.llm) + assert.equal(models[0].maxTokens, 8192) +}) + +it('GeminiClient keeps additional non-Gemini model names unchanged', async () => { + const cfg = { + pullModels: false, + imageModelSearch: true, + additionalModels: [ + { + model: 'chatluna-chat-search', + modelType: 'LLM 大语言模型', + modelCapabilities: [ + ModelCapabilities.TextInput, + ModelCapabilities.ToolCall + ], + contextSize: 4096 + } + ] + } + const mock = client(cfg) + + const models = await mock.item.refreshModels() + + assert.deepEqual(models, [ + { + name: 'chatluna-chat-search', + type: ModelType.llm, + capabilities: [ + ModelCapabilities.TextInput, + ModelCapabilities.ToolCall + ], + maxTokens: 4096 + } + ]) +}) + +it('prepareModelConfig preserves configured non-Gemini request model names', () => { + const cfg = { + thinkingBudget: 77, + imageGeneration: true, + additionalModels: [ + { + model: 'chatluna-chat-search', + modelType: 'LLM 大语言模型', + modelCapabilities: [ + ModelCapabilities.TextInput, + ModelCapabilities.ToolCall + ], + contextSize: 4096 + } + ] + } + + const model = prepareModelConfig({ model: 'chatluna-chat-search' }, cfg) + + assert.equal(model.model, 'chatluna-chat-search') + assert.equal(model.forceGoogleSearch, false) + assert.equal(model.imageGeneration, true) + assert.equal(model.thinkingBudget, 77) +}) diff --git a/packages/adapter-gemini/tests/tsconfig.json b/packages/adapter-gemini/tests/tsconfig.json new file mode 100644 index 000000000..356ff1c23 --- /dev/null +++ b/packages/adapter-gemini/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "types": ["node", "mocha", "chai"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +}