feat(stage-ui): add nvidia nim provider (#1196)
* feat: add nvidia nim provider * fix: restore onboarding no-models help text * fix: limit nvidia provider to desktop runtime * [autofix.ci] apply automated fixes * fix: update validators check list Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Garfield Lee <Garfield550@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
gemini-code-assist[bot]
autofix-ci[bot]
Garfield Lee
parent
a16606f2de
commit
861beacad2
@@ -743,6 +743,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: OpenAI Compatible
|
||||
title: OpenAI Compatible
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - High-performance AI API provider.
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: OpenAI Compatible
|
||||
title: OpenAI Compatible
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - High-performance AI API provider.
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: Compatible avec OpenAI
|
||||
title: Compatible avec OpenAI
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - Fournisseur d'API d'IA haute performance.
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: OpenAI互換
|
||||
title: OpenAI互換
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - 高性能なAI APIプロバイダー。
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: OpenAI 호환
|
||||
title: OpenAI 호환
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - 고성능 AI API 제공자.
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: Совместимый с OpenAI
|
||||
title: OpenAI Compatible
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - High-performance AI API provider.
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: Tương thích OpenAI
|
||||
title: OpenAI Compatible
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - High-performance AI API provider.
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: OpenAI 兼容 API
|
||||
title: OpenAI 兼容 API
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - 高性能的 AI API 提供商
|
||||
title: n1n
|
||||
|
||||
@@ -711,6 +711,9 @@ pages:
|
||||
openai-compatible:
|
||||
description: OpenAI 相容
|
||||
title: OpenAI Compatible
|
||||
nvidia:
|
||||
description: build.nvidia.com
|
||||
title: NVIDIA NIM
|
||||
n1n:
|
||||
description: n1n.ai - High-performance AI API provider.
|
||||
title: n1n
|
||||
|
||||
@@ -30,7 +30,7 @@ const {
|
||||
|
||||
// Popular providers for first-time setup
|
||||
const popularProviders = computed(() => {
|
||||
const popular = ['openai', 'anthropic', 'google-generative-ai', 'groq', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible']
|
||||
const popular = ['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))
|
||||
|
||||
+15
-13
@@ -35,9 +35,22 @@ const {
|
||||
</div>
|
||||
|
||||
<!-- Using the new RadioCardManySelect component -->
|
||||
<div flex-1>
|
||||
<div flex flex-1 flex-col gap-4>
|
||||
<Alert
|
||||
v-if="providerModels.length === 0 && !isLoadingActiveProviderModels"
|
||||
type="error"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('settings.dialogs.onboarding.no-models') }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="whitespace-pre-wrap break-all">
|
||||
{{ t('settings.dialogs.onboarding.no-models-help') }}
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<RadioCardManySelect
|
||||
v-if="providerModels.length > 0"
|
||||
v-model="activeModel"
|
||||
v-model:search-query="modelSearchQuery"
|
||||
:items="providerModels.toSorted((a, b) => a.id === activeModel ? -1 : b.id === activeModel ? 1 : 0)"
|
||||
@@ -52,17 +65,6 @@ 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-else type="error">
|
||||
<template #title>
|
||||
{{ t('settings.dialogs.onboarding.no-models') }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="whitespace-pre-wrap break-all">
|
||||
{{ t('settings.dialogs.onboarding.no-models-help') }}
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
|
||||
+1
@@ -146,6 +146,7 @@ function getApiKeyPlaceholder(providerId: string): string {
|
||||
'modelscope': 'ms-...',
|
||||
'fireworks-ai': 'fw-...',
|
||||
'featherless-ai': 'fw-...',
|
||||
'nvidia': 'nvapi-...',
|
||||
'novita-ai': 'nvt-...',
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import './lm-studio'
|
||||
import './openai-compatible'
|
||||
import './n1n'
|
||||
import './openrouter-ai'
|
||||
import './nvidia'
|
||||
import './groq'
|
||||
import './anthropic'
|
||||
import './google-generative-ai'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { createOpenAICompatibleValidators } from '../../validators/openai-compatible'
|
||||
import { defineProvider } from '../registry'
|
||||
|
||||
const nvidiaConfigSchema = z.object({
|
||||
apiKey: z
|
||||
.string('API Key'),
|
||||
baseUrl: z
|
||||
.string('Base URL')
|
||||
.optional()
|
||||
.default('https://integrate.api.nvidia.com/v1/'),
|
||||
})
|
||||
|
||||
type NvidiaConfig = z.input<typeof nvidiaConfigSchema>
|
||||
|
||||
export const providerNvidia = defineProvider<NvidiaConfig>({
|
||||
id: 'nvidia',
|
||||
name: 'NVIDIA NIM',
|
||||
nameLocalize: ({ t }) => t('settings.pages.providers.provider.nvidia.title'),
|
||||
description: 'build.nvidia.com',
|
||||
descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.nvidia.description'),
|
||||
tasks: ['chat'],
|
||||
icon: 'i-simple-icons:nvidia',
|
||||
isAvailableBy: isStageTamagotchi,
|
||||
|
||||
createProviderConfig: ({ t }) => nvidiaConfigSchema.extend({
|
||||
apiKey: nvidiaConfigSchema.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: nvidiaConfigSchema.shape.baseUrl.meta({
|
||||
labelLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.label'),
|
||||
descriptionLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.description'),
|
||||
placeholderLocalized: t('settings.pages.providers.catalog.edit.config.common.fields.field.base-url.placeholder'),
|
||||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createOpenAI(config.apiKey, config.baseUrl)
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
return !!config.apiKey?.trim()
|
||||
},
|
||||
validators: {
|
||||
...createOpenAICompatibleValidators({
|
||||
checks: ['connectivity', 'model_list'],
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createOpenAICompatibleValidators } from './openai-compatible'
|
||||
|
||||
const {
|
||||
generateTextMock,
|
||||
listModelsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
generateTextMock: vi.fn(),
|
||||
listModelsMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/generate-text', () => ({
|
||||
generateText: generateTextMock,
|
||||
}))
|
||||
|
||||
vi.mock('@xsai/model', () => ({
|
||||
listModels: listModelsMock,
|
||||
}))
|
||||
|
||||
function getProviderValidators(options?: Parameters<typeof createOpenAICompatibleValidators>[0]) {
|
||||
const validators = createOpenAICompatibleValidators(options)
|
||||
|
||||
return (validators?.validateProvider || []).map(create => create({
|
||||
t: (input: string) => input,
|
||||
} as any))
|
||||
}
|
||||
|
||||
describe('createOpenAICompatibleValidators', () => {
|
||||
const config = {
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://example.com/v1/',
|
||||
}
|
||||
const provider = {
|
||||
model: () => ({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
}),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not probe chat completions with a synthetic fallback model', async () => {
|
||||
listModelsMock.mockResolvedValue([])
|
||||
|
||||
const [connectivityValidator, chatValidator] = getProviderValidators({
|
||||
checks: ['connectivity', 'chat_completions'],
|
||||
})
|
||||
|
||||
const connectivityResult = await connectivityValidator.validator(config, provider as any, undefined as any, undefined as any)
|
||||
const chatResult = await chatValidator.validator(config, provider as any, undefined as any, undefined as any)
|
||||
|
||||
expect(connectivityResult.valid).toBe(false)
|
||||
expect(connectivityResult.reason).toContain('No model available for validation.')
|
||||
expect(chatResult.valid).toBe(false)
|
||||
expect(chatResult.reason).toContain('No model available for validation.')
|
||||
expect(generateTextMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows providers to skip chat probing when they do not expose model listing', async () => {
|
||||
listModelsMock.mockResolvedValue([])
|
||||
|
||||
const [connectivityValidator, chatValidator] = getProviderValidators({
|
||||
checks: ['connectivity', 'chat_completions'],
|
||||
allowValidationWithoutModel: true,
|
||||
})
|
||||
|
||||
const connectivityResult = await connectivityValidator.validator(config, provider as any, undefined as any, undefined as any)
|
||||
const chatResult = await chatValidator.validator(config, provider as any, undefined as any, undefined as any)
|
||||
|
||||
expect(connectivityResult.valid).toBe(true)
|
||||
expect(chatResult.valid).toBe(true)
|
||||
expect(generateTextMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ type OpenAICompatibleValidationCheck = 'connectivity' | 'model_list' | 'chat_com
|
||||
interface OpenAICompatibleValidationOptions<TConfig extends { apiKey?: string, baseUrl?: string }> {
|
||||
checks?: OpenAICompatibleValidationCheck[]
|
||||
additionalHeaders?: Record<string, string>
|
||||
allowValidationWithoutModel?: boolean
|
||||
schedule?: {
|
||||
mode: 'once' | 'interval'
|
||||
intervalMs?: number
|
||||
@@ -88,16 +89,14 @@ async function pickValidationModel<TConfig extends { apiKey?: string | null, bas
|
||||
config: TConfig,
|
||||
provider: ProviderInstance,
|
||||
providerExtra: ProviderExtraMethods<TConfig> | undefined,
|
||||
): Promise<string> {
|
||||
const fallback = 'test'
|
||||
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const models = await resolveModels(config, provider, providerExtra)
|
||||
const modelId = extractModelId(models.find(model => !shouldSkipModelId(extractModelId(model))))
|
||||
return modelId || fallback
|
||||
return modelId || null
|
||||
}
|
||||
catch {
|
||||
return fallback
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +105,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
): ProviderDefinition<TConfig>['validators'] {
|
||||
const checks = options?.checks ?? ['connectivity', 'model_list', 'chat_completions']
|
||||
const additionalHeaders = options?.additionalHeaders
|
||||
const missingValidationModelReason = 'No model available for validation. Configure a model manually and try again.'
|
||||
|
||||
interface ChatCheckResult {
|
||||
connectivityOk: boolean
|
||||
@@ -114,6 +114,48 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
async function runChatCheck(
|
||||
config: TConfig,
|
||||
provider: ProviderInstance,
|
||||
providerExtra: ProviderExtraMethods<TConfig> | undefined,
|
||||
): Promise<ChatCheckResult> {
|
||||
const model = await pickValidationModel(config, provider, providerExtra)
|
||||
|
||||
if (!model) {
|
||||
if (options?.allowValidationWithoutModel) {
|
||||
return { connectivityOk: true, chatOk: true }
|
||||
}
|
||||
|
||||
return {
|
||||
connectivityOk: false,
|
||||
chatOk: false,
|
||||
errorMessage: missingValidationModelReason,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await generateText({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl!,
|
||||
headers: additionalHeaders,
|
||||
model,
|
||||
messages: message.messages(message.user('ping')),
|
||||
max_tokens: 1,
|
||||
})
|
||||
|
||||
return { connectivityOk: true, chatOk: true }
|
||||
}
|
||||
catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
return { connectivityOk: false, chatOk: false, error: e, errorMessage: errorMessageFrom(e) }
|
||||
}
|
||||
|
||||
const status = extractStatusCode(e)
|
||||
const chatOk = status === 400 || Boolean(status && status >= 200 && status < 300)
|
||||
return { connectivityOk: true, chatOk, errorMessage: errorMessageFrom(e) }
|
||||
}
|
||||
}
|
||||
|
||||
const chatCheckCacheKey = 'openai-compatible:chat-check'
|
||||
const chatCheckMutexKey = 'openai-compatible:chat-check:mutex'
|
||||
const getChatCheckResult = async (
|
||||
@@ -128,28 +170,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
return existing
|
||||
|
||||
if (!cache) {
|
||||
const model = await pickValidationModel(config, provider, providerExtra)
|
||||
try {
|
||||
await generateText({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl!,
|
||||
headers: additionalHeaders,
|
||||
model,
|
||||
messages: message.messages(message.user('ping')),
|
||||
max_tokens: 1,
|
||||
})
|
||||
|
||||
return { connectivityOk: true, chatOk: true }
|
||||
}
|
||||
catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
return { connectivityOk: false, chatOk: false, error: e, errorMessage: errorMessageFrom(e) }
|
||||
}
|
||||
|
||||
const status = extractStatusCode(e)
|
||||
const chatOk = status === 400 || Boolean(status && status >= 200 && status < 300)
|
||||
return { connectivityOk: true, chatOk, errorMessage: errorMessageFrom(e) }
|
||||
}
|
||||
return runChatCheck(config, provider, providerExtra)
|
||||
}
|
||||
|
||||
let mutex = cache.get(chatCheckMutexKey) as Mutex | undefined
|
||||
@@ -165,30 +186,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const sharedCheck = (async () => {
|
||||
const model = await pickValidationModel(config, provider, providerExtra)
|
||||
try {
|
||||
await generateText({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl!,
|
||||
headers: additionalHeaders,
|
||||
model,
|
||||
messages: message.messages(message.user('ping')),
|
||||
max_tokens: 1,
|
||||
})
|
||||
|
||||
return { connectivityOk: true, chatOk: true }
|
||||
}
|
||||
catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
return { connectivityOk: false, chatOk: false, error: e, errorMessage: errorMessageFrom(e) }
|
||||
}
|
||||
|
||||
const status = extractStatusCode(e)
|
||||
const chatOk = status === 400 || Boolean(status && status >= 200 && status < 300)
|
||||
return { connectivityOk: true, chatOk, errorMessage: errorMessageFrom(e) }
|
||||
}
|
||||
})()
|
||||
const sharedCheck = runChatCheck(config, provider, providerExtra)
|
||||
|
||||
cache.set(chatCheckCacheKey, sharedCheck)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user