feat(stage-ui): allow official provider protocol selection (#2583)

This commit is contained in:
RainbowBird
2026-09-18 11:15:32 +00:00
committed by GitHub
parent 70bcee06a3
commit 03bcd64bfc
10 changed files with 205 additions and 31 deletions
@@ -0,0 +1,50 @@
import en from '@proj-airi/i18n/locales/en'
import { PiniaColada } from '@pinia/colada'
import { ProviderGenerationSettings } from '@proj-airi/stage-ui/components'
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { createI18n } from 'vue-i18n'
import 'virtual:uno.css'
async function renderSettings() {
const pinia = createPinia()
setActivePinia(pinia)
const screen = await render(ProviderGenerationSettings, {
props: { providerId: 'official-provider' },
global: {
plugins: [pinia, PiniaColada, createI18n({ legacy: false, locale: 'en', messages: { en } })],
},
})
const configStore = useProviderConfigStore(pinia)
configStore.ensureProvider('official-provider', 'official-provider', { api: 'responses' })
return { configStore, screen }
}
describe('official chat provider settings', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('persists the selected API protocol', async () => {
const { configStore, screen } = await renderSettings()
await expect.element(screen.getByText('API protocol', { exact: true })).toBeVisible()
await expect.element(screen.getByRole('combobox')).toHaveValue('Responses API')
await screen.getByRole('combobox').click()
await screen.getByRole('option', { name: 'Chat Completions' }).click()
await expect.poll(() => configStore.getProviderConfig('official-provider')?.api).toBe('chat-completions')
})
})
+5 -1
View File
@@ -38,6 +38,9 @@ OpenAI and OpenAI Compatible configurations accept `api: 'chat-completions' | 'r
`GenerationProvider.generation(model, options)` selects a protocol and returns its request configuration.
Core-agent receives this single interface and projects context directly into the selected protocol.
Protocol defaults and native tools remain provider-owned policy. The validation probe uses the selected protocol.
`generationProtocolDefinitions` owns the display metadata for every protocol.
Provider schemas use `generationProtocolOptions` to keep selector labels consistent.
Adding a protocol requires a request type and a matching metadata entry. TypeScript reports an incomplete registry or provider request switch.
```ts
const definition = getDefinedProvider('openai')
@@ -47,7 +50,8 @@ const provider = await definition.createProvider({
})
```
User-configured providers send Responses requests directly to their configured endpoint with their own API key. They do not require AIRI backend changes or Flux billing. The official provider continues to use Chat Completions; its Responses support is a separate gateway change.
User-configured providers send Responses requests directly to their configured endpoint with their own API key. They do not require AIRI backend changes or Flux billing.
The official provider defaults to Responses and lets the user select Chat Completions.
OpenAI has a `webSearch` switch, disabled by default. The selected protocol must be Responses.
Explicitly enabling search sends the hosted tool on the official OpenAI endpoint. The provider validates model support.
+20 -1
View File
@@ -1,4 +1,23 @@
import type { GenerationCapabilities } from './types'
import type { GenerationCapabilities, GenerationRequest } from './types'
interface GenerationProtocolDefinition {
/** The protocol name shown in provider settings. */
label: string
}
/** Metadata for every wire protocol that AIRI can use for text generation. */
export const generationProtocolDefinitions = {
'chat-completions': { label: 'Chat Completions' },
'responses': { label: 'Responses API' },
} as const satisfies Record<GenerationRequest['protocol'], GenerationProtocolDefinition>
/** Builds selector options in the order declared by a provider capability. */
export function generationProtocolOptions(capabilities: GenerationCapabilities) {
return capabilities.supportedProtocols.map(protocol => ({
label: generationProtocolDefinitions[protocol].label,
value: protocol,
}))
}
/** The protocol list is shared by runtime defaults and the provider editor. */
export const openAIProtocols = {
+1
View File
@@ -17,6 +17,7 @@ export function listProviders() {
}
export { portableProviderDefinitions }
export * from './generation'
export { createWebSpeechAPIProvider, streamWebSpeechAPITranscription } from './providers/local/browser-web-speech-api'
export * from './providers/registry'
export * from './types'
@@ -3,7 +3,7 @@ import type { GenerationRequest } from '../../../types'
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { compatibleProtocols } from '../../../generation'
import { compatibleProtocols, generationProtocolOptions } from '../../../generation'
import { ProviderValidationCheck } from '../../../types'
import { createOpenAICompatibleValidators } from '../../../validators'
import { defineProvider } from '../../registry'
@@ -35,7 +35,7 @@ export const providerOpenAICompatible = defineProvider<Config, 'openai-compatibl
createProviderConfig: ({ t }) => configSchema.extend({
api: configSchema.shape.api.meta({
type: 'select',
options: compatibleProtocols.supportedProtocols.map(value => ({ label: value === 'responses' ? 'Responses API' : 'Chat Completions', value })),
options: generationProtocolOptions(compatibleProtocols),
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-protocol.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-protocol.description'),
}),
@@ -57,9 +57,12 @@ export const providerOpenAICompatible = defineProvider<Config, 'openai-compatibl
model: provider.model,
generation(model: string): GenerationRequest {
const request = provider.chat(model)
return config.api === 'responses'
? { protocol: 'responses', config: request, webSearch: false }
: { protocol: 'chat-completions', config: request }
switch (config.api ?? compatibleProtocols.defaultProtocol) {
case 'responses':
return { protocol: 'responses', config: request, webSearch: false }
case 'chat-completions':
return { protocol: 'chat-completions', config: request }
}
},
}
},
@@ -5,7 +5,7 @@ import { openaiChatModels } from 'model-bank/openai'
import { MODEL_REASONING_EXTEND_PARAMS, MODEL_REASONING_PARAM_LEVELS } from 'model-bank/types'
import { z } from 'zod'
import { openAIProtocols, supportsOpenAIWebSearchEndpoint } from '../../../generation'
import { generationProtocolOptions, openAIProtocols, supportsOpenAIWebSearchEndpoint } from '../../../generation'
import { listModelCatalog } from '../../../model-catalog'
import { ProviderValidationCheck } from '../../../types'
import { createOpenAICompatibleValidators } from '../../../validators'
@@ -38,7 +38,7 @@ export const providerOpenAI = defineProvider<Config, 'openai'>({
createProviderConfig: ({ t, config }) => configSchema.extend({
api: configSchema.shape.api.meta({
type: 'select',
options: openAIProtocols.supportedProtocols.map(value => ({ label: value === 'responses' ? 'Responses API' : 'Chat Completions', value })),
options: generationProtocolOptions(openAIProtocols),
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-protocol.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-protocol.description'),
}),
@@ -73,20 +73,23 @@ export const providerOpenAI = defineProvider<Config, 'openai'>({
// Only send an explicit effort supported by this exact model's catalog entry.
// Unknown models use server defaults; older reasoning models cannot disable reasoning.
const supportsEffort = options?.reasoning && supportedEfforts?.includes(effort)
if ((config.api ?? openAIProtocols.defaultProtocol) === 'responses') {
const responseConfig: ResponsesConfig = { ...request }
if (supportsEffort)
responseConfig.reasoning = { effort, ...(options?.reasoning === 'enabled' ? { summary: 'auto' as const } : {}) }
switch (config.api ?? openAIProtocols.defaultProtocol) {
case 'responses': {
const responseConfig: ResponsesConfig = { ...request }
if (supportsEffort)
responseConfig.reasoning = { effort, ...(options?.reasoning === 'enabled' ? { summary: 'auto' as const } : {}) }
return {
protocol: 'responses',
webSearch: config.webSearch === true && supportsOpenAIWebSearchEndpoint(request.baseURL),
config: responseConfig,
return {
protocol: 'responses',
webSearch: config.webSearch === true && supportsOpenAIWebSearchEndpoint(request.baseURL),
config: responseConfig,
}
}
}
return {
protocol: 'chat-completions',
config: { ...request, ...(supportsEffort ? { reasoningEffort: effort } : {}) },
case 'chat-completions':
return {
protocol: 'chat-completions',
config: { ...request, ...(supportsEffort ? { reasoningEffort: effort } : {}) },
}
}
},
}
@@ -1,12 +1,24 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { compatibleProtocols, generationProtocolOptions, openAIProtocols } from '../generation'
import { getGenerationProvider, isGenerationProvider } from '../types'
import { providerOpenAI } from './cloud/openai'
import { providerOpenAICompatible } from './cloud/openai-compatible'
import { providerOpenRouterAI } from './cloud/openrouter-ai'
describe('generation selection', () => {
it('builds protocol options from the shared protocol registry in provider order', () => {
expect(generationProtocolOptions(openAIProtocols)).toEqual([
{ label: 'Responses API', value: 'responses' },
{ label: 'Chat Completions', value: 'chat-completions' },
])
expect(generationProtocolOptions(compatibleProtocols)).toEqual([
{ label: 'Chat Completions', value: 'chat-completions' },
{ label: 'Responses API', value: 'responses' },
])
})
it('defaults OpenAI to Responses without inferring search from model names', async () => {
const provider = await providerOpenAI.createProvider({ apiKey: 'test' })
if (!isGenerationProvider(provider))
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { isFluxPurchaseDisabled } from '@proj-airi/stage-shared'
import {
ProviderBasicSettings,
ProviderGenerationSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
@@ -55,6 +57,13 @@ function handleLogin() {
</div>
<div v-else flex flex-col gap-6>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
>
<ProviderGenerationSettings :provider-id="providerId" />
</ProviderBasicSettings>
<div class="rounded-xl bg-neutral-100/50 p-6 backdrop-blur-sm dark:bg-neutral-800/50">
<div flex items-center justify-between>
<div flex flex-col gap-1>
@@ -1,8 +1,10 @@
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import { isGenerationProvider } from '@proj-airi/provider-inference'
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID, providerOfficialSpeech, providerOfficialSpeechStreaming, providerOfficialTranscription } from './index'
import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID, providerOfficialChat, providerOfficialSpeech, providerOfficialSpeechStreaming, providerOfficialTranscription } from './index'
interface OfficialSpeechOptions {
speed?: number
@@ -17,6 +19,48 @@ interface OfficialSpeechOptions {
}
}
describe('official chat provider', () => {
it('defaults new and existing empty configurations to Responses', async () => {
const schema = await providerOfficialChat.createProviderConfig({ t: key => key })
expect(z.parse(schema, {})).toEqual({ api: 'responses' })
const provider = await providerOfficialChat.createProvider({})
if (!isGenerationProvider(provider))
throw new Error('Expected generation')
expect(provider.generation('auto')).toMatchObject({
protocol: 'responses',
webSearch: false,
config: { model: 'auto' },
})
})
it('uses Chat Completions when the user selects it', async () => {
const provider = await providerOfficialChat.createProvider({ api: 'chat-completions' })
if (!isGenerationProvider(provider))
throw new Error('Expected generation')
expect(provider.generation('auto')).toMatchObject({
protocol: 'chat-completions',
config: { model: 'auto' },
})
})
it('describes the protocol selector in its configuration schema', async () => {
const schema = await providerOfficialChat.createProviderConfig({ t: key => key })
if (!(schema instanceof z.ZodObject))
throw new Error('Expected an object configuration schema')
expect(schema.shape.api.meta()).toMatchObject({
type: 'select',
options: [
{ label: 'Responses API', value: 'responses' },
{ label: 'Chat Completions', value: 'chat-completions' },
],
})
})
})
describe('official speech provider', () => {
/**
* @example
@@ -1,5 +1,8 @@
import type { ChatRequestOptions, GenerationRequest } from '@proj-airi/provider-inference'
import type { ModelInfo, ProviderModelCatalog, VoiceInfo } from '../../types'
import { generationProtocolOptions, openAIProtocols } from '@proj-airi/provider-inference'
import { z } from 'zod'
import { getAuthToken } from '../../../../libs/auth'
@@ -11,6 +14,11 @@ import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICO
export { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from './constants'
const officialConfigSchema = z.object({})
const officialChatConfigSchema = z.object({
api: z.enum(openAIProtocols.supportedProtocols).default(openAIProtocols.defaultProtocol),
})
type OfficialChatConfig = z.input<typeof officialChatConfigSchema>
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' }
@@ -47,7 +55,7 @@ async function listStreamingModelCatalog(): Promise<ProviderModelCatalog> {
}
}
export const providerOfficialChat = defineProvider({
export const providerOfficialChat = defineProvider<OfficialChatConfig, typeof OFFICIAL_CHAT_PROVIDER_ID>({
id: OFFICIAL_CHAT_PROVIDER_ID,
order: -1,
name: 'Official Provider',
@@ -58,17 +66,38 @@ export const providerOfficialChat = defineProvider({
icon: OFFICIAL_ICON,
requiresCredentials: false,
configuredBy: 'authentication',
capabilities: { chat: { generation: openAIProtocols } },
createProviderConfig: () => officialConfigSchema,
createProvider(_config) {
createProviderConfig: ({ t }) => officialChatConfigSchema.extend({
api: officialChatConfigSchema.shape.api.meta({
type: 'select',
options: generationProtocolOptions(openAIProtocols),
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-protocol.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-protocol.description'),
}),
}),
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 {
model: provider.model,
generation(model: string, _options?: ChatRequestOptions): GenerationRequest {
const request = provider.chat(model)
request.fetch = withCredentials()
switch (config.api ?? openAIProtocols.defaultProtocol) {
case 'responses':
return {
protocol: 'responses',
webSearch: false,
config: request,
}
case 'chat-completions':
return {
protocol: 'chat-completions',
config: request,
}
}
},
}
return provider
},
validationRequiredWhen: () => false,