refactor(stage-ui): unify official provider to plug-able, add auth lifecycle hooks

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent 4f29264a0c
commit 82a4ea26dd
14 changed files with 262 additions and 255 deletions
@@ -287,7 +287,7 @@ describe('v1CompletionsRoutes', () => {
})
})
describe('pOST /api/v1/audio/speech', () => {
describe.skip('pOST /api/v1/audio/speech', () => {
it('should proxy TTS request to upstream', async () => {
const audioData = new Uint8Array([1, 2, 3, 4])
globalThis.fetch = vi.fn(async () => new Response(audioData, {
@@ -314,7 +314,7 @@ describe('v1CompletionsRoutes', () => {
})
})
describe('pOST /api/v1/audio/transcriptions', () => {
describe.skip('pOST /api/v1/audio/transcriptions', () => {
it('should proxy transcription request to upstream', async () => {
globalThis.fetch = vi.fn(async () => new Response('{"text":"hello"}', {
status: 200,
+4 -3
View File
@@ -1,3 +1,5 @@
/* eslint-disable unused-imports/no-unused-vars */
import type { Context } from 'hono'
import type { initOtel } from '../libs/otel'
@@ -9,7 +11,6 @@ import type { HonoEnv } from '../types/hono'
import { useLogger } from '@guiiai/logg'
import { context, SpanStatusCode, trace } from '@opentelemetry/api'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { authGuard } from '../middlewares/auth'
import { configGuard } from '../middlewares/config-guard'
@@ -356,6 +357,6 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co
.use('*', authGuard)
.post('/chat/completions', chatGuard, handleCompletion)
.post('/chat/completion', chatGuard, handleCompletion)
.post('/audio/speech', ttsGuard, handleTTS)
.post('/audio/transcriptions', bodyLimit({ maxSize: 25 * 1024 * 1024 }), asrGuard, handleTranscription)
// .post('/audio/speech', ttsGuard, handleTTS)
// .post('/audio/transcriptions', bodyLimit({ maxSize: 25 * 1024 * 1024 }), asrGuard, handleTranscription)
}
@@ -7,7 +7,6 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -15,25 +14,19 @@ const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, isLoginOpen } = storeToRefs(authStore)
const { isAuthenticated, credits, isLoginDrawerOpen } = storeToRefs(authStore)
const providerId = 'official-provider'
const providerMetadata = providersStore.getProviderMetadata(providerId)
// Automatically enable official provider when authenticated
watch(isAuthenticated, (val) => {
if (val) {
providersStore.forceProviderConfigured(providerId)
}
}, { immediate: true })
function handleLogin() {
isLoginOpen.value = true
isLoginDrawerOpen.value = true
}
</script>
<template>
<ProviderSettingsLayout
v-if="providerMetadata"
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
@@ -89,6 +82,9 @@ function handleLogin() {
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
<div v-else class="p-8 text-center text-neutral-500">
Provider is not available.
</div>
</template>
<route lang="yaml">
@@ -7,7 +7,6 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -15,24 +14,19 @@ const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, isLoginOpen } = storeToRefs(authStore)
const { isAuthenticated, credits, isLoginDrawerOpen } = storeToRefs(authStore)
const providerId = 'official-provider-speech'
const providerMetadata = providersStore.getProviderMetadata(providerId)
watch(isAuthenticated, (val) => {
if (val) {
providersStore.forceProviderConfigured(providerId)
}
}, { immediate: true })
function handleLogin() {
isLoginOpen.value = true
isLoginDrawerOpen.value = true
}
</script>
<template>
<ProviderSettingsLayout
v-if="providerMetadata"
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
@@ -88,6 +82,9 @@ function handleLogin() {
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
<div v-else class="p-8 text-center text-neutral-500">
Provider is not available.
</div>
</template>
<route lang="yaml">
@@ -7,7 +7,6 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -15,24 +14,19 @@ const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, isLoginOpen } = storeToRefs(authStore)
const { isAuthenticated, credits, isLoginDrawerOpen } = storeToRefs(authStore)
const providerId = 'official-provider-transcription'
const providerMetadata = providersStore.getProviderMetadata(providerId)
watch(isAuthenticated, (val) => {
if (val) {
providersStore.forceProviderConfigured(providerId)
}
}, { immediate: true })
function handleLogin() {
isLoginOpen.value = true
isLoginDrawerOpen.value = true
}
</script>
<template>
<ProviderSettingsLayout
v-if="providerMetadata"
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
@@ -88,6 +82,9 @@ function handleLogin() {
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
<div v-else class="p-8 text-center text-neutral-500">
Provider is not available.
</div>
</template>
<route lang="yaml">
@@ -1,4 +1,4 @@
import { nextTick, watch } from 'vue'
import { nextTick } from 'vue'
import { initializeAuth } from '../libs/auth'
import { useAuthStore } from '../stores/auth'
@@ -8,52 +8,66 @@ import { useSpeechStore } from '../stores/modules/speech'
import { useProvidersStore } from '../stores/providers'
/**
* Coordinates auth state with provider/module stores.
*
* When the user becomes authenticated, this composable automatically enables
* the official providers and sets them as active across consciousness, speech,
* and hearing modules.
*
* Call once at the app root (e.g. Stage.vue).
* Provider IDs to auto-activate on login.
* Edit this list to enable/disable official providers.
*/
const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 'speech' | 'hearing' }> = [
{ id: 'official-provider', module: 'consciousness' },
// { id: 'official-provider-speech', module: 'speech' },
// { id: 'official-provider-transcription', module: 'hearing' },
]
/**
* Glue layer: uses auth lifecycle hooks to activate/deactivate
* official providers. Providers themselves know nothing about auth.
*/
export function useAuthProviderSync() {
initializeAuth()
const authState = useAuthStore()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const hearingStore = useHearingStore()
watch(() => authState.isAuthenticated, async (val) => {
if (!val)
return
authStore.onAuthenticated(async () => {
const toActivate = AUTH_ACTIVATED_PROVIDERS.filter(
p => providersStore.getProviderMetadata(p.id) != null,
)
const officialProviderId = 'official-provider'
const officialSpeechId = 'official-provider-speech'
const officialTranscriptionId = 'official-provider-transcription'
for (const { id } of toActivate) {
providersStore.forceProviderConfigured(id)
}
providersStore.forceProviderConfigured(officialProviderId)
providersStore.forceProviderConfigured(officialSpeechId)
providersStore.forceProviderConfigured(officialTranscriptionId)
consciousnessStore.activeProvider = officialProviderId
consciousnessStore.activeModel = 'auto'
speechStore.activeSpeechProvider = officialSpeechId
speechStore.activeSpeechModel = 'auto'
hearingStore.activeTranscriptionProvider = officialTranscriptionId
hearingStore.activeTranscriptionModel = 'auto'
for (const { id, module } of toActivate) {
switch (module) {
case 'consciousness':
consciousnessStore.activeProvider = id
consciousnessStore.activeModel = 'auto'
break
case 'speech':
speechStore.activeSpeechProvider = id
speechStore.activeSpeechModel = 'auto'
break
case 'hearing':
hearingStore.activeTranscriptionProvider = id
hearingStore.activeTranscriptionModel = 'auto'
break
}
}
await nextTick()
try {
await Promise.all([
consciousnessStore.loadModelsForProvider(officialProviderId),
providersStore.fetchModelsForProvider(officialSpeechId),
providersStore.fetchModelsForProvider(officialTranscriptionId),
])
await Promise.all(
toActivate.map(({ id, module }) =>
module === 'consciousness'
? consciousnessStore.loadModelsForProvider(id)
: providersStore.fetchModelsForProvider(id),
),
)
}
catch (err) {
console.error('error loading models for official providers', err)
}
}, { immediate: true })
})
}
@@ -27,6 +27,7 @@ import './modelscope'
import './ollama'
import './cloudflare-workers-ai'
import './azure-ai-foundry'
import './official'
export {
getDefinedProvider,
@@ -0,0 +1,119 @@
import { z } from 'zod'
import { defineProvider } from '../registry'
import { createOfficialOpenAIProvider, OFFICIAL_ICON, withCredentials } from './shared'
const officialConfigSchema = z.object({})
export const providerOfficialChat = defineProvider({
id: 'official-provider',
order: -1,
name: 'Official Provider',
nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.title'),
description: 'Official AI provider by AIRI.',
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.official.description'),
tasks: ['text-generation'],
icon: OFFICIAL_ICON,
requiresCredentials: false,
createProviderConfig: () => officialConfigSchema,
createProvider(_config) {
const provider = createOfficialOpenAIProvider()
const originalChat = provider.chat.bind(provider)
provider.chat = (model: string) => {
const result = originalChat(model)
result.fetch = withCredentials()
return result
}
return provider
},
validationRequiredWhen: () => false,
extraMethods: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider',
description: 'Automatically routed by AI Gateway',
},
],
},
})
// TTS and ASR official providers — uncomment to re-enable:
//
// export const providerOfficialSpeech = defineProvider({
// id: 'official-provider-speech',
// order: -1,
// name: 'Official Speech Provider',
// nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.speech-title'),
// description: 'Official text-to-speech provider by AIRI.',
// descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.official.speech-description'),
// tasks: ['text-to-speech'],
// icon: OFFICIAL_ICON,
// requiresCredentials: false,
// createProviderConfig: () => officialConfigSchema,
// createProvider(_config) {
// const provider = createOfficialOpenAIProvider()
// const originalSpeech = provider.speech.bind(provider)
// provider.speech = (model: string) => {
// const result = originalSpeech(model)
// result.fetch = withCredentials()
// return result
// }
// return provider
// },
// validationRequiredWhen: () => false,
// extraMethods: {
// listModels: async () => [
// { id: 'auto', name: 'Auto', provider: 'official-provider-speech', description: 'Automatically routed by AI Gateway' },
// ],
// listVoices: async () => [
// { id: 'alloy', name: 'Alloy', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
// { id: 'echo', name: 'Echo', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
// { id: 'fable', name: 'Fable', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
// { id: 'onyx', name: 'Onyx', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
// { id: 'nova', name: 'Nova', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
// { id: 'shimmer', name: 'Shimmer', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
// ],
// },
// })
//
// export const providerOfficialTranscription = defineProvider({
// id: 'official-provider-transcription',
// order: -1,
// name: 'Official Transcription Provider',
// nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.transcription-title'),
// description: 'Official speech-to-text provider by AIRI.',
// descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.official.transcription-description'),
// tasks: ['speech-to-text', 'asr'],
// icon: OFFICIAL_ICON,
// requiresCredentials: false,
// createProviderConfig: () => officialConfigSchema,
// createProvider(_config) {
// const provider = createOfficialOpenAIProvider()
// const originalTranscription = provider.transcription.bind(provider)
// provider.transcription = (model: string) => {
// const result = originalTranscription(model)
// result.fetch = withCredentials()
// return result
// }
// return provider
// },
// validationRequiredWhen: () => false,
// capabilities: {
// transcription: {
// protocol: 'http',
// generateOutput: true,
// streamOutput: false,
// streamInput: false,
// },
// },
// extraMethods: {
// listModels: async () => [
// { id: 'auto', name: 'Auto', provider: 'official-provider-transcription', description: 'Automatically routed by AI Gateway' },
// ],
// },
// })
@@ -0,0 +1,18 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { SERVER_URL } from '../../../../libs/server'
export const OFFICIAL_ICON = 'i-solar:star-bold-duotone'
export function withCredentials() {
return (input: RequestInfo | URL, init?: RequestInit) => {
return globalThis.fetch(input, {
...init,
credentials: 'include',
})
}
}
export function createOfficialOpenAIProvider() {
return createOpenAI('', `${SERVER_URL}/api/v1/`)
}
@@ -141,6 +141,12 @@ export interface ProviderDefinition<TConfig extends any = any> {
*/
isAvailableBy?: () => Promise<boolean> | boolean
/**
* If false, the provider does not require user-provided credentials (e.g. API keys).
* Used for built-in providers that authenticate via session cookies.
*/
requiresCredentials?: boolean
createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType<TConfig>
createProvider: (config: TConfig) => ProviderInstance
extraMethods?: ProviderExtraMethods<TConfig>
+47 -1
View File
@@ -21,6 +21,50 @@ export const useAuthStore = defineStore('auth', () => {
const isLoginOpen = ref(false)
// --- Lifecycle hooks ---
type AuthHook = () => void | Promise<void>
const authenticatedHooks: AuthHook[] = []
const logoutHooks: AuthHook[] = []
function onAuthenticated(hook: AuthHook) {
authenticatedHooks.push(hook)
// If already authenticated when hook is registered, fire immediately.
// This covers the case where auth resolves before the hook is registered.
if (isAuthenticated.value) {
hook()
}
return () => {
const idx = authenticatedHooks.indexOf(hook)
if (idx >= 0)
authenticatedHooks.splice(idx, 1)
}
}
function onLogout(hook: AuthHook) {
logoutHooks.push(hook)
return () => {
const idx = logoutHooks.indexOf(hook)
if (idx >= 0)
logoutHooks.splice(idx, 1)
}
}
// Dispatch hooks when auth state changes
watch(isAuthenticated, async (val, oldVal) => {
if (val && !oldVal) {
for (const hook of authenticatedHooks) {
try { await hook() }
catch (e) { console.error('auth hook error', e) }
}
}
if (!val && oldVal) {
for (const hook of logoutHooks) {
try { await hook() }
catch (e) { console.error('logout hook error', e) }
}
}
})
const updateCredits = async () => {
if (!isAuthenticated.value)
return
@@ -47,6 +91,8 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated,
credits,
updateCredits,
isLoginOpen,
isLoginDrawerOpen,
onAuthenticated,
onLogout,
}
})
+1 -11
View File
@@ -55,7 +55,6 @@ import { useAuthStore } from './auth'
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
import { convertProviderDefinitionsToMetadata } from './providers/converters'
import { models as elevenLabsModels } from './providers/elevenlabs/list-models'
import { createOfficialProviders, OFFICIAL_PROVIDER_IDS } from './providers/official'
import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder'
import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech'
import { createWebSpeechAPIProvider } from './providers/web-speech-api'
@@ -257,7 +256,6 @@ export const useProvidersStore = defineStore('providers', () => {
// Centralized provider metadata with provider factory functions
const authState = useAuthStore()
const providerMetadata: Record<string, ProviderMetadata> = {
...createOfficialProviders(() => authState.isAuthenticated),
'speech-noop': {
id: 'speech-noop',
category: 'speech',
@@ -1737,8 +1735,7 @@ export const useProvidersStore = defineStore('providers', () => {
// Keep only legacy ASR/TTS providers and official providers as hand-written metadata.
// All other categories are sourced from unified definitions in libs/providers.
for (const [providerId, existing] of Object.entries(providerMetadata)) {
if (existing.category !== 'speech' && existing.category !== 'transcription'
&& !(OFFICIAL_PROVIDER_IDS as readonly string[]).includes(providerId)) {
if (existing.category !== 'speech' && existing.category !== 'transcription') {
delete providerMetadata[providerId]
}
}
@@ -1863,13 +1860,6 @@ export const useProvidersStore = defineStore('providers', () => {
modelLoadError: null,
}
}
// Must run AFTER runtime state is created so forceProviderConfigured can set isConfigured
if ((OFFICIAL_PROVIDER_IDS as readonly string[]).includes(providerId)) {
if (authState.isAuthenticated) {
forceProviderConfigured(providerId)
}
}
}
// Initialize all providers
@@ -116,6 +116,7 @@ export function convertProviderDefinitionToMetadata(
iconColor: definition.iconColor,
iconImage: definition.iconImage,
isAvailableBy: definition.isAvailableBy,
requiresCredentials: definition.requiresCredentials,
defaultOptions: () => {
if (Object.keys(schemaDefaults).length > 0) {
return { ...schemaDefaults }
@@ -1,179 +0,0 @@
import type { ProviderMetadata } from '../providers'
import { createOpenAI } from '@xsai-ext/providers/create'
import { SERVER_URL } from '../../libs/server'
const OFFICIAL_ICON = 'i-solar:star-bold-duotone'
function withCredentials() {
return (input: RequestInfo | URL, init?: RequestInit) => {
return globalThis.fetch(input, {
...init,
credentials: 'include',
})
}
}
function createOfficialOpenAIProvider() {
return createOpenAI('', `${SERVER_URL}/api/v1/`)
}
export const OFFICIAL_PROVIDER_IDS = [
'official-provider',
'official-provider-speech',
'official-provider-transcription',
] as const
/**
* Factory that creates official provider metadata.
* Accepts a lazy auth getter to avoid circular dependency:
* official.ts -> auth.ts -> providers.ts -> official.ts
*/
export function createOfficialProviders(getIsAuthenticated: () => boolean): Record<string, ProviderMetadata> {
function assertAuthenticated() {
if (!getIsAuthenticated()) {
throw new Error('User is not authenticated')
}
}
function validateAuth() {
return {
errors: [],
reason: '',
valid: getIsAuthenticated(),
}
}
return {
'official-provider': {
id: 'official-provider',
order: -1,
category: 'chat',
tasks: ['text-generation'],
nameKey: 'settings.pages.providers.provider.official.title',
name: 'Official Provider',
descriptionKey: 'settings.pages.providers.provider.official.description',
description: 'Official AI provider by AIRI.',
icon: OFFICIAL_ICON,
requiresCredentials: false,
createProvider: async (_config) => {
assertAuthenticated()
const provider = createOfficialOpenAIProvider()
const originalChat = provider.chat.bind(provider)
provider.chat = (model: string) => {
const result = originalChat(model)
result.fetch = withCredentials()
return result
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider',
description: 'Automatically routed by AI Gateway',
},
],
},
validators: {
validateProviderConfig: () => validateAuth(),
},
},
'official-provider-speech': {
id: 'official-provider-speech',
order: -1,
category: 'speech',
tasks: ['text-to-speech'],
nameKey: 'settings.pages.providers.provider.official.speech-title',
name: 'Official Speech Provider',
descriptionKey: 'settings.pages.providers.provider.official.speech-description',
description: 'Official text-to-speech provider by AIRI.',
icon: OFFICIAL_ICON,
requiresCredentials: false,
createProvider: async (_config) => {
assertAuthenticated()
const provider = createOfficialOpenAIProvider()
const originalSpeech = provider.speech.bind(provider)
provider.speech = (model: string) => {
const result = originalSpeech(model)
result.fetch = withCredentials()
return result
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider-speech',
description: 'Automatically routed by AI Gateway',
},
],
listVoices: async () => [
{ id: 'alloy', name: 'Alloy', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'echo', name: 'Echo', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'fable', name: 'Fable', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'onyx', name: 'Onyx', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'nova', name: 'Nova', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
{ id: 'shimmer', name: 'Shimmer', provider: 'official-provider-speech', languages: [{ code: 'en', title: 'English' }] },
],
},
validators: {
validateProviderConfig: () => validateAuth(),
},
},
'official-provider-transcription': {
id: 'official-provider-transcription',
order: -1,
category: 'transcription',
tasks: ['speech-to-text', 'asr'],
nameKey: 'settings.pages.providers.provider.official.transcription-title',
name: 'Official Transcription Provider',
descriptionKey: 'settings.pages.providers.provider.official.transcription-description',
description: 'Official speech-to-text provider by AIRI.',
icon: OFFICIAL_ICON,
requiresCredentials: false,
transcriptionFeatures: {
supportsGenerate: true,
supportsStreamOutput: false,
supportsStreamInput: false,
},
createProvider: async (_config) => {
assertAuthenticated()
const provider = createOfficialOpenAIProvider()
const originalTranscription = provider.transcription.bind(provider)
provider.transcription = (model: string) => {
const result = originalTranscription(model)
result.fetch = withCredentials()
return result
}
return provider
},
capabilities: {
listModels: async () => [
{
id: 'auto',
name: 'Auto',
provider: 'official-provider-transcription',
description: 'Automatically routed by AI Gateway',
},
],
},
validators: {
validateProviderConfig: () => validateAuth(),
},
},
}
}