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:
Ver_zhzh
2026-03-09 20:09:36 +08:00
committed by GitHub
co-authored by gemini-code-assist[bot] autofix-ci[bot] Garfield Lee
parent a16606f2de
commit 861beacad2
16 changed files with 225 additions and 65 deletions
@@ -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))
@@ -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 -->
@@ -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)