Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions packages/adapter-gemini/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,60 +121,95 @@
* - gemini-3 系列:thinking 等级变体(low / medium / high / minimal)
* - 其他模型:直接加入,不展开
*/
async refreshModels(config?: RunnableConfig): Promise<ModelInfo[]> {
// --- 获取原始模型列表 ---
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')
)
}
} catch (e) {
if (e instanceof ChatLunaError) throw e
throw new ChatLunaError(ChatLunaErrorCode.MODEL_INIT_ERROR, e)
}

// --- 将原始模型转换并展开为变体列表 ---
const models: ModelInfo[] = []

for (const model of this._config.additionalModels) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在 Koishi 插件中,当用户更新插件但尚未在控制台重新保存配置时,新引入的配置项 additionalModels 可能会是 undefined。直接对其进行 for...of 遍历会导致运行时报错 TypeError: ... is not iterable

建议添加空值保护,例如使用空数组作为回退值。

Suggested change
for (const model of this._config.additionalModels) {
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)
}
}
Comment on lines +147 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

补充模型分支缺少按名称去重,可能返回重复模型

Line 147-171 当前把 additionalModels 展开后直接写入 models,但只在 Line 205 对远端展开结果做了去重。
当手动配置里出现重复项,或同时配置基础模型与其变体(如 gemini-2.5-pro + gemini-2.5-pro-thinking)时,refreshModels() 会返回重复 name,与最终列表去重预期不一致。

建议修复(在 additionalModels 分支也走同样的去重路径)
-        for (const model of this._config.additionalModels) {
+        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)
-            }
+            const items: ModelInfo[] = []
+            if (
+                !expandModelVariants(
+                    items,
+                    baseInfo,
+                    this._config.imageModelSearch
+                )
+            ) {
+                items.push(baseInfo)
+            }
+
+            for (const item of items) {
+                if (models.findIndex((info) => info.name === item.name) < 0) {
+                    models.push(item)
+                }
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapter-gemini/src/client.ts` around lines 147 - 171, The
additionalModels branch currently pushes baseInfo into models without name-based
deduplication, causing duplicate model names when manual config includes repeats
or base+variant pairs; update the logic handling this._config.additionalModels
so it uses the same expansion + dedupe path as the remote results—invoke
expandModelVariants(models, baseInfo, this._config.imageModelSearch) as before
and, if it returns false, only push baseInfo when no existing entry in models
has the same name (compare ModelInfo.name), or reuse the same dedupe routine
used later in refreshModels(); ensure references: additionalModels, baseInfo,
expandModelVariants, models, and refreshModels().


for (const model of rawModels) {
const modelNameLower = model.name.toLowerCase()

if (shouldFilterOutGeminiModel(modelNameLower)) {
continue
}

const isEmbedding = modelNameLower.includes('embedding')

const baseInfo: ModelInfo = {
name: model.name,
maxTokens: model.inputTokenLimit,
type: isEmbedding ? ModelType.embeddings : ModelType.llm,
capabilities: createGeminiCapabilities(
modelNameLower,
isEmbedding
)
}

// 尝试展开特殊变体;未命中则直接加入
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)
}
}
}

return models
}

Check notice on line 212 in packages/adapter-gemini/src/client.ts

View check run for this annotation

codefactor.io / CodeFactor

packages/adapter-gemini/src/client.ts#L124-L212

Complex Method

// #endregion

Expand Down
37 changes: 37 additions & 0 deletions packages/adapter-gemini/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -58,6 +66,35 @@ export const Config: Schema<Config> = 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(''),
Expand Down
17 changes: 17 additions & 0 deletions packages/adapter-gemini/src/locales/en-US.schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
17 changes: 17 additions & 0 deletions packages/adapter-gemini/src/locales/zh-CN.schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
17 changes: 17 additions & 0 deletions packages/adapter-gemini/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
)
Comment on lines +550 to +553

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

同理,在 prepareModelConfig 中,pluginConfig.additionalModels 在配置未更新时可能为 undefined。直接调用 .find() 会导致运行时抛出 TypeError: Cannot read properties of undefined (reading 'find') 异常。

建议使用可选链(optional chaining)进行安全调用。

Suggested change
const extra = pluginConfig.additionalModels.find(
(item) =>
item.model === model && !item.model.toLowerCase().includes('gemini')
)
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)
Expand Down
119 changes: 119 additions & 0 deletions packages/adapter-gemini/tests/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/// <reference types="mocha" />

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)
})
9 changes: 9 additions & 0 deletions packages/adapter-gemini/tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"types": ["node", "mocha", "chai"]
},
"include": ["./**/*.ts", "../src/**/*.ts"]
}
Loading