feat(stage-ui): add thinking model option (#2343)
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import Header from '@proj-airi/stage-layouts/components/Layouts/Header.vue'
|
||||
import InteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea.vue'
|
||||
import MobileHeader from '@proj-airi/stage-layouts/components/Layouts/MobileHeader.vue'
|
||||
@@ -17,7 +15,6 @@ import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -50,7 +47,6 @@ const { discardRecord, startRecord, stopRecord, onStopRecord } = useAudioRecorde
|
||||
const hearingPipeline = useHearingSpeechInputPipeline()
|
||||
const { removeStreamingTranscriptionConsumer, transcribeForRecording, transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
|
||||
const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const providersStore = useProviderStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
|
||||
const chatStore = useChatStore()
|
||||
@@ -74,6 +70,25 @@ const {
|
||||
|
||||
let stopOnStopRecord: (() => void) | undefined
|
||||
|
||||
async function sendVoiceInputTextToChat(text: string | undefined) {
|
||||
if (!text?.trim())
|
||||
return
|
||||
|
||||
try {
|
||||
const providerId = activeChatProvider.value
|
||||
const model = activeChatModel.value
|
||||
if (!providerId || !model)
|
||||
return
|
||||
|
||||
const provider = await consciousnessStore.getChatProviderInstance(providerId)
|
||||
|
||||
await chatStore.ingest(text, { model, chatProvider: provider })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to send chat from voice:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudioInteraction() {
|
||||
try {
|
||||
await initVAD()
|
||||
@@ -83,19 +98,7 @@ async function startAudioInteraction() {
|
||||
// Hook once
|
||||
stopOnStopRecord = onStopRecord(async (recording) => {
|
||||
const text = await transcribeForRecording(recording)
|
||||
if (!text || !text.trim())
|
||||
return
|
||||
|
||||
try {
|
||||
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
}
|
||||
await sendVoiceInputTextToChat(text)
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
@@ -110,23 +113,7 @@ async function handleSpeechStart() {
|
||||
await transcribeForMediaStream(stream.value, {
|
||||
consumerId: transcriptionConsumerId,
|
||||
onSentenceEnd: (delta) => {
|
||||
const finalText = delta
|
||||
if (!finalText || !finalText.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
|
||||
if (!provider || !activeChatModel.value)
|
||||
return
|
||||
|
||||
await chatStore.ingest(finalText, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to send chat from voice:', err)
|
||||
}
|
||||
})()
|
||||
void sendVoiceInputTextToChat(delta)
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import Header from '@proj-airi/stage-layouts/components/Layouts/Header.vue'
|
||||
import InteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea.vue'
|
||||
import MobileHeader from '@proj-airi/stage-layouts/components/Layouts/MobileHeader.vue'
|
||||
@@ -17,7 +15,6 @@ import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -64,7 +61,6 @@ const { discardRecord, startRecord, stopRecord, onStopRecord } = useAudioRecorde
|
||||
const hearingPipeline = useHearingSpeechInputPipeline()
|
||||
const { removeStreamingTranscriptionConsumer, stopStreamingTranscription, transcribeForMediaStream, transcribeForRecording } = hearingPipeline
|
||||
const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const providersStore = useProviderStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider: activeChatProvider, activeModel: activeChatModel } = storeToRefs(consciousnessStore)
|
||||
const chatStore = useChatStore()
|
||||
@@ -93,11 +89,14 @@ async function sendVoiceInputTextToChat(text: string | undefined) {
|
||||
return
|
||||
|
||||
try {
|
||||
const provider = await providersStore.getProviderInstance(activeChatProvider.value)
|
||||
if (!provider || !activeChatModel.value)
|
||||
const providerId = activeChatProvider.value
|
||||
const model = activeChatModel.value
|
||||
if (!providerId || !model)
|
||||
return
|
||||
|
||||
await chatStore.ingest(text, { model: activeChatModel.value, chatProvider: provider as ChatProvider })
|
||||
const provider = await consciousnessStore.getChatProviderInstance(providerId)
|
||||
|
||||
await chatStore.ingest(text, { model, chatProvider: provider })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to send chat from voice:', error)
|
||||
|
||||
@@ -628,6 +628,10 @@ pages:
|
||||
show_more: Show more
|
||||
subtitle: Select a default model from the provider
|
||||
title: Model
|
||||
model-options:
|
||||
title: Model options
|
||||
thinking:
|
||||
label: Thinking
|
||||
title: Consciousness
|
||||
description: Thinking, vision, speech synthesis, gaming, etc.
|
||||
gaming-factorio:
|
||||
|
||||
@@ -603,6 +603,10 @@ pages:
|
||||
show_more: 收起
|
||||
subtitle: 选择一个默认模型
|
||||
title: 模型
|
||||
model-options:
|
||||
title: 模型选项
|
||||
thinking:
|
||||
label: 思考
|
||||
title: 意识
|
||||
description: 思维,视觉,言语综合,游戏等
|
||||
gaming-factorio:
|
||||
|
||||
@@ -25,9 +25,9 @@ async function deleteModels() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
async function resetModules() {
|
||||
try {
|
||||
resetModulesSettings()
|
||||
await resetModulesSettings()
|
||||
trackDataAction({ action: 'modules_settings_reset' })
|
||||
emitStatus(t('settings.pages.data.status.modules_reset'))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Alert, ErrorContainer, RadioCardManySelect, RadioCardSimple } from '@pr
|
||||
import { useAnalytics } from '@proj-airi/stage-ui/composables'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useConsciousnessSettingsStore } from '@proj-airi/stage-ui/stores/modules/consciousness-settings'
|
||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
||||
import { FieldCheckbox } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -14,8 +16,10 @@ const providersStore = useProviderStore()
|
||||
const providerStore = useProviderConfigStore()
|
||||
const airiCardStore = useAiriCardStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const consciousnessSettingsStore = useConsciousnessSettingsStore()
|
||||
const { configuredProviders } = storeToRefs(providerStore)
|
||||
const { persistedChatProvidersMetadata } = storeToRefs(providersStore)
|
||||
const { reasoning } = storeToRefs(consciousnessSettingsStore)
|
||||
const {
|
||||
activeProvider,
|
||||
activeModel,
|
||||
@@ -29,7 +33,6 @@ const {
|
||||
|
||||
const { t } = useI18n()
|
||||
const { trackModelSwitched, trackProviderClick } = useAnalytics()
|
||||
|
||||
watch(activeProvider, async (provider) => {
|
||||
if (!provider)
|
||||
return
|
||||
@@ -60,6 +63,10 @@ function handleDeleteProvider(providerId: string) {
|
||||
}
|
||||
providersStore.deleteProvider(providerId)
|
||||
}
|
||||
|
||||
async function updateReasoning(value: boolean) {
|
||||
await consciousnessSettingsStore.setReasoning(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -280,6 +287,21 @@ function handleDeleteProvider(providerId: string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="activeProvider && activeModel"
|
||||
:class="['flex', 'flex-col', 'gap-4', 'border-t', 'border-neutral-200', 'pt-4', 'dark:border-neutral-800']"
|
||||
>
|
||||
<h2 :class="['text-lg', 'text-neutral-500', 'md:text-2xl', 'dark:text-neutral-400']">
|
||||
{{ t('settings.pages.modules.consciousness.sections.section.model-options.title') }}
|
||||
</h2>
|
||||
|
||||
<FieldCheckbox
|
||||
:model-value="reasoning"
|
||||
:label="t('settings.pages.modules.consciousness.sections.section.model-options.thinking.label')"
|
||||
@update:model-value="updateReasoning"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useDisplayModelsStore } from '../stores/display-models'
|
||||
import { useMcpStore } from '../stores/mcp'
|
||||
import { useAiriCardStore } from '../stores/modules/airi-card'
|
||||
import { useConsciousnessStore } from '../stores/modules/consciousness'
|
||||
import { useConsciousnessSettingsStore } from '../stores/modules/consciousness-settings'
|
||||
import { useDiscordStore } from '../stores/modules/discord'
|
||||
import { useFactorioStore } from '../stores/modules/gaming-factorio'
|
||||
import { useMinecraftStore } from '../stores/modules/gaming-minecraft'
|
||||
@@ -34,6 +35,7 @@ export function useDataMaintenance() {
|
||||
const hearingStore = useHearingStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const consciousnessSettingsStore = useConsciousnessSettingsStore()
|
||||
const twitterStore = useTwitterStore()
|
||||
const webSearchStore = useWebSearchStore()
|
||||
const discordStore = useDiscordStore()
|
||||
@@ -53,10 +55,11 @@ export function useDataMaintenance() {
|
||||
await providersStore.resetProviderSettings()
|
||||
}
|
||||
|
||||
function resetModulesSettings() {
|
||||
async function resetModulesSettings() {
|
||||
hearingStore.resetState()
|
||||
speechStore.resetState()
|
||||
consciousnessStore.resetState()
|
||||
await consciousnessSettingsStore.resetState()
|
||||
twitterStore.resetState()
|
||||
webSearchStore.resetState()
|
||||
discordStore.resetState()
|
||||
@@ -101,7 +104,7 @@ export function useDataMaintenance() {
|
||||
async function deleteAllData() {
|
||||
await deleteAllModels()
|
||||
await resetProvidersSettings()
|
||||
resetModulesSettings()
|
||||
await resetModulesSettings()
|
||||
deleteAllChatSessions()
|
||||
await resetSettingsState()
|
||||
}
|
||||
@@ -111,7 +114,7 @@ export function useDataMaintenance() {
|
||||
return
|
||||
|
||||
await resetSettingsState()
|
||||
resetModulesSettings()
|
||||
await resetModulesSettings()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createChatProvider, createEmbedProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -24,6 +26,7 @@ export const providerAIHubMix = defineProvider<AIHubMixConfig>({
|
||||
description: 'AIHubMix',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.aihubmix.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:aihubmix',
|
||||
iconColor: 'i-lobe-icons:aihubmix-color',
|
||||
|
||||
@@ -41,11 +44,22 @@ export const providerAIHubMix = defineProvider<AIHubMixConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return merge(
|
||||
const provider = merge(
|
||||
createChatProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
createEmbedProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
createModelProvider({ apiKey: config.apiKey, baseURL: config.baseUrl! }),
|
||||
)
|
||||
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelInfo } from '../types'
|
||||
import type { ChatRequestOptions, ModelInfo } from '../types'
|
||||
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
@@ -64,6 +64,7 @@ export function createArkChatProviderDefinition(options: ArkProviderDefinitionOp
|
||||
description,
|
||||
descriptionLocalize: ({ t }) => t(descriptionKey),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon,
|
||||
iconColor,
|
||||
|
||||
@@ -86,8 +87,12 @@ export function createArkChatProviderDefinition(options: ArkProviderDefinitionOp
|
||||
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string) {
|
||||
return originalChat(stripModelPrefix(model, modelPrefix))
|
||||
chat(model: string, requestOptions?: ChatRequestOptions) {
|
||||
const request = originalChat(stripModelPrefix(model, modelPrefix))
|
||||
if (!requestOptions?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, thinking: { type: requestOptions.reasoning } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelInfo } from '../../types'
|
||||
import type { ChatRequestOptions, ModelInfo, ProviderInstance } from '../../types'
|
||||
|
||||
import { createAzure } from '@xsai-ext/providers/special/create'
|
||||
import { z } from 'zod'
|
||||
@@ -22,6 +22,7 @@ export const providerAzureAIFoundry = defineProvider<AzureAIFoundryConfig>({
|
||||
description: 'azure.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.azure-ai-foundry.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:microsoft',
|
||||
|
||||
createProviderConfig: ({ t }) => azureAIFoundryConfigSchema.extend({
|
||||
@@ -49,11 +50,27 @@ export const providerAzureAIFoundry = defineProvider<AzureAIFoundryConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createAzure({
|
||||
const provider = createAzure({
|
||||
apiKey: async () => config.apiKey.trim(),
|
||||
resourceName: config.resourceName.trim(),
|
||||
apiVersion: config.apiVersion?.trim(),
|
||||
}) as any
|
||||
}).then(baseProvider => ({
|
||||
...baseProvider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = baseProvider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}))
|
||||
|
||||
// NOTICE:
|
||||
// Azure provider creation resolves its authentication-aware client asynchronously.
|
||||
// ProviderDefinition still declares a synchronous result, while the runtime awaits it.
|
||||
// Source: @xsai-ext/providers special/create and stores/providers/provider.ts.
|
||||
// Remove this cast when ProviderDefinition accepts MaybePromise<ProviderInstance>.
|
||||
return provider as unknown as ProviderInstance
|
||||
},
|
||||
|
||||
extraMethods: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createCerebras } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerCerebrasAI = defineProvider<CerebrasConfig>({
|
||||
description: 'cerebras.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.cerebras.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:cerebras',
|
||||
iconColor: 'i-lobe-icons:cerebras-color',
|
||||
|
||||
@@ -40,7 +43,17 @@ export const providerCerebrasAI = defineProvider<CerebrasConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createCerebras(config.apiKey, config.baseUrl)
|
||||
const provider = createCerebras(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type {
|
||||
ChatProvider,
|
||||
ChatProviderWithExtraOptions,
|
||||
} from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { ProviderInstance } from '../../types'
|
||||
import type { ChatRequestOptions, ProviderInstance } from '../../types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { providerDeepSeek } from './index'
|
||||
|
||||
type DeepSeekChatProvider = ChatProvider | ChatProviderWithExtraOptions
|
||||
type DeepSeekChatProvider = ChatProviderWithExtraOptions<string, ChatRequestOptions>
|
||||
|
||||
function isDeepSeekChatProvider(provider: ProviderInstance): provider is DeepSeekChatProvider {
|
||||
return 'chat' in provider && typeof provider.chat === 'function'
|
||||
@@ -52,4 +51,12 @@ describe('providerDeepSeek.createProvider chat options', () => {
|
||||
thinking: { type: 'enabled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should prioritize request reasoning over the provider setting', () => {
|
||||
const provider = createDeepSeekChatProvider('enable')
|
||||
|
||||
expect(provider.chat('deepseek-chat', { reasoning: 'disabled' })).toMatchObject({
|
||||
thinking: { type: 'disabled' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createDeepSeek } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -54,6 +56,7 @@ export const providerDeepSeek = defineProvider<DeepSeekConfig>({
|
||||
description: 'deepseek.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.deepseek.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:deepseek',
|
||||
iconColor: 'i-lobe-icons:deepseek-color',
|
||||
|
||||
@@ -95,8 +98,11 @@ export const providerDeepSeek = defineProvider<DeepSeekConfig>({
|
||||
|
||||
return {
|
||||
...baseProvider,
|
||||
chat(model: string) {
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const chatOptions = baseProvider.chat(model)
|
||||
if (options?.reasoning)
|
||||
return { ...chatOptions, thinking: { type: options.reasoning } }
|
||||
|
||||
const thinking = resolveDeepSeekThinking(config.thinkingMode)
|
||||
|
||||
if (thinking === undefined)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerFeatherlessAI = defineProvider<FeatherlessConfig>({
|
||||
description: 'featherless.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.featherless.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:featherless-color',
|
||||
|
||||
createProviderConfig: ({ t }) => featherlessConfigSchema.extend({
|
||||
@@ -39,7 +42,17 @@ export const providerFeatherlessAI = defineProvider<FeatherlessConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createOpenAI(config.apiKey, config.baseUrl)
|
||||
const provider = createOpenAI(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, chatTemplateKwargs: { enable_thinking: options.reasoning === 'enabled' } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createFireworks } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerFireworksAI = defineProvider<FireworksConfig>({
|
||||
description: 'fireworks.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.fireworks.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:fireworks',
|
||||
iconColor: 'i-lobe-icons:fireworks-color',
|
||||
|
||||
@@ -40,7 +43,17 @@ export const providerFireworksAI = defineProvider<FireworksConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createFireworks(config.apiKey, config.baseUrl)
|
||||
const provider = createFireworks(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createGoogleGenerativeAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -24,6 +26,7 @@ export const providerGoogleGenerativeAI = defineProvider<GoogleGenerativeConfig>
|
||||
description: 'ai.google.dev',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.google-generative-ai.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:gemini',
|
||||
iconColor: 'i-lobe-icons:gemini-color',
|
||||
|
||||
@@ -41,7 +44,17 @@ export const providerGoogleGenerativeAI = defineProvider<GoogleGenerativeConfig>
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createGoogleGenerativeAI(config.apiKey, config.baseUrl)
|
||||
const provider = createGoogleGenerativeAI(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerGroq = defineProvider<GroqConfig>({
|
||||
description: 'groq.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.groq.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:groq',
|
||||
|
||||
createProviderConfig: ({ t }) => groqConfigSchema.extend({
|
||||
@@ -39,7 +42,17 @@ export const providerGroq = defineProvider<GroqConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createOpenAI(config.apiKey, config.baseUrl)
|
||||
const provider = createOpenAI(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createXiaomi } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -24,6 +26,7 @@ export const providerMimo = defineProvider<MimoConfig>({
|
||||
description: 'api.xiaomimimo.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.mimo.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-simple-icons:xiaomi',
|
||||
|
||||
createProviderConfig: ({ t }) => mimoConfigSchema.extend({
|
||||
@@ -40,7 +43,17 @@ export const providerMimo = defineProvider<MimoConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createXiaomi(config.apiKey, config.baseUrl)
|
||||
const provider = createXiaomi(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, thinking: { type: options.reasoning } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createMoonshotai } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerMoonshotAI = defineProvider<MoonshotConfig>({
|
||||
description: 'moonshot.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.moonshot.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:moonshot',
|
||||
|
||||
createProviderConfig: ({ t }) => moonshotConfigSchema.extend({
|
||||
@@ -39,7 +42,17 @@ export const providerMoonshotAI = defineProvider<MoonshotConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createMoonshotai(config.apiKey, config.baseUrl)
|
||||
const provider = createMoonshotai(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, thinking: { type: options.reasoning } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createNovita } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerNovitaAI = defineProvider<NovitaConfig>({
|
||||
description: 'novita.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.novita.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:novita',
|
||||
iconColor: 'i-lobe-icons:novita-color',
|
||||
|
||||
@@ -40,7 +43,17 @@ export const providerNovitaAI = defineProvider<NovitaConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createNovita(config.apiKey, config.baseUrl)
|
||||
const provider = createNovita(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, enableThinking: options.reasoning === 'enabled' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
@@ -24,6 +26,7 @@ export const providerNvidia = defineProvider<NvidiaConfig>({
|
||||
description: 'build.nvidia.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.nvidia.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-simple-icons:nvidia',
|
||||
isAvailableBy: isStageTamagotchi,
|
||||
|
||||
@@ -41,7 +44,17 @@ export const providerNvidia = defineProvider<NvidiaConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createOpenAI(config.apiKey, config.baseUrl)
|
||||
const provider = createOpenAI(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, chatTemplateKwargs: { enable_thinking: options.reasoning === 'enabled' } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,71 +1,78 @@
|
||||
import type { ChatProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { ChatRequestOptions, ProviderInstance } from '../../types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { providerOllama, resolveOllamaThink } from './index'
|
||||
import { providerOllama, resolveOllamaReasoningEffort } from './index'
|
||||
|
||||
describe('providerOllama.resolveOllamaThink', () => {
|
||||
type OllamaChatProvider = ChatProviderWithExtraOptions<string, ChatRequestOptions>
|
||||
|
||||
function isOllamaChatProvider(provider: ProviderInstance): provider is OllamaChatProvider {
|
||||
return 'chat' in provider && typeof provider.chat === 'function'
|
||||
}
|
||||
|
||||
function createOllamaChatProvider(thinkingMode: 'auto' | 'disable' | 'enable'): OllamaChatProvider {
|
||||
const provider = providerOllama.createProvider({
|
||||
baseUrl: 'http://localhost:11434/v1/',
|
||||
thinkingMode,
|
||||
})
|
||||
if (!isOllamaChatProvider(provider))
|
||||
throw new Error('Ollama provider must support chat')
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
describe('providerOllama.resolveOllamaReasoningEffort', () => {
|
||||
it('should return undefined for auto mode', () => {
|
||||
expect(resolveOllamaThink('qwen3:8b', 'auto')).toBeUndefined()
|
||||
expect(resolveOllamaReasoningEffort('auto')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should map disable/enable to booleans for non gpt-oss models', () => {
|
||||
expect(resolveOllamaThink('qwen3:8b', 'disable')).toBe(false)
|
||||
expect(resolveOllamaThink('qwen3:8b', 'enable')).toBe(true)
|
||||
})
|
||||
|
||||
it('should map disable/enable to levels for gpt-oss models', () => {
|
||||
expect(resolveOllamaThink('gpt-oss:20b', 'disable')).toBe('low')
|
||||
expect(resolveOllamaThink('gpt-oss:20b', 'enable')).toBe('medium')
|
||||
it('should map disable/enable to OpenAI-compatible effort values', () => {
|
||||
expect(resolveOllamaReasoningEffort('disable')).toBe('none')
|
||||
expect(resolveOllamaReasoningEffort('enable')).toBe('medium')
|
||||
})
|
||||
|
||||
it('should pass level modes through unchanged', () => {
|
||||
expect(resolveOllamaThink('qwen3:8b', 'low')).toBe('low')
|
||||
expect(resolveOllamaThink('qwen3:8b', 'medium')).toBe('medium')
|
||||
expect(resolveOllamaThink('qwen3:8b', 'high')).toBe('high')
|
||||
expect(resolveOllamaReasoningEffort('low')).toBe('low')
|
||||
expect(resolveOllamaReasoningEffort('medium')).toBe('medium')
|
||||
expect(resolveOllamaReasoningEffort('high')).toBe('high')
|
||||
})
|
||||
|
||||
it('should fallback invalid values to auto mode', () => {
|
||||
expect(resolveOllamaThink('qwen3:8b', 'invalid')).toBeUndefined()
|
||||
expect(resolveOllamaReasoningEffort('invalid')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerOllama.createProvider chat options', () => {
|
||||
it('should not set think when thinkingMode is auto', () => {
|
||||
const provider = providerOllama.createProvider({
|
||||
baseUrl: 'http://localhost:11434/v1/',
|
||||
thinkingMode: 'auto',
|
||||
}) as any
|
||||
it('should not set reasoning effort when thinkingMode is auto', () => {
|
||||
const provider = createOllamaChatProvider('auto')
|
||||
|
||||
const chatOptions = provider.chat('qwen3:8b') as Record<string, unknown>
|
||||
expect('think' in chatOptions).toBe(false)
|
||||
expect(provider.chat('qwen3:8b')).not.toHaveProperty('reasoningEffort')
|
||||
})
|
||||
|
||||
it('should set think=false for non gpt-oss when thinkingMode is disable', () => {
|
||||
const provider = providerOllama.createProvider({
|
||||
baseUrl: 'http://localhost:11434/v1/',
|
||||
thinkingMode: 'disable',
|
||||
}) as any
|
||||
it('should set reasoning effort to none for non gpt-oss when thinkingMode is disable', () => {
|
||||
const provider = createOllamaChatProvider('disable')
|
||||
|
||||
const chatOptions = provider.chat('qwen3:8b') as Record<string, unknown>
|
||||
expect(chatOptions.think).toBe(false)
|
||||
expect(provider.chat('qwen3:8b')).toMatchObject({ reasoningEffort: 'none' })
|
||||
})
|
||||
|
||||
it('should set think=medium for gpt-oss when thinkingMode is enable', () => {
|
||||
const provider = providerOllama.createProvider({
|
||||
baseUrl: 'http://localhost:11434/v1/',
|
||||
thinkingMode: 'enable',
|
||||
}) as any
|
||||
it('should set reasoning effort to medium when thinkingMode is enable', () => {
|
||||
const provider = createOllamaChatProvider('enable')
|
||||
|
||||
const chatOptions = provider.chat('gpt-oss:20b') as Record<string, unknown>
|
||||
expect(chatOptions.think).toBe('medium')
|
||||
expect(provider.chat('gpt-oss:20b')).toMatchObject({ reasoningEffort: 'medium' })
|
||||
})
|
||||
|
||||
it('should set think=low for gpt-oss when thinkingMode is disable', () => {
|
||||
const provider = providerOllama.createProvider({
|
||||
baseUrl: 'http://localhost:11434/v1/',
|
||||
thinkingMode: 'disable',
|
||||
}) as any
|
||||
it('should set reasoning effort to none when thinkingMode is disable', () => {
|
||||
const provider = createOllamaChatProvider('disable')
|
||||
|
||||
const chatOptions = provider.chat('gpt-oss:20b') as Record<string, unknown>
|
||||
expect(chatOptions.think).toBe('low')
|
||||
expect(provider.chat('gpt-oss:20b')).toMatchObject({ reasoningEffort: 'none' })
|
||||
})
|
||||
|
||||
it('should apply request reasoning without checking the model name', () => {
|
||||
const provider = createOllamaChatProvider('auto')
|
||||
|
||||
expect(provider.chat('llama3.2', { reasoning: 'disabled' })).toMatchObject({ reasoningEffort: 'none' })
|
||||
expect(provider.chat('gpt-oss:20b', { reasoning: 'enabled' })).toMatchObject({ reasoningEffort: 'medium' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createOllama } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -5,7 +7,7 @@ import { ProviderValidationCheck } from '../../types'
|
||||
import { createOpenAICompatibleValidators } from '../../validators'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
type OllamaThinkValue = boolean | 'high' | 'low' | 'medium'
|
||||
type OllamaReasoningEffort = 'high' | 'low' | 'medium' | 'none'
|
||||
type OllamaThinkingMode = 'auto' | 'disable' | 'enable' | 'high' | 'low' | 'medium'
|
||||
|
||||
const ollamaConfigSchema = z.object({
|
||||
@@ -19,10 +21,6 @@ const ollamaConfigSchema = z.object({
|
||||
|
||||
type OllamaConfig = z.input<typeof ollamaConfigSchema>
|
||||
|
||||
function isGptOssModel(model: string): boolean {
|
||||
return model.toLowerCase().includes('gpt-oss')
|
||||
}
|
||||
|
||||
function normalizeOllamaThinkingMode(value: unknown): OllamaThinkingMode {
|
||||
switch (value) {
|
||||
case 'auto':
|
||||
@@ -37,19 +35,23 @@ function normalizeOllamaThinkingMode(value: unknown): OllamaThinkingMode {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOllamaThink(model: string, modeRaw: unknown): OllamaThinkValue | undefined {
|
||||
/**
|
||||
* Maps the persisted Ollama setting to its OpenAI-compatible effort value.
|
||||
*
|
||||
* @example
|
||||
* resolveOllamaReasoningEffort('disable')
|
||||
* // => 'none'
|
||||
*/
|
||||
export function resolveOllamaReasoningEffort(modeRaw: unknown): OllamaReasoningEffort | undefined {
|
||||
const mode = normalizeOllamaThinkingMode(modeRaw)
|
||||
const isGptOss = isGptOssModel(model)
|
||||
|
||||
switch (mode) {
|
||||
case 'auto':
|
||||
return undefined
|
||||
case 'disable':
|
||||
// NOTICE: GPT-OSS ignores boolean `think`, so "disable" degrades to `low`.
|
||||
return isGptOss ? 'low' : false
|
||||
return 'none'
|
||||
case 'enable':
|
||||
// NOTICE: GPT-OSS requires levels; map generic "enable" to medium effort.
|
||||
return isGptOss ? 'medium' : true
|
||||
return 'medium'
|
||||
case 'low':
|
||||
case 'medium':
|
||||
case 'high':
|
||||
@@ -67,6 +69,7 @@ export const providerOllama = defineProvider<OllamaConfig>({
|
||||
description: 'Local Ollama server for fast model iteration.',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.ollama.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:ollama',
|
||||
|
||||
createProviderConfig: ({ t }) => ollamaConfigSchema.extend({
|
||||
@@ -122,14 +125,21 @@ export const providerOllama = defineProvider<OllamaConfig>({
|
||||
|
||||
return {
|
||||
...baseProvider,
|
||||
chat(model: string) {
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const chatOptions = baseProvider.chat(model)
|
||||
const think = resolveOllamaThink(model, config.thinkingMode)
|
||||
if (options?.reasoning) {
|
||||
return {
|
||||
...chatOptions,
|
||||
reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none',
|
||||
}
|
||||
}
|
||||
|
||||
if (think === undefined)
|
||||
const reasoningEffort = resolveOllamaReasoningEffort(config.thinkingMode)
|
||||
|
||||
if (reasoningEffort === undefined)
|
||||
return chatOptions
|
||||
|
||||
return { ...chatOptions, think }
|
||||
return { ...chatOptions, reasoningEffort }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -24,6 +26,7 @@ export const providerOpenAI = defineProvider<OpenAICompatibleConfig>({
|
||||
description: 'OpenAI',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openai.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:openai',
|
||||
|
||||
createProviderConfig: ({ t }) => openAICompatibleConfigSchema.extend({
|
||||
@@ -40,7 +43,17 @@ export const providerOpenAI = defineProvider<OpenAICompatibleConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createOpenAI(config.apiKey, config.baseUrl)
|
||||
const provider = createOpenAI(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoningEffort: options.reasoning === 'enabled' ? 'medium' : 'none' }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ChatProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
import type { JsonSchema } from 'xsschema'
|
||||
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createSparkCommandTool } from '../../../../tools/character/orchestrator/spark-command'
|
||||
@@ -33,6 +36,19 @@ describe('providerOpenRouterAI tool schemas', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('maps AIRI reasoning modes to OpenRouter request fields', () => {
|
||||
const provider = providerOpenRouterAI.createProvider({
|
||||
apiKey: 'test-key',
|
||||
}) as ChatProviderWithExtraOptions<string, ChatRequestOptions>
|
||||
|
||||
expect(provider.chat('openai/gpt-test', { reasoning: 'disabled' })).toMatchObject({
|
||||
reasoning: { effort: 'none' },
|
||||
})
|
||||
expect(provider.chat('openai/gpt-test', { reasoning: 'enabled' })).toMatchObject({
|
||||
reasoning: { effort: 'medium' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the canonical nullable anyOf when it sends a chat request', async () => {
|
||||
const tools = await createSparkCommandTool({
|
||||
sendSparkCommand: () => undefined,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createOpenRouter } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -29,6 +31,7 @@ export const providerOpenRouterAI = defineProvider<OpenRouterConfig>({
|
||||
description: 'openrouter.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.openrouter.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:openrouter',
|
||||
|
||||
createProviderConfig: ({ t }) => openRouterConfigSchema.extend({
|
||||
@@ -48,15 +51,22 @@ export const providerOpenRouterAI = defineProvider<OpenRouterConfig>({
|
||||
const base = createOpenRouter(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...base,
|
||||
chat: (model: string) => ({
|
||||
...base.chat(model),
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
for (const [k, v] of Object.entries(OPENROUTER_ATTRIBUTION_HEADERS))
|
||||
headers.set(k, v)
|
||||
return globalThis.fetch(input, { ...init, headers })
|
||||
},
|
||||
}),
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = {
|
||||
...base.chat(model),
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
for (const [k, v] of Object.entries(OPENROUTER_ATTRIBUTION_HEADERS))
|
||||
headers.set(k, v)
|
||||
return globalThis.fetch(input, { ...init, headers })
|
||||
},
|
||||
}
|
||||
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoning: { effort: options.reasoning === 'enabled' ? 'medium' : 'none' } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createTogetherAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerTogetherAI = defineProvider<TogetherConfig>({
|
||||
description: 'together.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.together.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:together',
|
||||
iconColor: 'i-lobe-icons:together-color',
|
||||
|
||||
@@ -40,7 +43,17 @@ export const providerTogetherAI = defineProvider<TogetherConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createTogetherAI(config.apiKey, config.baseUrl)
|
||||
const provider = createTogetherAI(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, reasoning: { enabled: options.reasoning === 'enabled' } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatRequestOptions } from '../../types'
|
||||
|
||||
import { createZai } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -23,6 +25,7 @@ export const providerZai = defineProvider<ZaiConfig>({
|
||||
description: 'z.ai',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.zai.description'),
|
||||
tasks: ['chat'],
|
||||
capabilities: { chat: { reasoning: { modes: ['enabled', 'disabled'] } } },
|
||||
icon: 'i-lobe-icons:zai',
|
||||
|
||||
createProviderConfig: ({ t }) => zaiConfigSchema.extend({
|
||||
@@ -39,7 +42,17 @@ export const providerZai = defineProvider<ZaiConfig>({
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createZai(config.apiKey, config.baseUrl)
|
||||
const provider = createZai(config.apiKey, config.baseUrl)
|
||||
return {
|
||||
...provider,
|
||||
chat(model: string, options?: ChatRequestOptions) {
|
||||
const request = provider.chat(model)
|
||||
if (!options?.reasoning)
|
||||
return request
|
||||
|
||||
return { ...request, thinking: { type: options.reasoning } }
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
||||
@@ -204,6 +204,9 @@ export interface ProviderDefinition<TConfig extends any = any> {
|
||||
validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderRuntimeValidator<TConfig>>
|
||||
}
|
||||
capabilities?: {
|
||||
chat?: {
|
||||
reasoning?: ChatReasoningCapability
|
||||
}
|
||||
transcription?: {
|
||||
protocol: 'websocket' | 'http'
|
||||
generateOutput: boolean
|
||||
@@ -252,3 +255,18 @@ export interface ProviderDefinition<TConfig extends any = any> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reasoning modes that AIRI can request from a chat provider. */
|
||||
export type ChatReasoningMode = 'disabled' | 'enabled'
|
||||
|
||||
/** User-selected options that a provider applies to one chat request. */
|
||||
export interface ChatRequestOptions {
|
||||
/** Requested reasoning mode. */
|
||||
reasoning: ChatReasoningMode
|
||||
}
|
||||
|
||||
/** Describes the reasoning controls that AIRI implements for a provider. */
|
||||
export interface ChatReasoningCapability {
|
||||
/** Modes that AIRI can pass to the provider. */
|
||||
modes: readonly ChatReasoningMode[]
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@ describe('store character-orchestrator', () => {
|
||||
sendSparkCommandMock.mockReset()
|
||||
mockedStore(useModsServerChannelStore, pinia).send = sendSparkCommandMock
|
||||
|
||||
const mockGetProviderInstance = vi.fn()
|
||||
mockedStore(useProviderStore, pinia).getProviderInstance = mockGetProviderInstance
|
||||
mockedStore(useProviderStore, pinia).getProviderInstance.mockResolvedValue({ chat: (_model: string) => ({} as any) })
|
||||
const mockGetChatProviderInstance = vi.fn()
|
||||
mockedStore(useProviderStore, pinia).getChatProviderInstance = mockGetChatProviderInstance
|
||||
mockedStore(useProviderStore, pinia).getChatProviderInstance.mockResolvedValue({ chat: (_model: string) => ({} as any) })
|
||||
|
||||
const consciousnessStore = useConsciousnessStore(pinia)
|
||||
consciousnessStore.activeProvider = 'mock-provider'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SparkNotifyResponseControl } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { createSparkNotifyAgent, createSparkNotifyReactionPlugin } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
@@ -10,14 +9,13 @@ import { useCharacterNotebookStore, useCharacterStore } from '../'
|
||||
import { useLLM } from '../../ai/chat-llm/llm'
|
||||
import { useModsServerChannelStore } from '../../mods/api/channel-server'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
import { useProviderStore } from '../../providers/provider'
|
||||
|
||||
export { sparkNotifyCommandSchema } from '@proj-airi/core-agent/agents/spark-notify'
|
||||
|
||||
export const useCharacterOrchestratorStore = defineStore('character-orchestrator', () => {
|
||||
const { stream } = useLLM()
|
||||
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
|
||||
const providersStore = useProviderStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const { activeProvider, activeModel } = storeToRefs(consciousnessStore)
|
||||
const characterStore = useCharacterStore()
|
||||
const notebookStore = useCharacterNotebookStore()
|
||||
const { systemPrompt } = storeToRefs(characterStore)
|
||||
@@ -123,7 +121,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
|
||||
return undefined
|
||||
}
|
||||
|
||||
const provider = await providersStore.getProviderInstance<ChatProvider>(providerId)
|
||||
const provider = await consciousnessStore.getChatProviderInstance(providerId)
|
||||
processing.value = true
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StreamOptions } from '@proj-airi/core-agent'
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
import type { Message, Tool } from '@xsai/shared-chat'
|
||||
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
AIRI_CHAT_SESSION_ID_HEADER,
|
||||
} from '../libs/analytics-headers'
|
||||
import { useChatStore } from './chat'
|
||||
import { useConsciousnessSettingsStore } from './modules/consciousness-settings'
|
||||
|
||||
vi.hoisted(() => {
|
||||
;(globalThis as any).window = {
|
||||
@@ -65,7 +67,7 @@ const forkSessionMock = vi.fn()
|
||||
const ensureSessionMock = vi.fn()
|
||||
const loadSessionMock = vi.fn()
|
||||
const deleteSessionMock = vi.fn()
|
||||
const getProviderInstanceMock = vi.fn()
|
||||
const getChatProviderInstanceMock = vi.fn()
|
||||
const getToolsByNamesMock = vi.fn<(names: string[]) => Tool[]>()
|
||||
|
||||
const activeSessionIdRef = ref('session-1')
|
||||
@@ -178,12 +180,6 @@ vi.mock('./ai/chat-llm/tools', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./providers/provider', () => ({
|
||||
useProviderStore: () => ({
|
||||
getProviderInstance: getProviderInstanceMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./ai/chat-llm/toolset-prompts', () => ({
|
||||
useLlmToolsetPromptsStore: () => ({
|
||||
activeToolsetPrompt: 'Plugin toolset guidance.',
|
||||
@@ -194,6 +190,9 @@ vi.mock('./modules/consciousness', () => ({
|
||||
useConsciousnessStore: () => ({
|
||||
activeModel: activeModelRef,
|
||||
activeProvider: activeProviderRef,
|
||||
getChatProviderInstance: (providerId: string) => getChatProviderInstanceMock(providerId, {
|
||||
reasoning: useConsciousnessSettingsStore().reasoning ? 'enabled' : 'disabled',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -241,7 +240,7 @@ describe('chat store contract', () => {
|
||||
ensureSessionMock.mockReset()
|
||||
loadSessionMock.mockReset().mockResolvedValue(true)
|
||||
deleteSessionMock.mockReset().mockResolvedValue(undefined)
|
||||
getProviderInstanceMock.mockReset().mockResolvedValue(provider)
|
||||
getChatProviderInstanceMock.mockReset().mockResolvedValue(provider)
|
||||
getToolsByNamesMock.mockReset().mockImplementation(names => names.map(name => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -285,8 +284,8 @@ describe('chat store contract', () => {
|
||||
text: 'continue',
|
||||
})
|
||||
|
||||
expect(getProviderInstanceMock).toHaveBeenCalledTimes(2)
|
||||
expect(getProviderInstanceMock).toHaveBeenCalledWith('mock-provider')
|
||||
expect(getChatProviderInstanceMock).toHaveBeenCalledTimes(2)
|
||||
expect(getChatProviderInstanceMock).toHaveBeenCalledWith('mock-provider', { reasoning: 'disabled' })
|
||||
expect(() => structuredClone(result)).not.toThrow()
|
||||
expect(resolvedToolNames).toEqual([
|
||||
['stage_widgets'],
|
||||
@@ -294,6 +293,20 @@ describe('chat store contract', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('passes the current consciousness reasoning option to the chat provider', async () => {
|
||||
const settings = useConsciousnessSettingsStore()
|
||||
await settings.setReasoning(true)
|
||||
llmStreamMock.mockImplementationOnce(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: StreamOptions) => {
|
||||
await options.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
|
||||
const store = useChatStore()
|
||||
await store.send({ sessionId: 'session-1', text: 'reply without changing provider defaults' })
|
||||
|
||||
expect(getChatProviderInstanceMock).toHaveBeenCalledWith('mock-provider', { reasoning: 'enabled' })
|
||||
await settings.setReasoning(false)
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2085
|
||||
it('hydrates the target session before sending for Issue #2085', async () => {
|
||||
// ROOT CAUSE:
|
||||
|
||||
@@ -36,7 +36,6 @@ import { useAiriCardStore } from './modules/airi-card'
|
||||
import { useAutonomousArtistryStore } from './modules/artistry-autonomous'
|
||||
import { useConsciousnessStore } from './modules/consciousness'
|
||||
import { useWebSearchStore } from './modules/web-search'
|
||||
import { useProviderStore } from './providers/provider'
|
||||
import { executeToolCallRerun } from './tool-call-rerun'
|
||||
|
||||
interface ForkOptions {
|
||||
@@ -146,7 +145,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
// without its paired prompt-injection defense.
|
||||
useWebSearchStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const providerStore = useProviderStore()
|
||||
const artistryAutonomousStore = useAutonomousArtistryStore()
|
||||
const { activeModel, activeProvider } = storeToRefs(consciousnessStore)
|
||||
const chatSession = useChatSessionStore()
|
||||
@@ -363,7 +361,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
throw new Error('Failed to load the target chat session')
|
||||
|
||||
const messageCount = chatSession.getSessionMessages(payload.sessionId).length
|
||||
const chatProvider = await providerStore.getProviderInstance<ChatProvider>(providerId)
|
||||
const chatProvider = await consciousnessStore.getChatProviderInstance(providerId)
|
||||
if (!chatProvider)
|
||||
throw new Error(`Failed to resolve chat provider "${providerId}"`)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock('../../modules/consciousness', () => ({
|
||||
useConsciousnessStore: () => ({
|
||||
activeProvider: ref(undefined),
|
||||
activeModel: ref(undefined),
|
||||
getChatProviderInstance: vi.fn(async () => ({})),
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -262,6 +262,7 @@ vi.mock('../../modules/consciousness', () => ({
|
||||
useConsciousnessStore: () => ({
|
||||
activeProvider: activeProviderRef,
|
||||
activeModel: activeModelRef,
|
||||
getChatProviderInstance: getProviderInstanceMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useChatSessionStore } from '../../chat/session-store'
|
||||
import { useChatStreamStore } from '../../chat/stream-store'
|
||||
import { useContextObservabilityStore } from '../../devtools/context-observability'
|
||||
import { useConsciousnessStore } from '../../modules/consciousness'
|
||||
import { useProviderStore } from '../../providers/provider'
|
||||
import { useModsServerChannelStore } from './channel-server'
|
||||
import { createContextChannel } from './context-channel'
|
||||
|
||||
@@ -57,7 +56,6 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
const contextObservability = useContextObservabilityStore()
|
||||
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const providersStore = useProviderStore()
|
||||
const { activeProvider, activeModel } = storeToRefs(consciousnessStore)
|
||||
const streamingControl = useLlmStreamingControlStore()
|
||||
|
||||
@@ -685,10 +683,10 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
||||
if (activeProvider.value && activeModel.value) {
|
||||
let chatProvider: ChatProvider
|
||||
try {
|
||||
chatProvider = await providersStore.getProviderInstance<ChatProvider>(activeProvider.value)
|
||||
chatProvider = await consciousnessStore.getChatProviderInstance(activeProvider.value)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[context-bridge] getProviderInstance failed for provider:', activeProvider.value, err)
|
||||
console.error('[context-bridge] getChatProviderInstance failed for provider:', activeProvider.value, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { LeadershipMode, SyncedPiniaRuntime } from 'pinia-plugin-synced'
|
||||
|
||||
import { createPinia, disposePinia, setActivePinia } from 'pinia'
|
||||
import { createSyncedPiniaPlugin } from 'pinia-plugin-synced'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import { useConsciousnessSettingsStore } from './consciousness-settings'
|
||||
|
||||
const syncedContexts: Array<{
|
||||
pinia: ReturnType<typeof createPinia>
|
||||
runtime: SyncedPiniaRuntime
|
||||
}> = []
|
||||
|
||||
function createSyncedContext(namespace: string, leadership: LeadershipMode) {
|
||||
const pinia = createPinia()
|
||||
const runtime = createSyncedPiniaPlugin({
|
||||
callTimeout: 1000,
|
||||
leadership,
|
||||
namespace,
|
||||
})
|
||||
pinia.use(runtime.plugin)
|
||||
createApp({}).use(pinia)
|
||||
syncedContexts.push({ pinia, runtime })
|
||||
return { pinia, runtime }
|
||||
}
|
||||
|
||||
describe('consciousness settings synchronization', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const context of syncedContexts.splice(0)) {
|
||||
context.runtime.dispose()
|
||||
disposePinia(context.pinia)
|
||||
}
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('applies one remote snapshot without publishing it again', async () => {
|
||||
const namespace = `consciousness-settings:${crypto.randomUUID()}`
|
||||
const leaderContext = createSyncedContext(namespace, 'leader-only')
|
||||
await vi.waitFor(() => expect(leaderContext.runtime.isLeader()).toBe(true))
|
||||
|
||||
setActivePinia(leaderContext.pinia)
|
||||
const leaderStore = useConsciousnessSettingsStore()
|
||||
|
||||
const followerContext = createSyncedContext(namespace, 'follower-only')
|
||||
setActivePinia(followerContext.pinia)
|
||||
const followerStore = useConsciousnessSettingsStore()
|
||||
await vi.waitFor(() => expect(followerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId))
|
||||
|
||||
let leaderMutations = 0
|
||||
let followerMutations = 0
|
||||
let followerActions = 0
|
||||
leaderStore.$subscribe(() => leaderMutations++, { flush: 'sync' })
|
||||
followerStore.$subscribe(() => followerMutations++, { flush: 'sync' })
|
||||
followerStore.$onAction(() => followerActions++)
|
||||
|
||||
leaderStore.reasoning = true
|
||||
await vi.waitFor(() => expect(followerStore.reasoning).toBe(true))
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
expect(leaderMutations).toBe(1)
|
||||
expect(followerMutations).toBe(1)
|
||||
expect(followerActions).toBe(0)
|
||||
expect(localStorage.getItem('settings/consciousness/reasoning')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists a follower update through one leader-owned action', async () => {
|
||||
const namespace = `consciousness-settings:${crypto.randomUUID()}`
|
||||
const leaderContext = createSyncedContext(namespace, 'leader-only')
|
||||
await vi.waitFor(() => expect(leaderContext.runtime.isLeader()).toBe(true))
|
||||
|
||||
setActivePinia(leaderContext.pinia)
|
||||
const leaderStore = useConsciousnessSettingsStore()
|
||||
|
||||
const followerContext = createSyncedContext(namespace, 'follower-only')
|
||||
setActivePinia(followerContext.pinia)
|
||||
const followerStore = useConsciousnessSettingsStore()
|
||||
await vi.waitFor(() => expect(followerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId))
|
||||
|
||||
let leaderActions = 0
|
||||
leaderStore.$onAction(({ name }) => {
|
||||
if (name === 'setReasoning')
|
||||
leaderActions++
|
||||
})
|
||||
|
||||
await followerStore.setReasoning(true)
|
||||
await vi.waitFor(() => expect(followerStore.reasoning).toBe(true))
|
||||
|
||||
expect(leaderStore.reasoning).toBe(true)
|
||||
expect(leaderActions).toBe(1)
|
||||
expect(localStorage.getItem('settings/consciousness/reasoning')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useConsciousnessSettingsStore } from './consciousness-settings'
|
||||
|
||||
describe('consciousness settings store', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('turns model reasoning off by default', () => {
|
||||
const store = useConsciousnessSettingsStore()
|
||||
|
||||
expect(store.reasoning).toBe(false)
|
||||
})
|
||||
|
||||
it('loads the persisted value', () => {
|
||||
localStorage.setItem('settings/consciousness/reasoning', 'true')
|
||||
const store = useConsciousnessSettingsStore()
|
||||
|
||||
expect(store.reasoning).toBe(true)
|
||||
})
|
||||
|
||||
it('persists changes through store actions', async () => {
|
||||
const store = useConsciousnessSettingsStore()
|
||||
await store.setReasoning(true)
|
||||
|
||||
expect(store.reasoning).toBe(true)
|
||||
expect(localStorage.getItem('settings/consciousness/reasoning')).toBe('true')
|
||||
|
||||
await store.resetState()
|
||||
|
||||
expect(store.reasoning).toBe(false)
|
||||
expect(localStorage.getItem('settings/consciousness/reasoning')).toBe('false')
|
||||
})
|
||||
|
||||
it('ignores storage events because Pinia owns cross-window synchronization', () => {
|
||||
const store = useConsciousnessSettingsStore()
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'settings/consciousness/reasoning',
|
||||
newValue: 'true',
|
||||
}))
|
||||
|
||||
expect(store.reasoning).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {} from 'pinia-plugin-synced'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
function loadReasoning() {
|
||||
// Non-renderer runtimes have no durable settings owner. They use the product
|
||||
// default until a synchronized renderer snapshot arrives.
|
||||
if (typeof localStorage === 'undefined')
|
||||
return false
|
||||
|
||||
return localStorage.getItem('settings/consciousness/reasoning') === 'true'
|
||||
}
|
||||
|
||||
function persistReasoning(value: boolean) {
|
||||
if (typeof localStorage === 'undefined')
|
||||
return
|
||||
|
||||
localStorage.setItem('settings/consciousness/reasoning', String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores request policies for the consciousness module.
|
||||
*
|
||||
* Consciousness chat request preparation reads this state before inference.
|
||||
* Each provider maps the reasoning value to its own request fields.
|
||||
*/
|
||||
export const useConsciousnessSettingsStore = defineStore('consciousness-settings', () => {
|
||||
// Pinia owns live cross-window state. Only synchronized actions write the
|
||||
// durable value, so a follower cannot persist an uncommitted proposal.
|
||||
const reasoning = shallowRef(loadReasoning())
|
||||
|
||||
async function setReasoning(value: boolean) {
|
||||
reasoning.value = value
|
||||
persistReasoning(value)
|
||||
}
|
||||
|
||||
async function resetState() {
|
||||
reasoning.value = false
|
||||
persistReasoning(false)
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning,
|
||||
setReasoning,
|
||||
resetState,
|
||||
}
|
||||
}, {
|
||||
synced: {
|
||||
actions: ['resetState', 'setReasoning'],
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { nextTick } from 'vue'
|
||||
import { useProviderConfigStore } from '../providers/config'
|
||||
import { useProviderStore } from '../providers/provider'
|
||||
import { useConsciousnessStore } from './consciousness'
|
||||
import { useConsciousnessSettingsStore } from './consciousness-settings'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
@@ -76,6 +77,21 @@ describe('consciousness store provider selection', () => {
|
||||
expect(definition?.name).toBe('OpenAI')
|
||||
})
|
||||
|
||||
it('resolves chat providers with the current reasoning mode', async () => {
|
||||
const providerConfigStore = useProviderConfigStore()
|
||||
providerConfigStore.ensureProvider('openai', 'openai', { apiKey: 'sk-test' })
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const settingsStore = useConsciousnessSettingsStore()
|
||||
|
||||
const disabledProvider = await consciousnessStore.getChatProviderInstance('openai')
|
||||
expect(disabledProvider.chat('test-model')).toMatchObject({ reasoningEffort: 'none' })
|
||||
|
||||
await settingsStore.setReasoning(true)
|
||||
|
||||
const enabledProvider = await consciousnessStore.getChatProviderInstance('openai')
|
||||
expect(enabledProvider.chat('test-model')).toMatchObject({ reasoningEffort: 'medium' })
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The model selection was only cleared on provider switches by a watcher in
|
||||
|
||||
@@ -6,9 +6,11 @@ import { defineStore } from 'pinia'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useProviderStore } from '../providers/provider'
|
||||
import { useConsciousnessSettingsStore } from './consciousness-settings'
|
||||
|
||||
export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
const providersStore = useProviderStore()
|
||||
const settingsStore = useConsciousnessSettingsStore()
|
||||
|
||||
// Pinia synchronization owns live cross-window state. localStorage remains
|
||||
// durable persistence, but storage events must not reflect state back into
|
||||
@@ -93,6 +95,13 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
return []
|
||||
}
|
||||
|
||||
/** Resolves a provider with the reasoning mode shared by every Consciousness input path. */
|
||||
async function getChatProviderInstance(provider: string) {
|
||||
return providersStore.getChatProviderInstance(provider, {
|
||||
reasoning: settingsStore.reasoning ? 'enabled' : 'disabled',
|
||||
})
|
||||
}
|
||||
|
||||
const configured = computed(() => {
|
||||
return !!activeProvider.value && !!activeModel.value
|
||||
})
|
||||
@@ -122,6 +131,7 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
resetModelSelection,
|
||||
loadModelsForProvider,
|
||||
getModelsForProvider,
|
||||
getChatProviderInstance,
|
||||
resetState,
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './airi-card'
|
||||
export * from './consciousness'
|
||||
export * from './consciousness-settings'
|
||||
export * from './discord'
|
||||
export * from './gaming-factorio'
|
||||
export * from './gaming-minecraft'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -103,6 +105,25 @@ describe('provider store synchronization boundary', () => {
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it('applies provider-owned reasoning options without changing the cached provider', async () => {
|
||||
const store = useProviderStore()
|
||||
const configStore = useProviderConfigStore()
|
||||
configStore.ensureProvider('openai', 'openai', {
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.openai.com/v1/',
|
||||
})
|
||||
|
||||
const baseProvider = await store.getProviderInstance<ChatProvider>('openai')
|
||||
const reasoningDisabledProvider = await store.getChatProviderInstance('openai', { reasoning: 'disabled' })
|
||||
const reasoningEnabledProvider = await store.getChatProviderInstance('openai', { reasoning: 'enabled' })
|
||||
|
||||
expect(reasoningDisabledProvider).not.toBe(baseProvider)
|
||||
expect(reasoningEnabledProvider).not.toBe(baseProvider)
|
||||
expect(reasoningDisabledProvider.chat('any-model')).toMatchObject({ reasoningEffort: 'none' })
|
||||
expect(reasoningEnabledProvider.chat('any-model')).toMatchObject({ reasoningEffort: 'medium' })
|
||||
expect(baseProvider.chat('any-model')).not.toHaveProperty('reasoningEffort')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// A model request kept a reference to its runtime entry across an await.
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import type {} from 'pinia-plugin-synced'
|
||||
|
||||
import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers'
|
||||
import type { ModelInfo, ProviderDefinition, ProviderInstance, VoiceInfo } from '../../libs/providers/types'
|
||||
import type { ChatRequestOptions, ModelInfo, ProviderDefinition, ProviderInstance, VoiceInfo } from '../../libs/providers/types'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
|
||||
@@ -48,6 +48,20 @@ export interface ProviderRuntimeState {
|
||||
const emptyProviderModels: ModelInfo[] = []
|
||||
Object.freeze(emptyProviderModels)
|
||||
|
||||
function withChatRequestOptions(
|
||||
provider: ChatProviderWithExtraOptions<string, ChatRequestOptions>,
|
||||
options: ChatRequestOptions,
|
||||
): ChatProvider {
|
||||
const decorated = {
|
||||
...provider,
|
||||
chat(model: string) {
|
||||
return provider.chat(model, options)
|
||||
},
|
||||
}
|
||||
|
||||
return decorated
|
||||
}
|
||||
|
||||
// Only the provider data plane crosses renderer boundaries. Async derived refs
|
||||
// stay in useProviderStore and recompute locally instead of being patched as
|
||||
// authoritative state by pinia-plugin-synced.
|
||||
@@ -754,9 +768,9 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
throw new Error(`Provider credentials for ${providerId} not found`)
|
||||
|
||||
try {
|
||||
const instance = await definition.createProvider(config || {}) as R
|
||||
const instance = await definition.createProvider(config || {})
|
||||
providerInstanceCache.set(providerId, instance)
|
||||
return instance
|
||||
return instance as R
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error creating provider instance for ${providerId}:`, error)
|
||||
@@ -764,6 +778,23 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passes AIRI chat options to the provider that owns their wire representation.
|
||||
* The cached base instance remains unchanged for consumers that do not opt in.
|
||||
*/
|
||||
async function getChatProviderInstance(
|
||||
providerId: string,
|
||||
options: ChatRequestOptions,
|
||||
): Promise<ChatProvider> {
|
||||
const provider = await getProviderInstance<ChatProviderWithExtraOptions<string, ChatRequestOptions>>(providerId)
|
||||
const definition = findProviderDefinition(providerId)
|
||||
const reasoning = definition?.capabilities?.chat?.reasoning
|
||||
if (!reasoning?.modes.includes(options.reasoning))
|
||||
return provider
|
||||
|
||||
return withChatRequestOptions(provider, options)
|
||||
}
|
||||
|
||||
async function disposeProviderInstance(providerId: string) {
|
||||
const instance = providerInstanceCache.get(providerId) as { dispose?: () => Promise<void> | void } | undefined
|
||||
if (instance?.dispose)
|
||||
@@ -879,6 +910,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
loadProviderModel,
|
||||
loadModelsForConfiguredProviders,
|
||||
getProviderInstance,
|
||||
getChatProviderInstance,
|
||||
disposeProviderInstance,
|
||||
resetProviderSettings,
|
||||
forceProviderConfigured,
|
||||
|
||||
Reference in New Issue
Block a user