fix(ai): correct apiKeySet detection and prevent stale key reuse

- Fix apiKeySet reporting false positives for local no-key configs by
  resolving the actual key via resolveApiKey and excluding placeholders
- Prevent editing a config from reusing an incompatible API key when
  provider or connection mode has changed
- Pass summary strategy to single-session generation for consistency

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor authored and digua committed 2026-05-27 23:47:27 +08:00
1 parent a9b8599417
commit 88ab0ae172
11 files changed
+232 -18

No files matched your search

@@ -0,0 +1,52 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { toLlmConfigDisplay } from './ai-config-display'
describe('toLlmConfigDisplay', () => {
it('marks apiKeySet when provider fallback auth resolves a key', () => {
const result = toLlmConfigDisplay(
{
id: 'cfg-1',
name: 'OpenAI compatible',
provider: 'openai-compatible',
apiKey: '',
},
(provider, authProfile) => (provider === 'openai-compatible' && !authProfile ? 'sk-provider-fallback' : '')
)
assert.equal(result.apiKey, '')
assert.equal(result.apiKeySet, true)
})
it('does not mark the local no-key placeholder as a real API key', () => {
const result = toLlmConfigDisplay(
{
id: 'cfg-2',
name: 'LAN Ollama',
provider: 'openai-compatible',
apiKey: '',
baseUrl: 'http://127.0.0.1:11434',
authProfile: 'lan-ollama',
},
() => 'sk-no-key-required'
)
assert.equal(result.apiKey, '')
assert.equal(result.apiKeySet, false)
})
it('does not mark legacy plaintext apiKey as reusable', () => {
const result = toLlmConfigDisplay(
{
id: 'cfg-3',
name: 'Legacy OpenAI',
provider: 'openai',
apiKey: 'sk-legacy-plaintext',
},
() => ''
)
assert.equal(result.apiKey, '')
assert.equal(result.apiKeySet, false)
})
})
@@ -0,0 +1,21 @@
type LlmConfigRecord = Record<string, unknown>
type ResolveApiKey = (provider: string, authProfile?: string) => string | undefined
const NO_KEY_PLACEHOLDER = 'sk-no-key-required'
function hasRealApiKey(apiKey: string): boolean {
return !!apiKey && apiKey !== NO_KEY_PLACEHOLDER
}
export function toLlmConfigDisplay(config: LlmConfigRecord, resolveApiKey: ResolveApiKey): LlmConfigRecord {
const { apiKey: _rawApiKey, ...rest } = config
const provider = typeof config.provider === 'string' ? config.provider : ''
const authProfile = typeof config.authProfile === 'string' ? config.authProfile : undefined
const resolvedApiKey = provider ? resolveApiKey(provider, authProfile) : ''
return {
...rest,
apiKey: '',
apiKeySet: hasRealApiKey(resolvedApiKey || ''),
}
}
+3 -4
View File
@@ -46,6 +46,8 @@ import {
deleteCustomModel,
} from '../../ai/custom-store'
import { validateApiKey, fetchRemoteModels } from '../../ai/remote-api'
import { resolveApiKey } from '@openchatlab/config'
import { toLlmConfigDisplay } from './ai-config-display'
function getAiDir(dbManager: DatabaseManager): string {
const pathProvider = (dbManager as any)['pathProvider']
@@ -177,10 +179,7 @@ export function registerAiRoutes(
try {
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const configs = (data.configs || []).map((c: Record<string, unknown>) => {
const { apiKey: _k, ...rest } = c
return { ...rest, apiKey: '', apiKeySet: !!c.authProfile }
})
const configs = (data.configs || []).map((c: Record<string, unknown>) => toLlmConfigDisplay(c, resolveApiKey))
return {
configs,
defaultAssistant: data.defaultAssistant ?? null,
@@ -16,6 +16,7 @@ import {
generateSessionSummary,
checkSessionsCanGenerateSummary,
type SummaryDeps,
type SummaryOptions,
completeSimple,
type PiTextContent,
} from '../ai'
@@ -67,7 +68,7 @@ export async function generateSummary(
sessionId: string,
chatSessionId: number,
serviceDeps: SummaryServiceDeps,
options?: { locale?: string; forceRegenerate?: boolean }
options?: SummaryOptions
) {
const llmConfig = serviceDeps.getLlmConfig()
if (!llmConfig) {
@@ -83,7 +84,7 @@ export async function generateAllSummaries(
adapter: SessionRuntimeAdapter,
sessionId: string,
serviceDeps: SummaryServiceDeps,
options?: { locale?: string; forceRegenerate?: boolean }
options?: SummaryOptions
) {
const llmConfig = serviceDeps.getLlmConfig()
if (!llmConfig) {
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { useSessionIndexService } from '@/services'
import { getSummaryStrategy } from '@/composables/useUiConfig'
import { stopBatchSummaryGeneration } from './summaryGeneration'
const props = defineProps<{
open: boolean
@@ -330,10 +331,9 @@ async function startGenerate() {
}
}
// 停止生成:立即更新 UI 状态,后台请求自然结束不影响
// 停止生成:保持生成锁定,等待当前循环进入 finally 后统一释放。
function stopGenerate() {
shouldStop.value = true
isGenerating.value = false
stopBatchSummaryGeneration(shouldStop)
}
// 关闭弹窗
@@ -8,7 +8,9 @@ import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useSessionIndexService, type ChatSessionItem } from '@/services'
import { getSummaryStrategy } from '@/composables/useUiConfig'
import BatchSummaryModal from './BatchSummaryModal.vue'
import { buildGenerateSummaryArgs } from './summaryGeneration'
// 扁平化列表项类型
type FlatListItem =
@@ -229,7 +231,9 @@ async function generateSummary(session: ChatSessionItem, event: Event) {
try {
console.log('[SessionTimeline] 调用 IPC...')
const result = await useSessionIndexService().generateSummary(props.sessionId, session.id, locale.value)
const result = await useSessionIndexService().generateSummary(
...buildGenerateSummaryArgs(props.sessionId, session.id, locale.value, getSummaryStrategy())
)
console.log('[SessionTimeline] IPC 返回:', result)
if (result.success && result.summary) {
@@ -0,0 +1,23 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildGenerateSummaryArgs, stopBatchSummaryGeneration } from './summaryGeneration'
describe('stopBatchSummaryGeneration', () => {
it('keeps generation locked while the active loop is still exiting', () => {
const isGenerating = { value: true }
const shouldStop = { value: false }
stopBatchSummaryGeneration(shouldStop)
assert.equal(shouldStop.value, true)
assert.equal(isGenerating.value, true)
})
})
describe('buildGenerateSummaryArgs', () => {
it('passes summary strategy to single-session generation', () => {
const args = buildGenerateSummaryArgs('db-1', 12, 'zh-CN', 'brief')
assert.deepEqual(args, ['db-1', 12, 'zh-CN', false, 'brief'])
})
})
@@ -0,0 +1,18 @@
import type { SummaryStrategy } from '@/composables/useUiConfig'
export interface MutableBooleanRef {
value: boolean
}
export function stopBatchSummaryGeneration(shouldStop: MutableBooleanRef): void {
shouldStop.value = true
}
export function buildGenerateSummaryArgs(
dbSessionId: string,
chatSessionId: number,
locale: string,
strategy: SummaryStrategy
): [string, number, string, false, SummaryStrategy] {
return [dbSessionId, chatSessionId, locale, false, strategy]
}
@@ -0,0 +1,47 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { canReuseExistingApiKey } from './apiKeyReuse'
describe('canReuseExistingApiKey', () => {
it('requires a new key after changing provider', () => {
const result = canReuseExistingApiKey({
mode: 'edit',
existingApiKeySet: true,
hasNewApiKey: false,
originalProvider: 'openai',
currentProvider: 'anthropic',
originalConnectionMode: 'preset',
currentConnectionMode: 'preset',
})
assert.equal(result, false)
})
it('allows reusing an existing key when provider and connection mode are unchanged', () => {
const result = canReuseExistingApiKey({
mode: 'edit',
existingApiKeySet: true,
hasNewApiKey: false,
originalProvider: 'openai',
currentProvider: 'openai',
originalConnectionMode: 'preset',
currentConnectionMode: 'preset',
})
assert.equal(result, true)
})
it('requires a new key after changing connection mode', () => {
const result = canReuseExistingApiKey({
mode: 'edit',
existingApiKeySet: true,
hasNewApiKey: false,
originalProvider: 'openai-compatible',
currentProvider: 'openai',
originalConnectionMode: 'openai-compat',
currentConnectionMode: 'preset',
})
assert.equal(result, false)
})
})
@@ -0,0 +1,33 @@
export type ConnectionMode = 'preset' | 'local' | 'openai-compat'
export interface ApiKeyReuseInput {
mode: 'add' | 'edit'
existingApiKeySet?: boolean
hasNewApiKey: boolean
originalProvider?: string
currentProvider: string
originalConnectionMode?: ConnectionMode
currentConnectionMode: ConnectionMode
}
export function getConnectionModeForConfig(config: {
provider: string
apiKeySet: boolean
baseUrl?: string
}): ConnectionMode {
const isCompat = config.provider === 'openai-compatible' || config.provider.startsWith('custom:')
if (!isCompat) return 'preset'
const looksLocal = !config.apiKeySet || (config.baseUrl?.includes('localhost') ?? false)
return looksLocal ? 'local' : 'openai-compat'
}
export function canReuseExistingApiKey(input: ApiKeyReuseInput): boolean {
return (
input.mode === 'edit' &&
!!input.existingApiKeySet &&
!input.hasNewApiKey &&
input.originalProvider === input.currentProvider &&
input.originalConnectionMode === input.currentConnectionMode
)
}
@@ -2,6 +2,7 @@ import { ref, computed, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useLLMStore } from '@/stores/llm'
import { canReuseExistingApiKey, getConnectionModeForConfig, type ConnectionMode } from './apiKeyReuse'
// ==================== 类型 ====================
@@ -37,8 +38,6 @@ export interface Provider {
models: Array<{ id: string; name: string; description?: string }>
}
export type ConnectionMode = 'preset' | 'local' | 'openai-compat'
// ==================== Composable ====================
const CHINA_MARKET_PROVIDERS = ['doubao', 'siliconflow']
@@ -165,7 +164,17 @@ export function useAIConfigForm(props: {
const canSave = computed(() => {
const { provider, apiKey, baseUrl, model } = formData.value
const isEdit = props.mode.value === 'edit'
const existingKeySet = isEdit && props.config.value?.apiKeySet
const existingKeySet =
isEdit &&
canReuseExistingApiKey({
mode: props.mode.value,
existingApiKeySet: props.config.value?.apiKeySet,
hasNewApiKey: !!apiKey.trim(),
originalProvider: props.config.value?.provider,
currentProvider: provider,
originalConnectionMode: props.config.value ? getConnectionModeForConfig(props.config.value) : undefined,
currentConnectionMode: connectionMode.value,
})
if (isLocalMode.value) {
return baseUrl.trim() && model.trim()
@@ -263,8 +272,7 @@ export function useAIConfigForm(props: {
const hasModelInCatalog = !!(config.model && llmStore.getModelById(config.provider, config.model))
if (isCompat) {
const looksLocal = !config.apiKeySet || (config.baseUrl?.includes('localhost') ?? false)
connectionMode.value = looksLocal ? 'local' : 'openai-compat'
connectionMode.value = getConnectionModeForConfig(config)
if (config.customModels && config.customModels.length > 0) {
compatModels.value = [...config.customModels]
} else {
@@ -665,8 +673,8 @@ export function useAIConfigForm(props: {
customModels: persistCustomModels,
}
if (formData.value.apiKey.trim()) {
updates.apiKey = formData.value.apiKey.trim()
if (formData.value.apiKey.trim() || isLocalMode.value) {
updates.apiKey = finalApiKey
}
const result = await window.llmApi.updateConfig(props.config.value!.id, updates)
@@ -693,7 +701,15 @@ export function useAIConfigForm(props: {
}
const hasNewApiKey = !!formData.value.apiKey.trim()
const isEditWithExistingKey = props.mode.value === 'edit' && props.config.value?.apiKeySet && !hasNewApiKey
const isEditWithExistingKey = canReuseExistingApiKey({
mode: props.mode.value,
existingApiKeySet: props.config.value?.apiKeySet,
hasNewApiKey,
originalProvider: props.config.value?.provider,
currentProvider: formData.value.provider,
originalConnectionMode: props.config.value ? getConnectionModeForConfig(props.config.value) : undefined,
currentConnectionMode: connectionMode.value,
})
if (isEditWithExistingKey) {
return doSave()
}