feat(stage-ui): added Azure OpenAI provider (#1057)

---------

Assume-co-authored-by-agent: Unknown <unknown@example.com>
Co-authored-by-agent: Codex <267193182+codex@users.noreply.github.com>
This commit is contained in:
breezy89757
2026-03-26 17:39:15 +08:00
committed by GitHub
parent caa197ebdf
commit f4d5d3de8d
10 changed files with 402 additions and 7 deletions
+1 -1
View File
@@ -334,7 +334,7 @@ npx bumpp --no-commit --no-tag
- [x] [Ollama](https://github.com/ollama/ollama)
- [x] [302.AI (sponsored)](https://share.302.ai/514k2v)
- [x] [OpenAI](https://platform.openai.com/docs/guides/gpt/chat-completions-api)
- [ ] [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) (PR welcome)
- [x] [Azure OpenAI API](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference)
- [x] [Anthropic Claude](https://anthropic.com)
- [ ] [AWS Claude](https://docs.anthropic.com/en/api/claude-on-amazon-bedrock) (PR welcome)
- [x] [DeepSeek](https://www.deepseek.com/)
@@ -775,6 +775,9 @@ pages:
openai:
description: openai.com
title: OpenAI
azure-openai:
description: Azure OpenAI API
title: Azure OpenAI
openai-compatible:
description: OpenAI Compatible
title: OpenAI Compatible
@@ -744,6 +744,9 @@ pages:
openai:
description: OpenAi.com
title: OpenAI
azure-openai:
description: Azure OpenAI API
title: Azure OpenAI
openai-compatible:
description: OpenAI 相容
title: OpenAI 兼容性
@@ -85,6 +85,7 @@ function goToModelSelection() {
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon="providerMetadata?.icon"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
@@ -41,7 +41,7 @@ const {
// Popular providers for first-time setup
const popularProviders = computed(() => {
const popular = ['openai', 'anthropic', 'google-generative-ai', 'groq', 'nvidia', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible']
const popular = ['openai', 'azure-openai', 'anthropic', 'google-generative-ai', 'groq', 'nvidia', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible']
return allChatProvidersMetadata.value
.filter(provider => popular.includes(provider.id))
.sort((a, b) => popular.indexOf(a.id) - popular.indexOf(b.id))
@@ -88,13 +88,14 @@ async function saveProviderConfiguration(data: ProviderConfigData) {
}
activeProvider.value = selectedProvider.value.id
await nextTick()
try {
await consciousnessStore.loadModelsForProvider(selectedProvider.value.id)
}
catch (err) {
console.error('error', err)
console.error('[onboarding] Failed to load models for provider:', err)
}
}
@@ -22,6 +22,7 @@ const {
modelSearchQuery,
providerModels,
isLoadingActiveProviderModels,
activeProviderModelError,
} = storeToRefs(consciousnessStore)
</script>
@@ -68,6 +69,17 @@ const {
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
list-class="max-h-[calc(100dvh-17rem)] sm:max-h-120 overflow-y-auto"
/>
<Alert v-if="activeProviderModelError" type="error">
<template #title>
{{ t('settings.dialogs.onboarding.validationFailed') }}
</template>
<template #content>
<div class="whitespace-pre-wrap break-all">
{{ activeProviderModelError }}
</div>
</template>
</Alert>
</div>
<!-- Action Buttons -->
@@ -34,8 +34,8 @@ function initializeForm() {
if (!provider)
return
const defaultOptions = provider.defaultOptions?.() || {}
baseUrl.value = (defaultOptions as any)?.baseUrl || ''
const defaultOptions = provider.defaultOptions?.() ?? {}
baseUrl.value = ('baseUrl' in defaultOptions ? String(defaultOptions.baseUrl) : '') || ''
apiKey.value = ''
accountId.value = ''
@@ -144,6 +144,7 @@ async function handleContinueAnyway() {
function getApiKeyPlaceholder(providerId: string): string {
const placeholders: Record<string, string> = {
'openai': 'sk-...',
'azure-openai': 'Azure OpenAI API Key',
'anthropic': 'sk-ant-...',
'google-generative-ai': 'AI...',
'openrouter-ai': 'sk-or-...',
@@ -0,0 +1,373 @@
import { errorMessageFrom } from '@moeru/std'
import { createOpenAI } from '@xsai-ext/providers/create'
import { z } from 'zod'
import { defineProvider } from '../registry'
const AZURE_OPENAI_PROVIDER_ID = 'azure-openai' as const
const DEFAULT_COMPLETIONS_API_VERSION = '2024-04-01-preview'
const DEFAULT_AZURE_BASE_URL = 'https://YOUR_RESOURCE_NAME.cognitiveservices.azure.com/openai/'
const FALLBACK_AZURE_ORIGIN = 'https://YOUR_RESOURCE_NAME.cognitiveservices.azure.com'
const DEFAULT_AZURE_OPENAI_COMPATIBLE_BASE_URL = `${FALLBACK_AZURE_ORIGIN}/openai/v1`
const DEPLOYMENT_CHAT_COMPLETIONS_PATH_REGEX = /\/openai\/deployments\/[^/]+\/chat\/completions\/?$/i
const OPENAI_PATH_REGEX = /^\/openai\/?$/i
const DEPLOYMENT_MATCH_REGEX = /\/openai\/deployments\/([^/]+)\/chat\/completions\/?$/i
const CHAT_COMPLETIONS_PATH_REGEX = /\/chat\/completions\/?$/i
const TRAILING_SLASH_REGEX = /\/$/
const azureOpenAIConfigSchema = z.object({
apiKey: z
.string('API Key'),
baseUrl: z
.string('Base URL')
.optional()
.default(DEFAULT_AZURE_BASE_URL),
completionsApiVersion: z
.string('Completions API Version')
.optional()
.default(DEFAULT_COMPLETIONS_API_VERSION),
})
type AzureOpenAIConfig = z.input<typeof azureOpenAIConfigSchema>
interface AzureEndpointHints {
origin: string
completionsUrl?: string
completionsDeployment?: string
apiVersionFromUrl?: string
}
function resolveProviderBaseUrl(input: string): string {
const trimmed = input.trim()
if (!trimmed) {
return DEFAULT_AZURE_OPENAI_COMPATIBLE_BASE_URL
}
try {
const parsed = new URL(trimmed)
if (DEPLOYMENT_CHAT_COMPLETIONS_PATH_REGEX.test(parsed.pathname) || OPENAI_PATH_REGEX.test(parsed.pathname)) {
return `${parsed.origin}/openai/v1`
}
}
catch {
}
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
function parseAzureEndpointHints(baseUrl: string | undefined): AzureEndpointHints {
const raw = (baseUrl || '').trim()
if (!raw) {
return { origin: FALLBACK_AZURE_ORIGIN }
}
try {
const parsed = new URL(raw)
const apiVersionFromUrl = parsed.searchParams.get('api-version')?.trim()
const deploymentMatch = parsed.pathname.match(DEPLOYMENT_MATCH_REGEX)
if (deploymentMatch?.[1]) {
return {
origin: parsed.origin,
completionsUrl: `${parsed.origin}${parsed.pathname}${parsed.search}`,
completionsDeployment: decodeURIComponent(deploymentMatch[1]),
apiVersionFromUrl,
}
}
return {
origin: parsed.origin,
apiVersionFromUrl,
}
}
catch {
return { origin: FALLBACK_AZURE_ORIGIN }
}
}
function resolveCompletionsApiVersion(config: AzureOpenAIConfig, hints: AzureEndpointHints): string {
return (hints.apiVersionFromUrl || config.completionsApiVersion || DEFAULT_COMPLETIONS_API_VERSION).trim()
}
function resolveConfiguredDeployments(config: AzureOpenAIConfig): string[] {
const endpointHints = parseAzureEndpointHints(config.baseUrl)
return endpointHints.completionsDeployment ? [endpointHints.completionsDeployment] : []
}
function mapChatBodyToCompletions(body: any): Record<string, unknown> {
const mappedBody: Record<string, unknown> = {
...body,
messages: body?.messages,
max_completion_tokens: body?.max_completion_tokens ?? body?.max_output_tokens ?? body?.max_tokens,
}
delete mappedBody.input
delete mappedBody.max_output_tokens
return mappedBody
}
function createAzureOpenAIFetch(config: AzureOpenAIConfig) {
const endpointHints = parseAzureEndpointHints(config.baseUrl)
const completionsApiVersion = resolveCompletionsApiVersion(config, endpointHints)
const apiKey = (config.apiKey || '').trim()
return async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
const isChatCompletionsCall = request.method.toUpperCase() === 'POST' && CHAT_COMPLETIONS_PATH_REGEX.test(url.pathname)
if (!isChatCompletionsCall) {
return fetch(request)
}
const requestBody = await request.clone().json().catch(() => null)
if (!requestBody) {
return fetch(request)
}
const deployment = endpointHints.completionsDeployment || (typeof requestBody?.model === 'string' ? requestBody.model.trim() : '')
if (!deployment) {
return fetch(request)
}
const completionsUrl = endpointHints.completionsUrl
? new URL(endpointHints.completionsUrl)
: new URL(`${endpointHints.origin}/openai/deployments/${encodeURIComponent(deployment)}/chat/completions`)
if (!completionsUrl.searchParams.get('api-version')) {
completionsUrl.searchParams.set('api-version', completionsApiVersion)
}
const headers = new Headers(request.headers)
headers.set('api-key', apiKey)
headers.set('content-type', 'application/json')
const mappedBody = mapChatBodyToCompletions(requestBody)
return fetch(completionsUrl.toString(), {
method: 'POST',
headers,
body: JSON.stringify(mappedBody),
signal: request.signal,
})
}
}
export const providerAzureOpenAI = defineProvider<AzureOpenAIConfig>({
id: 'azure-openai',
order: 2,
name: 'Azure OpenAI',
nameLocalize: ({ t }) => t('settings.pages.providers.provider.azure-openai.title'),
description: 'Azure OpenAI API',
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.azure-openai.description'),
tasks: ['chat'],
icon: 'i-simple-icons:microsoftazure',
extraMethods: {
listModels: async (config, _provider) => {
return resolveConfiguredDeployments(config).map(model => ({
id: model,
name: model,
provider: AZURE_OPENAI_PROVIDER_ID,
description: 'Azure deployment',
}))
},
},
createProviderConfig: ({ t }) => azureOpenAIConfigSchema.extend({
apiKey: azureOpenAIConfigSchema.shape.apiKey.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.label'),
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.description'),
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.api-key.placeholder'),
type: 'password',
}),
baseUrl: azureOpenAIConfigSchema.shape.baseUrl.meta({
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
descriptionLocalized: 'Azure endpoint or full Chat Completions URL. Full URL is recommended.',
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
}),
completionsApiVersion: azureOpenAIConfigSchema.shape.completionsApiVersion.meta({
labelLocalized: 'Completions API Version',
descriptionLocalized: 'Used for Azure Chat Completions API requests.',
placeholderLocalized: '2024-04-01-preview',
section: 'advanced',
}),
}),
createProvider(config) {
const normalizedBaseUrl = resolveProviderBaseUrl(config.baseUrl || DEFAULT_AZURE_BASE_URL)
const provider = createOpenAI(config.apiKey || '', normalizedBaseUrl) as any
const fetch = createAzureOpenAIFetch(config)
return {
...provider,
model: (...args: any[]) => ({
...provider.model(...args),
fetch,
}),
chat: (...args: any[]) => ({
...provider.chat(...args),
fetch,
}),
embed: (...args: any[]) => ({
...provider.embed(...args),
fetch,
}),
image: (...args: any[]) => ({
...provider.image(...args),
fetch,
}),
speech: (...args: any[]) => ({
...provider.speech(...args),
fetch,
}),
transcription: (...args: any[]) => ({
...provider.transcription(...args),
fetch,
}),
}
},
validationRequiredWhen(config) {
return !!config.apiKey?.trim()
},
validators: {
validateConfig: [
({ t }) => ({
id: 'azure-openai:check-config',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-config.title'),
validator: async (config) => {
const errors: Array<{ error: unknown }> = []
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
if (!apiKey)
errors.push({ error: new Error('API key is required.') })
if (!baseUrl) {
errors.push({ error: new Error('Base URL is required.') })
}
else {
try {
const parsed = new URL(baseUrl)
if (!parsed.host)
errors.push({ error: new Error('Base URL is not absolute. Check your input.') })
}
catch {
errors.push({ error: new Error('Base URL is invalid. It must be an absolute URL.') })
}
}
return {
errors,
reason: errors.length > 0 ? errors.map(item => (item.error as Error).message).join(', ') : '',
reasonKey: '',
valid: errors.length === 0,
}
},
}),
],
validateProvider: [
({ t }) => ({
id: 'azure-openai:check-completions-connectivity',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
validator: async (config) => {
const errors: Array<{ error: unknown }> = []
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
const baseUrlRaw = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
const endpointHints = parseAzureEndpointHints(baseUrlRaw)
const completionsApiVersion = resolveCompletionsApiVersion(config as AzureOpenAIConfig, endpointHints)
const deployment = endpointHints.completionsDeployment || ''
if (!apiKey || !baseUrlRaw) {
return {
errors: [{ error: new Error('API key and Base URL are required.') }],
reason: 'API key and Base URL are required.',
reasonKey: '',
valid: false,
}
}
try {
if (!deployment) {
const normalizedBaseUrl = resolveProviderBaseUrl(baseUrlRaw)
const modelsUrl = new URL(`${normalizedBaseUrl.replace(TRAILING_SLASH_REGEX, '')}/models`)
const response = await fetch(modelsUrl.toString(), {
method: 'GET',
headers: {
'api-key': apiKey,
},
})
if (response.status === 401 || response.status === 403) {
const responseText = await response.text()
errors.push({ error: new Error(`Authentication failed (${response.status}). Check API key / endpoint. Response: ${responseText || 'empty'}`) })
}
else if (response.status >= 500) {
const responseText = await response.text()
errors.push({ error: new Error(`Server error (${response.status}). Response: ${responseText || 'empty'}`) })
}
}
else {
const completionsUrl = endpointHints.completionsUrl
? new URL(endpointHints.completionsUrl)
: new URL(`${endpointHints.origin}/openai/deployments/${encodeURIComponent(deployment)}/chat/completions`)
if (!completionsUrl.searchParams.get('api-version')) {
completionsUrl.searchParams.set('api-version', completionsApiVersion)
}
const response = await fetch(completionsUrl.toString(), {
method: 'POST',
headers: {
'api-key': apiKey,
'content-type': 'application/json',
},
body: JSON.stringify({
model: deployment,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 1,
}),
})
if (response.status >= 400) {
const responseText = await response.text()
if (response.status === 400) {
return {
errors,
reason: '',
reasonKey: '',
valid: true,
}
}
if (response.status === 401 || response.status === 403) {
errors.push({ error: new Error(`Authentication failed (${response.status}). Check API key / endpoint. Response: ${responseText || 'empty'}`) })
}
else if (response.status === 404) {
errors.push({ error: new Error(`Deployment or endpoint not found (${response.status}). Check Base URL and API version. Response: ${responseText || 'empty'}`) })
}
else if (response.status >= 500) {
errors.push({ error: new Error(`Server error (${response.status}). Response: ${responseText || 'empty'}`) })
}
else {
errors.push({ error: new Error(`Completions connectivity check returned ${response.status}. Response: ${responseText || 'empty'}`) })
}
}
}
}
catch (error) {
errors.push({ error: new Error(`Connectivity check failed: ${errorMessageFrom(error) || 'Unknown error.'}`) })
}
return {
errors,
reason: errors.length > 0 ? errors.map(item => (item.error as Error).message).join(', ') : '',
reasonKey: '',
valid: errors.length === 0,
}
},
}),
],
},
})
@@ -1,6 +1,7 @@
import './openai'
import './aihubmix'
import './lm-studio'
import './azure-openai'
import './openai-compatible'
import './n1n'
import './openrouter-ai'
+2 -2
View File
@@ -4,8 +4,8 @@ import { computed, ref, watch } from 'vue'
import { useProvidersStore } from './providers'
const essentialProviderIds = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'openai-compatible'] as const
const credentialBasedEssentialProviderIds = ['openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'deepseek'] as const
const essentialProviderIds = ['openai', 'azure-openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'ollama', 'deepseek', 'openai-compatible'] as const
const credentialBasedEssentialProviderIds = ['openai', 'azure-openai', 'anthropic', 'google-generative-ai', 'openrouter-ai', 'deepseek'] as const
function hasNonEmptyText(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0