From 8021fd82e64eeb52d66f5766ed371765c580bea0 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 16 May 2026 00:24:04 +0800 Subject: [PATCH 1/3] [Fix] fallback from unavailable conversation models --- .../src/middlewares/model/resolve_model.ts | 5 +- packages/core/src/services/conversation.ts | 56 ++++++++++++++++--- .../core/tests/conversation-service.spec.ts | 40 +++++++++++++ packages/core/tests/helpers.ts | 31 ++++++++++ 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/packages/core/src/middlewares/model/resolve_model.ts b/packages/core/src/middlewares/model/resolve_model.ts index 94861bf9f..64ced5fb9 100644 --- a/packages/core/src/middlewares/model/resolve_model.ts +++ b/packages/core/src/middlewares/model/resolve_model.ts @@ -20,10 +20,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { try { const modelName = - resolved.effectiveModel ?? - resolved.conversation?.model ?? - config.defaultModel ?? - 'empty' + resolved.effectiveModel ?? config.defaultModel ?? 'empty' const presetName = resolved.effectivePreset ?? resolved.conversation?.preset ?? diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 9e29e3899..4b1633867 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' import type { Context, Session } from 'koishi' +import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' +import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import type { Config } from '../config' import { deserializeConversation, @@ -116,6 +118,34 @@ function matchTargetConversation( ) } +function hasModel(ctx: Context, model?: string | null) { + if ( + model == null || + model.trim().length < 1 || + model === '无' || + model === 'empty' + ) { + return false + } + + const [platform, name] = parseRawModelName(model) + + if (platform == null || name == null) { + return false + } + + const models = ctx.chatluna.platform.listPlatformModels( + platform, + ModelType.llm + ).value + + return models.length > 0 && models.some((m) => m.name === name) +} + +function pickModel(ctx: Context, models: (string | null | undefined)[]) { + return models.find((model) => hasModel(ctx, model)) ?? null +} + export class ConversationService { private readonly _bindingLocks = new Map() private readonly _titleLocks = new Map() @@ -287,11 +317,12 @@ export class ConversationService { presetLane: getPresetLane(bindingKey), binding: binding ?? null, conversation: allowedConversation, - effectiveModel: - constraint.fixedModel ?? - allowedConversation?.model ?? - constraint.defaultModel ?? - this.config.defaultModel, + effectiveModel: pickModel(this.ctx, [ + constraint.fixedModel, + allowedConversation?.model, + constraint.defaultModel, + this.config.defaultModel + ]), effectivePreset: constraint.fixedPreset ?? allowedConversation?.preset ?? @@ -364,8 +395,12 @@ export class ConversationService { current = { ...current, conversation, - effectiveModel: - current.constraint.fixedModel ?? conversation.model, + effectiveModel: pickModel(this.ctx, [ + current.constraint.fixedModel, + conversation.model, + current.constraint.defaultModel, + this.config.defaultModel + ]), effectivePreset: current.constraint.fixedPreset ?? conversation.preset, @@ -398,7 +433,12 @@ export class ConversationService { mode, conversation, conversationId: conversation.id, - effectiveModel: conversation.model, + effectiveModel: pickModel(this.ctx, [ + current.constraint.fixedModel, + conversation.model, + current.constraint.defaultModel, + this.config.defaultModel + ]), effectivePreset: conversation.preset, effectiveChatMode: conversation.chatMode } diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts index afb357b9c..5a5a87375 100644 --- a/packages/core/tests/conversation-service.spec.ts +++ b/packages/core/tests/conversation-service.spec.ts @@ -123,6 +123,46 @@ it('ConversationService defaults resolveConversation mode to context and sets nu assert.equal(resolved.conversationId, null) }) +it('ConversationService skips unavailable models before using config default', async () => { + const conversation = createConversation({ + model: 'missing-platform/old-model' + }) + const { service } = await createService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_constraint: [ + { + id: 1, + name: 'unavailable-models', + enabled: true, + priority: 10, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + users: null, + excludeUsers: null, + fixedModel: 'missing-platform/fixed-model', + defaultModel: 'missing-platform/default-model' + } as unknown as TableRow + ] + } + }) + + const resolved = await service.resolveConversation(createSession(), { + mode: 'context' + }) + + assert.equal(resolved.effectiveModel, 'test-platform/test-model') +}) + it('ConversationService resolveConversation uses explicit binding key constraints', async () => { const remote = createConversation({ id: 'conversation-remote-binding', diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 92e5008df..148434cc9 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -12,6 +12,7 @@ import { } from '../src/services/conversation_types' import { ChatLunaService } from '../src/services/chat' import { ConversationService } from '../src/services/conversation' +import { ModelType } from '../src/llm-core/platform/types' export async function expectRejected( promise: Promise, @@ -312,6 +313,24 @@ export async function createService( } }, chatluna: { + platform: { + chatChains: { + value: [{ name: 'plugin' }] + }, + listPlatformModels: (platform: string) => ({ + value: + platform === 'test-platform' + ? [ + { + name: 'test-model', + type: ModelType.llm, + maxTokens: 4096, + capabilities: [] + } + ] + : [] + }) + }, conversation: { getArchive: async (id: string) => database.tables.chatluna_archive.find( @@ -364,6 +383,18 @@ export async function createMemoryService( app.plugin(memory) app.plugin(ChatLunaService, createConfig(options.config)) await app.start() + ;( + app.chatluna.platform as unknown as { + _models: Record + } + )._models['test-platform'] = [ + { + name: 'test-model', + type: ModelType.llm, + maxTokens: 4096, + capabilities: [] + } + ] for (const [table, rows] of Object.entries(options.tables ?? {})) { for (const row of rows ?? []) { From 13789ac32ca2da316b72db82a3aab8b8c04aab59 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 16 May 2026 00:29:24 +0800 Subject: [PATCH 2/3] [Refactor] keep conversation model fallback in service --- packages/core/src/services/conversation.ts | 74 ++++++++++++---------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 4b1633867..9764fa802 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -118,34 +118,6 @@ function matchTargetConversation( ) } -function hasModel(ctx: Context, model?: string | null) { - if ( - model == null || - model.trim().length < 1 || - model === '无' || - model === 'empty' - ) { - return false - } - - const [platform, name] = parseRawModelName(model) - - if (platform == null || name == null) { - return false - } - - const models = ctx.chatluna.platform.listPlatformModels( - platform, - ModelType.llm - ).value - - return models.length > 0 && models.some((m) => m.name === name) -} - -function pickModel(ctx: Context, models: (string | null | undefined)[]) { - return models.find((model) => hasModel(ctx, model)) ?? null -} - export class ConversationService { private readonly _bindingLocks = new Map() private readonly _titleLocks = new Map() @@ -317,12 +289,12 @@ export class ConversationService { presetLane: getPresetLane(bindingKey), binding: binding ?? null, conversation: allowedConversation, - effectiveModel: pickModel(this.ctx, [ + effectiveModel: this.pickModel( constraint.fixedModel, allowedConversation?.model, constraint.defaultModel, this.config.defaultModel - ]), + ), effectivePreset: constraint.fixedPreset ?? allowedConversation?.preset ?? @@ -395,12 +367,12 @@ export class ConversationService { current = { ...current, conversation, - effectiveModel: pickModel(this.ctx, [ + effectiveModel: this.pickModel( current.constraint.fixedModel, conversation.model, current.constraint.defaultModel, this.config.defaultModel - ]), + ), effectivePreset: current.constraint.fixedPreset ?? conversation.preset, @@ -433,12 +405,12 @@ export class ConversationService { mode, conversation, conversationId: conversation.id, - effectiveModel: pickModel(this.ctx, [ + effectiveModel: this.pickModel( current.constraint.fixedModel, conversation.model, current.constraint.defaultModel, this.config.defaultModel - ]), + ), effectivePreset: conversation.preset, effectiveChatMode: conversation.chatMode } @@ -1935,6 +1907,40 @@ export class ConversationService { await fs.mkdir(target, { recursive: true }) return target } + + private pickModel(...models: (string | null | undefined)[]) { + for (const model of models) { + if ( + model == null || + model.trim().length < 1 || + model === '无' || + model === 'empty' + ) { + continue + } + + const [platform, name] = parseRawModelName(model) + + if (platform == null || name == null) { + continue + } + + const platformModels = + this.ctx.chatluna.platform.listPlatformModels( + platform, + ModelType.llm + ).value + + if ( + platformModels.length > 0 && + platformModels.some((m) => m.name === name) + ) { + return model + } + } + + return null + } } function isConstraintMatched(constraint: ConstraintRecord, session: Session) { From 2b1bdb3a587d550ead51b0a9261a87cbb861a306 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 16 May 2026 00:52:29 +0800 Subject: [PATCH 3/3] [Fix] reuse conversation model fallback everywhere --- .../middlewares/chat/chat_time_limit_check.ts | 6 +- .../src/middlewares/chat/read_chat_message.ts | 5 +- .../src/middlewares/chat/rollback_chat.ts | 3 +- .../src/middlewares/model/resolve_model.ts | 5 +- .../middlewares/system/conversation_manage.ts | 22 +++++-- packages/core/src/services/conversation.ts | 60 +++++++++++-------- .../core/tests/conversation-service.spec.ts | 5 +- .../extension-agent/src/trigger/executor.ts | 10 ++-- 8 files changed, 75 insertions(+), 41 deletions(-) diff --git a/packages/core/src/middlewares/chat/chat_time_limit_check.ts b/packages/core/src/middlewares/chat/chat_time_limit_check.ts index 8d5b59378..58edea8d4 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_check.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_check.ts @@ -124,7 +124,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } return { - model: resolved.effectiveModel ?? conversation.model, + model: + ctx.chatluna.conversation.pickModel( + resolved.constraint, + conversation + ) ?? conversation.model, conversationId: conversation.id } } diff --git a/packages/core/src/middlewares/chat/read_chat_message.ts b/packages/core/src/middlewares/chat/read_chat_message.ts index c03288d83..7c97418a0 100644 --- a/packages/core/src/middlewares/chat/read_chat_message.ts +++ b/packages/core/src/middlewares/chat/read_chat_message.ts @@ -141,7 +141,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { await ctx.chatluna.messageTransformer.transform( session, message, - resolved.effectiveModel ?? '', + ctx.chatluna.conversation.pickModel( + resolved.constraint, + resolved.conversation + ) ?? '', undefined, { quote: false, diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 3998f7328..3497a63fe 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -219,7 +219,8 @@ async function rollbackConversation( inputMessage = await ctx.chatluna.messageTransformer.transform( session, transformMessageContentToElements(humanContent), - resolved.effectiveModel ?? current.model, + ctx.chatluna.conversation.pickModel(resolved.constraint, current) ?? + current.model, undefined, { quote: false, diff --git a/packages/core/src/middlewares/model/resolve_model.ts b/packages/core/src/middlewares/model/resolve_model.ts index 64ced5fb9..37c6f97fc 100644 --- a/packages/core/src/middlewares/model/resolve_model.ts +++ b/packages/core/src/middlewares/model/resolve_model.ts @@ -20,7 +20,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { try { const modelName = - resolved.effectiveModel ?? config.defaultModel ?? 'empty' + ctx.chatluna.conversation.pickModel( + resolved.constraint, + resolved.conversation + ) ?? 'empty' const presetName = resolved.effectivePreset ?? resolved.conversation?.preset ?? diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index 3c9d32629..d9df2faba 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -128,7 +128,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session.text('chatluna.conversation.default_title'), model: createModel ?? - resolved.effectiveModel ?? + ctx.chatluna.conversation.pickModel( + resolved.constraint, + resolved.conversation + ) ?? config.defaultModel, preset: createPreset ?? @@ -223,6 +226,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const pagination = new Pagination({ formatItem: (item) => formatConversationLine( + ctx, session, item.conversation, resolved, @@ -265,7 +269,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.message = [ session.text('chatluna.conversation.messages.current_header'), - formatConversationLine(session, resolved.conversation, resolved) + formatConversationLine( + ctx, + session, + resolved.conversation, + resolved + ) ].join('\n') return ChainMiddlewareRunStatus.STOP }) @@ -998,6 +1007,7 @@ function formatConversationError( } function formatConversationLine( + ctx: Context, session: Session, conversation: ConversationRecord, resolved: ResolvedConversationContext, @@ -1010,10 +1020,10 @@ function formatConversationLine( resolved.binding?.activeConversationId ) const effectiveModel = - resolved.constraint.fixedModel ?? - conversation.model ?? - resolved.constraint.defaultModel ?? - '-' + ctx.chatluna.conversation.pickModel( + resolved.constraint, + conversation + ) ?? '-' const effectivePreset = resolved.constraint.fixedPreset ?? conversation.preset ?? diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 9764fa802..3e0724e87 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -283,18 +283,17 @@ export class ConversationService { )) ? conversation : null + const effectiveModel = this.pickModel(constraint, allowedConversation) return { bindingKey, presetLane: getPresetLane(bindingKey), binding: binding ?? null, - conversation: allowedConversation, - effectiveModel: this.pickModel( - constraint.fixedModel, - allowedConversation?.model, - constraint.defaultModel, - this.config.defaultModel - ), + conversation: + allowedConversation != null && effectiveModel != null + ? { ...allowedConversation, model: effectiveModel } + : allowedConversation, + effectiveModel, effectivePreset: constraint.fixedPreset ?? allowedConversation?.preset ?? @@ -328,7 +327,7 @@ export class ConversationService { return { ...target, mode, - conversation, + conversation: target.conversation ?? conversation, conversationId: conversation.id } } @@ -364,15 +363,18 @@ export class ConversationService { current.bindingKey )) ) { + const effectiveModel = this.pickModel( + current.constraint, + conversation + ) + current = { ...current, - conversation, - effectiveModel: this.pickModel( - current.constraint.fixedModel, - conversation.model, - current.constraint.defaultModel, - this.config.defaultModel - ), + conversation: + effectiveModel != null + ? { ...conversation, model: effectiveModel } + : conversation, + effectiveModel, effectivePreset: current.constraint.fixedPreset ?? conversation.preset, @@ -399,18 +401,20 @@ export class ConversationService { conversationId: current.conversation.id } ) + const effectiveModel = this.pickModel( + current.constraint, + conversation + ) return { ...current, mode, - conversation, + conversation: + effectiveModel != null + ? { ...conversation, model: effectiveModel } + : conversation, conversationId: conversation.id, - effectiveModel: this.pickModel( - current.constraint.fixedModel, - conversation.model, - current.constraint.defaultModel, - this.config.defaultModel - ), + effectiveModel, effectivePreset: conversation.preset, effectiveChatMode: conversation.chatMode } @@ -1908,8 +1912,16 @@ export class ConversationService { return target } - private pickModel(...models: (string | null | undefined)[]) { - for (const model of models) { + pickModel( + constraint: ResolvedConstraint, + conversation?: ConversationRecord | null + ) { + for (const model of [ + constraint.fixedModel, + conversation?.model, + constraint.defaultModel, + this.config.defaultModel + ]) { if ( model == null || model.trim().length < 1 || diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts index 5a5a87375..6595c7e7c 100644 --- a/packages/core/tests/conversation-service.spec.ts +++ b/packages/core/tests/conversation-service.spec.ts @@ -156,11 +156,10 @@ it('ConversationService skips unavailable models before using config default', a } }) - const resolved = await service.resolveConversation(createSession(), { - mode: 'context' - }) + const resolved = await service.ensureActiveConversation(createSession()) assert.equal(resolved.effectiveModel, 'test-platform/test-model') + assert.equal(resolved.conversation.model, 'test-platform/test-model') }) it('ConversationService resolveConversation uses explicit binding key constraints', async () => { diff --git a/packages/extension-agent/src/trigger/executor.ts b/packages/extension-agent/src/trigger/executor.ts index b6cedbdc4..c1ebda702 100644 --- a/packages/extension-agent/src/trigger/executor.ts +++ b/packages/extension-agent/src/trigger/executor.ts @@ -208,10 +208,12 @@ export class ChatLunaAgentTriggerExecutor { constraint.fixedPreset ?? constraint.defaultPreset ?? this.ctx.chatluna.config.defaultPreset - const model = - constraint.fixedModel ?? - constraint.defaultModel ?? - this.ctx.chatluna.config.defaultModel + const model = this.ctx.chatluna.conversation.pickModel(constraint) + + if (model == null) { + throw new Error('No available model found.') + } + const chatMode = constraint.fixedChatMode ?? constraint.defaultChatMode ??