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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -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 -->
|
||||
|
||||
+3
-2
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user