fix(stage-ui): many provider catalog issues
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { convertProviderDefinitionToMetadata } from './converters'
|
||||
|
||||
vi.mock('@xsai/model', () => ({
|
||||
listModels: vi.fn(async () => [
|
||||
{ id: 'test-model', name: 'Test Model', context_length: 8192 },
|
||||
]),
|
||||
}))
|
||||
|
||||
describe('providers converters', () => {
|
||||
it('keeps schema defaults when required fields are missing', () => {
|
||||
const definition = {
|
||||
id: 'test-provider',
|
||||
tasks: ['chat'],
|
||||
name: 'Test Provider',
|
||||
nameLocalize: ({ t }: { t: (input: string) => string }) => t('name.key'),
|
||||
description: 'test',
|
||||
descriptionLocalize: ({ t }: { t: (input: string) => string }) => t('description.key'),
|
||||
createProviderConfig: () => z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().optional().default('https://example.com/v1/'),
|
||||
}),
|
||||
createProvider: () => ({}) as any,
|
||||
} as any
|
||||
|
||||
const metadata = convertProviderDefinitionToMetadata(definition, ((key: string) => key) as any)
|
||||
|
||||
expect(metadata.defaultOptions?.()).toMatchObject({
|
||||
baseUrl: 'https://example.com/v1/',
|
||||
})
|
||||
})
|
||||
|
||||
it('provides generic model listing fallback for model providers', async () => {
|
||||
const definition = {
|
||||
id: 'test-provider',
|
||||
tasks: ['chat'],
|
||||
name: 'Test Provider',
|
||||
nameLocalize: ({ t }: { t: (input: string) => string }) => t('name.key'),
|
||||
description: 'test',
|
||||
descriptionLocalize: ({ t }: { t: (input: string) => string }) => t('description.key'),
|
||||
createProviderConfig: () => z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().optional().default('https://example.com/v1/'),
|
||||
}),
|
||||
createProvider: () => ({
|
||||
model: () => ({ baseURL: 'https://example.com/v1/', apiKey: 'k' }),
|
||||
}),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
() => ({
|
||||
id: 'openai-compatible:check-config',
|
||||
name: 'config',
|
||||
validator: async () => ({ errors: [], reason: '', reasonKey: '', valid: true }),
|
||||
}),
|
||||
],
|
||||
},
|
||||
validationRequiredWhen: () => true,
|
||||
} as any
|
||||
|
||||
const metadata = convertProviderDefinitionToMetadata(definition, ((key: string) => key) as any)
|
||||
const models = await metadata.capabilities.listModels?.({ apiKey: 'k', baseUrl: 'https://example.com/v1/' })
|
||||
|
||||
expect(models).toMatchObject([
|
||||
{
|
||||
id: 'test-model',
|
||||
name: 'Test Model',
|
||||
provider: 'test-provider',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('adds default base url hint to validation reason when base url is missing', async () => {
|
||||
const definition = {
|
||||
id: 'test-provider',
|
||||
tasks: ['chat'],
|
||||
name: 'Test Provider',
|
||||
nameLocalize: ({ t }: { t: (input: string) => string }) => t('name.key'),
|
||||
description: 'test',
|
||||
descriptionLocalize: ({ t }: { t: (input: string) => string }) => t('description.key'),
|
||||
createProviderConfig: () => z.object({
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string().optional().default('https://example.com/v1/'),
|
||||
}),
|
||||
createProvider: () => ({
|
||||
model: () => ({ baseURL: 'https://example.com/v1/', apiKey: 'k' }),
|
||||
}),
|
||||
validators: {
|
||||
validateConfig: [
|
||||
() => ({
|
||||
id: 'openai-compatible:check-config',
|
||||
name: 'config',
|
||||
validator: async () => ({ errors: [{ error: new Error('Base URL is required.') }], reason: 'Base URL is required.', reasonKey: '', valid: false }),
|
||||
}),
|
||||
],
|
||||
},
|
||||
validationRequiredWhen: () => true,
|
||||
} as any
|
||||
|
||||
const metadata = convertProviderDefinitionToMetadata(definition, ((key: string) => key) as any)
|
||||
const result = await metadata.validators.validateProviderConfig({ apiKey: 'k' })
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.reason).toContain('Base URL is required.')
|
||||
expect(result.reason).toContain('Default to https://example.com/v1/.')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,9 @@ import type { ProviderDefinition } from '../../libs/providers/types'
|
||||
import type { ProviderValidationPlan } from '../../libs/providers/validators/run'
|
||||
import type { ProviderMetadata } from '../providers'
|
||||
|
||||
import { listModels } from '@xsai/model'
|
||||
|
||||
import { isModelProvider } from '../../libs/providers/types'
|
||||
import { getValidatorsOfProvider, validateProvider } from '../../libs/providers/validators/run'
|
||||
|
||||
function getCategoryFromTasks(tasks: string[]): ProviderMetadata['category'] {
|
||||
@@ -21,16 +24,32 @@ function getCategoryFromTasks(tasks: string[]): ProviderMetadata['category'] {
|
||||
}
|
||||
|
||||
function extractSchemaDefaults(definition: ProviderDefinition<any>, t: ComposerTranslation) {
|
||||
const defaults: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
const schema = definition.createProviderConfig({ t })
|
||||
const parsed = (schema as any).safeParse?.({})
|
||||
const schema = definition.createProviderConfig({ t }) as any
|
||||
const shape = schema?.shape
|
||||
|
||||
// Zod object-level parsing fails when required fields (for example apiKey) are missing.
|
||||
// Extract each field default individually to preserve default base URLs.
|
||||
if (shape && typeof shape === 'object') {
|
||||
for (const [key, fieldSchema] of Object.entries(shape)) {
|
||||
const parsedField = (fieldSchema as any)?.safeParse?.(undefined)
|
||||
if (parsedField?.success) {
|
||||
defaults[key] = parsedField.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = schema?.safeParse?.({})
|
||||
if (parsed?.success && typeof parsed.data === 'object' && parsed.data !== null) {
|
||||
return parsed.data as Record<string, unknown>
|
||||
Object.assign(defaults, parsed.data as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
return {}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
function buildConfigValidationResult(plan: ProviderValidationPlan) {
|
||||
@@ -51,6 +70,26 @@ function buildConfigValidationResult(plan: ProviderValidationPlan) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapModelsToMetadataModels(providerId: string, models: any[]) {
|
||||
return models.map((model: any) => {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name || model.display_name || model.id,
|
||||
provider: providerId,
|
||||
description: model.description || '',
|
||||
contextLength: model.context_length || 0,
|
||||
deprecated: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function appendUniqueReason(reasons: string[], next: string) {
|
||||
if (!next)
|
||||
return
|
||||
if (!reasons.includes(next))
|
||||
reasons.push(next)
|
||||
}
|
||||
|
||||
export function convertProviderDefinitionToMetadata(
|
||||
definition: ProviderDefinition<any>,
|
||||
t: ComposerTranslation,
|
||||
@@ -88,13 +127,39 @@ export function convertProviderDefinitionToMetadata(
|
||||
? async (config) => {
|
||||
const provider = await definition.createProvider(config as any)
|
||||
try {
|
||||
return await definition.extraMethods!.listModels!(config as any, provider)
|
||||
const models = await definition.extraMethods!.listModels!(config as any, provider)
|
||||
return mapModelsToMetadataModels(definition.id, models as any[])
|
||||
}
|
||||
finally {
|
||||
await (provider as { dispose?: () => Promise<void> | void }).dispose?.()
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
: async (config) => {
|
||||
const provider = await definition.createProvider(config as any)
|
||||
try {
|
||||
if (isModelProvider(provider)) {
|
||||
const models = await listModels(provider.model())
|
||||
return mapModelsToMetadataModels(definition.id, models as any[])
|
||||
}
|
||||
|
||||
const baseUrl = typeof (config as any).baseUrl === 'string' ? (config as any).baseUrl.trim() : ''
|
||||
const apiKey = typeof (config as any).apiKey === 'string' ? (config as any).apiKey.trim() : ''
|
||||
if (!baseUrl)
|
||||
return []
|
||||
|
||||
const models = await listModels({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
})
|
||||
return mapModelsToMetadataModels(definition.id, models as any[])
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
finally {
|
||||
await (provider as { dispose?: () => Promise<void> | void }).dispose?.()
|
||||
}
|
||||
},
|
||||
listVoices: definition.extraMethods?.listVoices
|
||||
? async (config) => {
|
||||
const provider = await definition.createProvider(config as any)
|
||||
@@ -140,9 +205,24 @@ export function convertProviderDefinitionToMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
const reasons = invalidSteps.map(step => step.reason).filter(Boolean)
|
||||
const hasMissingBaseUrlError = reasons.some(reason => reason.includes('Base URL is required'))
|
||||
const defaultBaseUrl = typeof schemaDefaults.baseUrl === 'string' ? schemaDefaults.baseUrl.trim() : ''
|
||||
if (hasMissingBaseUrlError && defaultBaseUrl) {
|
||||
appendUniqueReason(reasons, `Default to ${defaultBaseUrl}.`)
|
||||
}
|
||||
|
||||
const connectivityFailed = invalidSteps.some(step => step.id === 'openai-compatible:check-connectivity')
|
||||
if (connectivityFailed) {
|
||||
const troubleshooting = definition.business?.({ t })?.troubleshooting?.validators?.openaiCompatibleCheckConnectivity?.content || ''
|
||||
if (troubleshooting) {
|
||||
appendUniqueReason(reasons, troubleshooting)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors: invalidSteps.map(step => new Error(step.reason || `${step.id} is invalid`)),
|
||||
reason: invalidSteps.map(step => step.reason).filter(Boolean).join('; '),
|
||||
reason: reasons.join('; '),
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user