From 03bcd64bfcbd85cc56a2478f8355cb98071a947a Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 18 Sep 2026 19:15:32 +0800 Subject: [PATCH] feat(stage-ui): allow official provider protocol selection (#2583) --- .../official-provider-chat.browser.test.ts | 50 +++++++++++++++++++ packages/provider-inference/README.md | 6 ++- packages/provider-inference/src/generation.ts | 21 +++++++- packages/provider-inference/src/index.ts | 1 + .../cloud/openai-compatible/index.ts | 13 +++-- .../src/providers/cloud/openai/index.ts | 31 ++++++------ .../src/providers/responses.test.ts | 12 +++++ .../providers/chat/official-provider.vue | 9 ++++ .../providers/official/index.test.ts | 46 ++++++++++++++++- .../providers/providers/official/index.ts | 47 +++++++++++++---- 10 files changed, 205 insertions(+), 31 deletions(-) create mode 100644 apps/stage-web/src/pages/official-provider-chat.browser.test.ts diff --git a/apps/stage-web/src/pages/official-provider-chat.browser.test.ts b/apps/stage-web/src/pages/official-provider-chat.browser.test.ts new file mode 100644 index 0000000000..6b160beb51 --- /dev/null +++ b/apps/stage-web/src/pages/official-provider-chat.browser.test.ts @@ -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') + }) +}) diff --git a/packages/provider-inference/README.md b/packages/provider-inference/README.md index 0c738fb6a5..8065a23763 100644 --- a/packages/provider-inference/README.md +++ b/packages/provider-inference/README.md @@ -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. diff --git a/packages/provider-inference/src/generation.ts b/packages/provider-inference/src/generation.ts index c2f2ecbb44..5e68db8597 100644 --- a/packages/provider-inference/src/generation.ts +++ b/packages/provider-inference/src/generation.ts @@ -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 + +/** 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 = { diff --git a/packages/provider-inference/src/index.ts b/packages/provider-inference/src/index.ts index 3b13eb969c..54a612944e 100644 --- a/packages/provider-inference/src/index.ts +++ b/packages/provider-inference/src/index.ts @@ -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' diff --git a/packages/provider-inference/src/providers/cloud/openai-compatible/index.ts b/packages/provider-inference/src/providers/cloud/openai-compatible/index.ts index 403da50c1e..53efe2ccc2 100644 --- a/packages/provider-inference/src/providers/cloud/openai-compatible/index.ts +++ b/packages/provider-inference/src/providers/cloud/openai-compatible/index.ts @@ -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 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({ 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({ // 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 } : {}) }, + } } }, } diff --git a/packages/provider-inference/src/providers/responses.test.ts b/packages/provider-inference/src/providers/responses.test.ts index 61b1aa37ee..188bd646dd 100644 --- a/packages/provider-inference/src/providers/responses.test.ts +++ b/packages/provider-inference/src/providers/responses.test.ts @@ -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)) diff --git a/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue b/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue index 6da4394042..68e3500194 100644 --- a/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue +++ b/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue @@ -1,6 +1,8 @@