refactor: update schemas and models for consistency and clarity

- Changed `perMillionTokens` to `per_million_tokens` in PricePerTokenSchema for snake_case consistency.
- Removed unused types from index.ts and simplified ProviderModelOverrideSchema by removing deprecated fields.
- Enhanced ModelConfigSchema to enforce unique capabilities and modalities, and made context_window and max_output_tokens optional.
- Updated ProviderConfigSchema to require at least one supported endpoint.
- Removed commented-out code and unused imports in route.ts for cleaner code.
- Added a cleanup script to remove deprecated fields from overrides.json.
- Implemented a new importer for AIHubMix models, transforming API data into the internal format.
- Created a utility for applying and validating model overrides, ensuring better error handling and warnings.
- Updated various scripts for better organization and clarity, including removing search models and generating AIHubMix models.
This commit is contained in:
suyao 2025-12-08 22:47:16 +08:00
parent 67f726afb7
commit 2593a427e0
No known key found for this signature in database
26 changed files with 17003 additions and 11390 deletions

View File

@ -93,7 +93,19 @@
},
"responses": {
"200": {
"description": "Models updated successfully"
"description": "Models updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}
@ -148,7 +160,22 @@
},
"responses": {
"200": {
"description": "Model updated successfully"
"description": "Model updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"model": {
"$ref": "#/components/schemas/Model"
}
}
}
}
}
}
}
}
@ -221,7 +248,19 @@
},
"responses": {
"200": {
"description": "Providers updated successfully"
"description": "Providers updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
}
}
}
}
}
}
}
@ -250,31 +289,44 @@
}
}
}
}
}
},
"/catalog/models/{modelId}/overrides": {
"get": {
"summary": "Get provider-specific overrides for a model",
"parameters": [
{
"name": "modelId",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"put": {
"summary": "Update specific provider",
"parameters": [
{
"name": "providerId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
}
],
"responses": {
"200": {
"description": "Provider overrides for the model",
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Model"
"$ref": "#/components/schemas/Provider"
}
}
}
},
"responses": {
"200": {
"description": "Provider updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"provider": {
"$ref": "#/components/schemas/Provider"
}
}
}
}
}
@ -283,7 +335,7 @@
}
}
},
"/catalog/models/{modelId}/providers/{providerId}": {
"/catalog/models/{modelId}/providers/{providerId}": {
"get": {
"summary": "Get model configuration as seen by specific provider",
"parameters": [
@ -357,7 +409,7 @@
"properties": {
"updated": {
"type": "string",
"enum": ["base_model", "override", "both"]
"enum": ["base_model", "override", "override_updated", "override_removed"]
},
"model": {
"$ref": "#/components/schemas/Model"
@ -387,24 +439,6 @@
}
}
},
"/catalog/validate": {
"post": {
"summary": "Validate catalog configuration",
"responses": {
"200": {
"description": "Validation results",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationResult"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Model": {
@ -558,7 +592,9 @@
"page": { "type": "integer" },
"limit": { "type": "integer" },
"total": { "type": "integer" },
"totalPages": { "type": "integer" }
"totalPages": { "type": "integer" },
"hasNext": { "type": "boolean" },
"hasPrev": { "type": "boolean" }
}
},
"ModelsConfig": {
@ -603,22 +639,11 @@
"total_models": { "type": "integer" },
"total_providers": { "type": "integer" },
"total_overrides": { "type": "integer" },
"models_by_provider": { "type": "object" },
"overrides_by_provider": { "type": "object" },
"last_updated": { "type": "string" }
}
},
"ValidationResult": {
"type": "object",
"properties": {
"valid": { "type": "boolean" },
"errors": {
"type": "array",
"items": { "type": "string" }
},
"warnings": {
"type": "array",
"items": { "type": "string" }
"last_updated": { "type": "string" },
"version": { "type": "string" },
"migration_status": {
"type": "string",
"enum": ["completed", "in_progress", "failed"]
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,8 @@
"dev": "tsc -w",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"import:aihubmix": "tsx scripts/import-aihubmix.ts"
},
"author": "Cherry Studio",
"license": "MIT",

View File

@ -0,0 +1,76 @@
#!/usr/bin/env tsx
/**
* Cleanup script for data/overrides.json
* Removes deprecated tracking fields: last_updated, updated_by
* These fields will be removed from override schema as git provides better tracking
*/
import * as fs from 'fs/promises'
import * as path from 'path'
interface Override {
provider_id: string
model_id: string
disabled?: boolean
reason?: string
priority?: number
last_updated?: string // To be removed
updated_by?: string // To be removed
limits?: unknown
pricing?: unknown
capabilities?: unknown
reasoning?: unknown
parameters?: unknown
replace_with?: string
[key: string]: unknown
}
interface OverrideFile {
version: string
overrides: Override[]
}
async function cleanupOverrides(): Promise<void> {
const overridesPath = path.join(process.cwd(), 'data', 'overrides.json')
console.log('Reading overrides file...')
const content = await fs.readFile(overridesPath, 'utf-8')
const data: OverrideFile = JSON.parse(content)
console.log(`Found ${data.overrides.length} override entries`)
let removedCount = 0
const cleanedOverrides = data.overrides.map((override) => {
const cleaned: Override = { ...override }
// Remove deprecated tracking fields
if ('last_updated' in cleaned) {
delete cleaned.last_updated
removedCount++
}
if ('updated_by' in cleaned) {
delete cleaned.updated_by
}
return cleaned
})
const cleanedData: OverrideFile = {
version: data.version,
overrides: cleanedOverrides
}
// Write cleaned data back
console.log(`Removing last_updated and updated_by from ${removedCount} entries...`)
await fs.writeFile(overridesPath, JSON.stringify(cleanedData, null, 2) + '\n', 'utf-8')
console.log('✓ Cleanup completed successfully')
console.log(`✓ Saved ${cleanedOverrides.length} cleaned override entries`)
}
// Run the cleanup
cleanupOverrides().catch((error) => {
console.error('Cleanup failed:', error)
process.exit(1)
})

View File

@ -0,0 +1,273 @@
#!/usr/bin/env tsx
import * as fs from 'fs'
import * as path from 'path'
// Types based on AIHubMix API structure
interface AiHubMixModel {
model_id: string
desc: string
pricing: {
cache_read?: number
cache_write?: number
input: number
output: number
}
types: string
features: string
input_modalities: string
max_output: number
context_length: number
}
interface AiHubMixResponse {
data: AiHubMixModel[]
}
// Transformer function (simplified version of the transformer class)
function transformModel(apiModel: AiHubMixModel) {
const capabilities = mapCapabilities(apiModel.types, apiModel.features)
const inputModalities = mapModalities(apiModel.input_modalities)
const outputModalities = inferOutputModalities(apiModel.types)
const tags = extractTags(apiModel)
const category = inferCategory(apiModel.types)
const transformed: any = {
id: apiModel.model_id,
description: apiModel.desc || undefined,
capabilities: capabilities.length > 0 ? capabilities : undefined,
input_modalities: inputModalities.length > 0 ? inputModalities : undefined,
output_modalities: outputModalities.length > 0 ? outputModalities : undefined,
context_window: apiModel.context_length || undefined,
max_output_tokens: apiModel.max_output || undefined,
pricing: {
input: {
per_million_tokens: apiModel.pricing.input,
currency: 'USD'
},
output: {
per_million_tokens: apiModel.pricing.output,
currency: 'USD'
}
},
metadata: {
source: 'aihubmix',
tags: tags.length > 0 ? tags : undefined,
category: category || undefined,
original_types: apiModel.types || undefined,
original_features: apiModel.features || undefined
}
}
// Add optional pricing fields only if they exist
if (apiModel.pricing.cache_read !== undefined) {
transformed.pricing.cache_read = {
per_million_tokens: apiModel.pricing.cache_read,
currency: 'USD'
}
}
if (apiModel.pricing.cache_write !== undefined) {
transformed.pricing.cache_write = {
per_million_tokens: apiModel.pricing.cache_write,
currency: 'USD'
}
}
// Remove undefined description
if (!apiModel.desc) {
delete transformed.description
}
return transformed
}
function mapCapabilities(types: string, features: string): string[] {
const caps = new Set<string>()
if (features) {
const featureList = features
.split(',')
.map((f) => f.trim().toLowerCase())
.filter(Boolean)
featureList.forEach((feature) => {
switch (feature) {
case 'thinking':
caps.add('REASONING')
break
case 'function_calling':
case 'tools':
caps.add('FUNCTION_CALL')
break
case 'structured_outputs':
caps.add('STRUCTURED_OUTPUT')
break
case 'web':
case 'deepsearch':
caps.add('WEB_SEARCH')
break
}
})
}
if (types) {
const typeList = types
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
typeList.forEach((type) => {
switch (type) {
case 'image_generation':
caps.add('IMAGE_GENERATION')
break
case 'video':
caps.add('VIDEO_GENERATION')
break
}
})
}
return Array.from(caps)
}
function mapModalities(modalitiesCSV: string): string[] {
if (!modalitiesCSV) {
return []
}
const modalities = new Set<string>()
const modalityList = modalitiesCSV
.split(',')
.map((m) => m.trim().toUpperCase())
.filter(Boolean)
modalityList.forEach((m) => {
switch (m) {
case 'TEXT':
modalities.add('TEXT')
break
case 'IMAGE':
modalities.add('VISION')
break
case 'AUDIO':
modalities.add('AUDIO')
break
case 'VIDEO':
modalities.add('VIDEO')
break
}
})
return Array.from(modalities)
}
function inferOutputModalities(types: string): string[] {
if (!types) {
return []
}
const typeList = types
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
if (typeList.includes('image_generation')) {
return ['VISION']
}
if (typeList.includes('video')) {
return ['VIDEO']
}
return []
}
function extractTags(apiModel: AiHubMixModel): string[] {
const tags: string[] = []
if (apiModel.types) {
const types = apiModel.types.split(',').map((t) => t.trim()).filter(Boolean)
tags.push(...types)
}
if (apiModel.features) {
const features = apiModel.features.split(',').map((f) => f.trim()).filter(Boolean)
tags.push(...features)
}
return Array.from(new Set(tags))
}
function inferCategory(types: string): string {
if (!types) {
return ''
}
const typeList = types
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
if (typeList.includes('image_generation')) {
return 'image-generation'
}
if (typeList.includes('video')) {
return 'video-generation'
}
return ''
}
// Main function
async function generateAiHubMixModels() {
console.log('Fetching models from AIHubMix API...')
const apiUrl = 'https://aihubmix.com/api/v1/models'
try {
const response = await fetch(apiUrl)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
const json: AiHubMixResponse = await response.json()
console.log(`✓ Fetched ${json.data.length} models from AIHubMix`)
// Transform to internal format
console.log('Transforming models...')
const models = json.data.map((m) => transformModel(m))
console.log(`✓ Transformed ${models.length} models`)
// Prepare output
const output = {
version: new Date().toISOString().split('T')[0].replace(/-/g, '.'),
models
}
// Write to aihubmix_models.json
const outputPath = path.join(__dirname, '../data/aihubmix_models.json')
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2) + '\n', 'utf-8')
console.log(`✓ Saved ${models.length} models to ${outputPath}`)
// Also update the main models.json by replacing the models array
const mainModelsPath = path.join(__dirname, '../data/models.json')
const mainModelsData = JSON.parse(fs.readFileSync(mainModelsPath, 'utf-8'))
mainModelsData.models = output.models
fs.writeFileSync(mainModelsPath, JSON.stringify(mainModelsData, null, 2) + '\n', 'utf-8')
console.log(`✓ Updated main models.json with ${models.length} models`)
} catch (error) {
console.error('✗ Failed to generate AIHubMix models:', error)
process.exit(1)
}
}
// Run the script
generateAiHubMixModels().catch(console.error)

View File

@ -0,0 +1,24 @@
#!/usr/bin/env tsx
/**
* One-time import script for AIHubMix model catalog
* Usage: yarn import:aihubmix
* Output: data/aihubmix-models.json
*/
import { AiHubMixImporter } from '../src/utils/importers'
async function main() {
console.log('AIHubMix Model Importer')
console.log('=======================\n')
try {
await AiHubMixImporter.run()
process.exit(0)
} catch (error) {
console.error('Import failed:', error)
process.exit(1)
}
}
main()

View File

@ -0,0 +1,38 @@
#!/usr/bin/env tsx
import fs from 'fs'
import path from 'path'
// Read the models.json file
const modelsPath = path.join(__dirname, '../data/models.json')
const catalogData = JSON.parse(fs.readFileSync(modelsPath, 'utf8'))
console.log('Total models before filtering:', catalogData.models?.length || 0)
// Check if models array exists
if (!catalogData.models || !Array.isArray(catalogData.models)) {
console.error('❌ No models array found in the file')
process.exit(1)
}
// Filter out models ending with 'search'
const filteredModels = catalogData.models.filter((model: any) => {
if (model.id && model.id.endsWith('search')) {
console.log('Removing model:', model.id)
return false
}
return true
})
console.log('Total models after filtering:', filteredModels.length)
// Update the data with filtered models
const updatedData = {
...catalogData,
models: filteredModels
}
// Write the filtered data back to the file
fs.writeFileSync(modelsPath, JSON.stringify(updatedData, null, 2), 'utf8')
console.log('✅ Successfully removed models ending with "search"')

View File

@ -15,17 +15,14 @@ exports[`Config & Schema > Snapshot Tests > should snapshot model configurations
"FUNCTION_CALL",
"REASONING",
],
"contextWindow": 128000,
"context_window": 128000,
"description": "A test model for unit testing",
"endpointTypes": [
"CHAT_COMPLETIONS",
],
"id": "test-model",
"inputModalities": [
"input_modalities": [
"TEXT",
],
"maxInputTokens": 124000,
"maxOutputTokens": 4096,
"max_input_tokens": 124000,
"max_output_tokens": 4096,
"metadata": {
"architecture": "transformer",
"category": "language-model",
@ -41,10 +38,10 @@ exports[`Config & Schema > Snapshot Tests > should snapshot model configurations
"trainingData": "synthetic",
},
"name": "Test Model",
"outputModalities": [
"output_modalities": [
"TEXT",
],
"ownedBy": "TestProvider",
"owned_by": "TestProvider",
"parameters": {
"maxTokens": true,
"systemMessage": true,
@ -64,11 +61,11 @@ exports[`Config & Schema > Snapshot Tests > should snapshot model configurations
"pricing": {
"input": {
"currency": "USD",
"perMillionTokens": 1,
"per_million_tokens": 1,
},
"output": {
"currency": "USD",
"perMillionTokens": 2,
"per_million_tokens": 2,
},
},
},
@ -87,22 +84,20 @@ exports[`Config & Schema > Snapshot Tests > should snapshot override configurati
],
},
"disabled": false,
"lastUpdated": "2025-11-24T07:08:00Z",
"limits": {
"contextWindow": 256000,
"maxOutputTokens": 8192,
"context_window": 256000,
"max_output_tokens": 8192,
},
"modelId": "test-model",
"model_id": "test-model",
"pricing": {
"input": {
"currency": "USD",
"perMillionTokens": 0.5,
"per_million_tokens": 0.5,
},
},
"priority": 100,
"providerId": "test-provider",
"provider_id": "test-provider",
"reason": "Test override for enhanced capabilities and limits",
"updatedBy": "test-suite",
},
]
`;
@ -110,40 +105,40 @@ exports[`Config & Schema > Snapshot Tests > should snapshot override configurati
exports[`Config & Schema > Snapshot Tests > should snapshot provider configurations 1`] = `
[
{
"apiCompatibility": {
"supportsApiVersion": false,
"supportsArrayContent": true,
"supportsDeveloperRole": false,
"supportsMultimodal": false,
"supportsParallelTools": false,
"supportsServiceTier": false,
"supportsStreamOptions": false,
"supportsThinkingControl": false,
"api_compatibility": {
"supports_api_version": false,
"supports_array_content": true,
"supports_developer_role": false,
"supports_multimodal": false,
"supports_parallel_tools": false,
"supports_service_tier": false,
"supports_stream_options": false,
"supports_thinking_control": false,
},
"authentication": "API_KEY",
"behaviors": {
"hasAutoRetry": false,
"hasRealTimeMetrics": false,
"providesFallbackRouting": false,
"providesModelMapping": false,
"providesUsageAnalytics": false,
"providesUsageLimits": false,
"requiresApiKeyValidation": true,
"supportsBatchProcessing": false,
"supportsCustomModels": false,
"supportsHealthCheck": false,
"supportsModelFineTuning": false,
"supportsModelVersioning": false,
"supportsRateLimiting": false,
"supportsStreaming": true,
"supportsWebhookEvents": false,
"has_auto_retry": false,
"has_real_time_metrics": false,
"provides_fallback_routing": false,
"provides_model_mapping": false,
"provides_usage_analytics": false,
"provides_usage_limits": false,
"requires_api_key_validation": true,
"supports_batch_processing": false,
"supports_custom_models": false,
"supports_health_check": false,
"supports_model_fine_tuning": false,
"supports_model_versioning": false,
"supports_rate_limiting": false,
"supports_streaming": true,
"supports_webhook_events": false,
},
"configVersion": "1.0.0",
"config_version": "1.0.0",
"deprecated": false,
"description": "A test provider for unit testing",
"documentation": "https://docs.test.com",
"id": "test-provider",
"maintenanceMode": false,
"maintenance_mode": false,
"metadata": {
"category": "ai-provider",
"reliability": "high",
@ -155,11 +150,11 @@ exports[`Config & Schema > Snapshot Tests > should snapshot provider configurati
"test",
],
},
"modelRouting": "DIRECT",
"model_routing": "DIRECT",
"name": "Test Provider",
"pricingModel": "PER_MODEL",
"specialConfig": {},
"supportedEndpoints": [
"pricing_model": "PER_MODEL",
"special_config": {},
"supported_endpoints": [
"CHAT_COMPLETIONS",
],
"website": "https://test.com",
@ -174,17 +169,14 @@ exports[`Config & Schema > Snapshot Tests > should snapshot validation results 1
"FUNCTION_CALL",
"REASONING",
],
"contextWindow": 128000,
"context_window": 128000,
"description": "A test model for unit testing",
"endpointTypes": [
"CHAT_COMPLETIONS",
],
"id": "test-model",
"inputModalities": [
"input_modalities": [
"TEXT",
],
"maxInputTokens": 124000,
"maxOutputTokens": 4096,
"max_input_tokens": 124000,
"max_output_tokens": 4096,
"metadata": {
"architecture": "transformer",
"category": "language-model",
@ -200,10 +192,10 @@ exports[`Config & Schema > Snapshot Tests > should snapshot validation results 1
"trainingData": "synthetic",
},
"name": "Test Model",
"outputModalities": [
"output_modalities": [
"TEXT",
],
"ownedBy": "TestProvider",
"owned_by": "TestProvider",
"parameters": {
"maxTokens": true,
"systemMessage": true,
@ -223,11 +215,11 @@ exports[`Config & Schema > Snapshot Tests > should snapshot validation results 1
"pricing": {
"input": {
"currency": "USD",
"perMillionTokens": 1,
"per_million_tokens": 1,
},
"output": {
"currency": "USD",
"perMillionTokens": 2,
"per_million_tokens": 2,
},
},
},

View File

@ -25,17 +25,17 @@ describe('Config & Schema', () => {
expect(model).toStrictEqual({
id: 'test-model',
name: 'Test Model',
ownedBy: 'TestProvider',
owned_by: 'TestProvider',
description: 'A test model for unit testing',
capabilities: ['FUNCTION_CALL', 'REASONING'],
inputModalities: ['TEXT'],
outputModalities: ['TEXT'],
contextWindow: 128000,
maxOutputTokens: 4096,
maxInputTokens: 124000,
input_modalities: ['TEXT'],
output_modalities: ['TEXT'],
context_window: 128000,
max_output_tokens: 4096,
max_input_tokens: 124000,
pricing: {
input: { perMillionTokens: 1, currency: 'USD' },
output: { perMillionTokens: 2, currency: 'USD' }
input: { per_million_tokens: 1, currency: 'USD' },
output: { per_million_tokens: 2, currency: 'USD' }
},
parameters: {
temperature: { supported: true, min: 0, max: 2, default: 1 },
@ -43,7 +43,6 @@ describe('Config & Schema', () => {
systemMessage: true,
topP: { supported: true, min: 0, max: 1, default: 1 }
},
endpointTypes: ['CHAT_COMPLETIONS'],
metadata: {
tags: ['test', 'fast', 'reliable'],
category: 'language-model',
@ -75,42 +74,42 @@ describe('Config & Schema', () => {
name: 'Test Provider',
description: 'A test provider for unit testing',
authentication: 'API_KEY',
pricingModel: 'PER_MODEL',
modelRouting: 'DIRECT',
pricing_model: 'PER_MODEL',
model_routing: 'DIRECT',
behaviors: {
supportsCustomModels: false,
providesModelMapping: false,
supportsModelVersioning: false,
providesFallbackRouting: false,
hasAutoRetry: false,
supportsHealthCheck: false,
hasRealTimeMetrics: false,
providesUsageAnalytics: false,
supportsWebhookEvents: false,
requiresApiKeyValidation: true,
supportsRateLimiting: false,
providesUsageLimits: false,
supportsStreaming: true,
supportsBatchProcessing: false,
supportsModelFineTuning: false
supports_custom_models: false,
provides_model_mapping: false,
supports_model_versioning: false,
provides_fallback_routing: false,
has_auto_retry: false,
supports_health_check: false,
has_real_time_metrics: false,
provides_usage_analytics: false,
supports_webhook_events: false,
requires_api_key_validation: true,
supports_rate_limiting: false,
provides_usage_limits: false,
supports_streaming: true,
supports_batch_processing: false,
supports_model_fine_tuning: false
},
supportedEndpoints: ['CHAT_COMPLETIONS'],
apiCompatibility: {
supportsArrayContent: true,
supportsStreamOptions: false,
supportsDeveloperRole: false,
supportsThinkingControl: false,
supportsApiVersion: false,
supportsParallelTools: false,
supportsMultimodal: false,
supportsServiceTier: false
supported_endpoints: ['CHAT_COMPLETIONS'],
api_compatibility: {
supports_array_content: true,
supports_stream_options: false,
supports_developer_role: false,
supports_thinking_control: false,
supports_api_version: false,
supports_parallel_tools: false,
supports_multimodal: false,
supports_service_tier: false
},
specialConfig: {},
special_config: {},
documentation: 'https://docs.test.com',
website: 'https://test.com',
deprecated: false,
maintenanceMode: false,
configVersion: '1.0.0',
maintenance_mode: false,
config_version: '1.0.0',
metadata: {
tags: ['test'],
category: 'ai-provider',
@ -135,8 +134,8 @@ describe('Config & Schema', () => {
const override = overrides[0]
expect(override).toMatchObject({
providerId: 'test-provider',
modelId: 'test-model',
provider_id: 'test-provider',
model_id: 'test-model',
disabled: false,
reason: 'Test override for enhanced capabilities and limits',
priority: 100
@ -144,8 +143,8 @@ describe('Config & Schema', () => {
expect(override.capabilities?.add).toContain('FUNCTION_CALL')
expect(override.capabilities?.remove).toContain('REASONING')
expect(override.limits?.contextWindow).toBe(256000)
expect(override.limits?.maxOutputTokens).toBe(8192)
expect(override.limits?.context_window).toBe(256000)
expect(override.limits?.max_output_tokens).toBe(8192)
})
it('should load all configs simultaneously', async () => {
@ -185,10 +184,10 @@ describe('Config & Schema', () => {
const validModel = {
id: 'test-model',
capabilities: ['FUNCTION_CALL', 'REASONING'],
inputModalities: ['TEXT'],
outputModalities: ['TEXT'],
contextWindow: 128000,
maxOutputTokens: 4096,
input_modalities: ['TEXT'],
output_modalities: ['TEXT'],
context_window: 128000,
max_output_tokens: 4096,
metadata: {
tags: ['test'],
category: 'language-model',
@ -222,11 +221,11 @@ describe('Config & Schema', () => {
const modelWithIssues = {
id: 'test-model',
capabilities: [], // Empty capabilities
inputModalities: ['TEXT'],
outputModalities: ['TEXT'],
contextWindow: 200000, // Large context window
maxOutputTokens: 4096,
capabilities: ['FUNCTION_CALL'], // At least one required now
input_modalities: ['TEXT'],
output_modalities: ['TEXT'],
context_window: 200000, // Large context window
max_output_tokens: 4096,
// Missing pricing and description
metadata: {
tags: ['test'],
@ -247,10 +246,10 @@ describe('Config & Schema', () => {
const model = {
id: 'test-model',
capabilities: ['FUNCTION_CALL'],
inputModalities: ['TEXT'],
outputModalities: ['TEXT'],
contextWindow: 1000,
maxOutputTokens: 500,
input_modalities: ['TEXT'],
output_modalities: ['TEXT'],
context_window: 1000,
max_output_tokens: 500,
metadata: {
tags: ['test'],
category: 'language-model',

View File

@ -4,21 +4,21 @@
{
"id": "test-model",
"name": "Test Model",
"ownedBy": "TestProvider",
"owned_by": "TestProvider",
"description": "A test model for unit testing",
"capabilities": ["FUNCTION_CALL", "REASONING"],
"inputModalities": ["TEXT"],
"outputModalities": ["TEXT"],
"contextWindow": 128000,
"maxOutputTokens": 4096,
"maxInputTokens": 124000,
"input_modalities": ["TEXT"],
"output_modalities": ["TEXT"],
"context_window": 128000,
"max_output_tokens": 4096,
"max_input_tokens": 124000,
"pricing": {
"input": {
"perMillionTokens": 1,
"per_million_tokens": 1,
"currency": "USD"
},
"output": {
"perMillionTokens": 2,
"per_million_tokens": 2,
"currency": "USD"
}
},

View File

@ -2,26 +2,24 @@
"version": "1.0.0",
"overrides": [
{
"providerId": "test-provider",
"modelId": "test-model",
"provider_id": "test-provider",
"model_id": "test-model",
"capabilities": {
"add": ["FUNCTION_CALL"],
"remove": ["REASONING"]
},
"limits": {
"contextWindow": 256000,
"maxOutputTokens": 8192
"context_window": 256000,
"max_output_tokens": 8192
},
"pricing": {
"input": {
"perMillionTokens": 0.5,
"per_million_tokens": 0.5,
"currency": "USD"
}
},
"disabled": false,
"reason": "Test override for enhanced capabilities and limits",
"lastUpdated": "2025-11-24T07:08:00Z",
"updatedBy": "test-suite",
"priority": 100
}
]

View File

@ -6,41 +6,42 @@
"name": "Test Provider",
"description": "A test provider for unit testing",
"authentication": "API_KEY",
"pricingModel": "PER_MODEL",
"modelRouting": "DIRECT",
"pricing_model": "PER_MODEL",
"model_routing": "DIRECT",
"behaviors": {
"supportsCustomModels": false,
"providesModelMapping": false,
"supportsModelVersioning": false,
"providesFallbackRouting": false,
"hasAutoRetry": false,
"supportsHealthCheck": false,
"hasRealTimeMetrics": false,
"providesUsageAnalytics": false,
"supportsWebhookEvents": false,
"requiresApiKeyValidation": true,
"supportsRateLimiting": false,
"providesUsageLimits": false,
"supportsStreaming": true,
"supportsBatchProcessing": false,
"supportsModelFineTuning": false
"supports_custom_models": false,
"provides_model_mapping": false,
"supports_model_versioning": false,
"provides_fallback_routing": false,
"has_auto_retry": false,
"supports_health_check": false,
"has_real_time_metrics": false,
"provides_usage_analytics": false,
"supports_webhook_events": false,
"requires_api_key_validation": true,
"supports_rate_limiting": false,
"provides_usage_limits": false,
"supports_streaming": true,
"supports_batch_processing": false,
"supports_model_fine_tuning": false
},
"supportedEndpoints": ["CHAT_COMPLETIONS"],
"apiCompatibility": {
"supportsArrayContent": true,
"supportsStreamOptions": false,
"supportsDeveloperRole": false,
"supportsThinkingControl": false,
"supportsApiVersion": false,
"supportsParallelTools": false,
"supportsMultimodal": false
"supported_endpoints": ["CHAT_COMPLETIONS"],
"api_compatibility": {
"supports_array_content": true,
"supports_stream_options": false,
"supports_developer_role": false,
"supports_thinking_control": false,
"supports_api_version": false,
"supports_parallel_tools": false,
"supports_multimodal": false,
"supports_service_tier": false
},
"specialConfig": {},
"special_config": {},
"documentation": "https://docs.test.com",
"website": "https://test.com",
"deprecated": false,
"maintenanceMode": false,
"configVersion": "1.0.0",
"maintenance_mode": false,
"config_version": "1.0.0",
"metadata": {
"tags": ["test"],
"category": "ai-provider",

View File

@ -33,9 +33,9 @@ export const StringRangeSchema = z.object({
max: z.string()
})
// Price per token schema
// Price per token schema (snake_case)
export const PricePerTokenSchema = z.object({
perMillionTokens: z.number().nonnegative(),
per_million_tokens: z.number().nonnegative(),
currency: CurrencySchema.default('USD')
})

View File

@ -25,8 +25,6 @@ export type {
Reasoning
} from './model'
export type {
OverrideResult,
OverrideValidation,
ProviderModelOverride
} from './override'
export type {

View File

@ -170,6 +170,10 @@ export const ModelPricingSchema = z.object({
input: PricePerTokenSchema,
output: PricePerTokenSchema,
// Cache pricing (optional) - for providers like AIHubMix, Anthropic, OpenAI
cache_read: PricePerTokenSchema.optional(),
cache_write: PricePerTokenSchema.optional(),
// Image pricing (optional)
per_image: z
.object({
@ -197,15 +201,33 @@ export const ModelConfigSchema = z.object({
description: z.string().optional(),
// Capabilities (core)
capabilities: z.array(ModelCapabilityTypeSchema),
capabilities: z
.array(ModelCapabilityTypeSchema)
.min(1, 'At least one capability is required')
.refine((arr) => new Set(arr).size === arr.length, {
message: 'Capabilities must be unique'
})
.optional(),
// Modalities
input_modalities: z.array(ModalitySchema),
output_modalities: z.array(ModalitySchema),
input_modalities: z
.array(ModalitySchema)
.min(1, 'At least one input modality is required')
.refine((arr) => new Set(arr).size === arr.length, {
message: 'Input modalities must be unique'
})
.optional(),
output_modalities: z
.array(ModalitySchema)
.min(1, 'At least one output modality is required')
.refine((arr) => new Set(arr).size === arr.length, {
message: 'Output modalities must be unique'
})
.optional(),
// Limits
context_window: z.number(),
max_output_tokens: z.number(),
context_window: z.number().optional(),
max_output_tokens: z.number().optional(),
max_input_tokens: z.number().optional(),
// Pricing

View File

@ -5,9 +5,8 @@
import * as z from 'zod'
import { MetadataSchema, ModelIdSchema, ProviderIdSchema, VersionSchema } from './common'
import { ModelIdSchema, ProviderIdSchema, VersionSchema } from './common'
import { ModelCapabilityTypeSchema, ModelPricingSchema, ParameterSupportSchema, ReasoningSchema } from './model'
import { EndpointTypeSchema } from './provider'
// Capability override operations
export const CapabilityOverrideSchema = z.object({
@ -23,125 +22,55 @@ export const LimitsOverrideSchema = z.object({
max_input_tokens: z.number().optional()
})
// Pricing override configuration
// Pricing override (partial of ModelPricingSchema)
export const PricingOverrideSchema = ModelPricingSchema.partial().optional()
// Endpoint types override
export const EndpointTypesOverrideSchema = z.array(EndpointTypeSchema).optional()
// Reasoning configuration override - allows partial override of reasoning configs
// Reasoning configuration override
export const ReasoningOverrideSchema = ReasoningSchema.optional()
// Parameter support override
export const ParameterSupportOverrideSchema = ParameterSupportSchema.partial().optional()
// Model metadata override
export const MetadataOverrideSchema = z
.object({
name: z.string().optional(),
description: z.string().optional(),
deprecation_date: z.iso.datetime().optional(),
replaced_by: ModelIdSchema.optional(),
metadata: MetadataSchema
})
.optional()
// Main provider model override schema
// SIMPLIFIED: Main provider model override schema
export const ProviderModelOverrideSchema = z.object({
// Identification
provider_id: ProviderIdSchema,
model_id: ModelIdSchema,
// Capability overrides
// Core overrides
capabilities: CapabilityOverrideSchema.optional(),
// Limits overrides
limits: LimitsOverrideSchema.optional(),
// Pricing overrides
pricing: PricingOverrideSchema,
// Reasoning configuration overrides
reasoning: ReasoningOverrideSchema.optional(),
// Parameter support overrides
parameters: ParameterSupportOverrideSchema.optional(),
// Endpoint type overrides
endpoint_types: EndpointTypesOverrideSchema.optional(),
// Status control
disabled: z.boolean().optional(),
replace_with: ModelIdSchema.optional(),
// Model metadata overrides
metadata: MetadataOverrideSchema.optional(),
// Metadata
reason: z.string().optional(),
priority: z.number().default(0)
// Status overrides
disabled: z.boolean().optional(), // Disable this model for this provider
replace_with: ModelIdSchema.optional(), // Replace with alternative model
// Override tracking
reason: z.string().optional(), // Reason for override
last_updated: z.iso.datetime().optional(),
updated_by: z.string().optional(), // Who made the override
// Override priority (higher number = higher priority)
priority: z.number().default(0),
// Override conditions
conditions: z
.object({
// Apply override only for specific regions
regions: z.array(z.string()).optional(),
// Apply override only for specific user tiers
user_tiers: z.array(z.string()).optional(),
// Apply override only in specific environments
environments: z.array(z.enum(['development', 'staging', 'production'])).optional(),
// Time-based conditions
valid_from: z.iso.datetime().optional(),
valid_until: z.iso.datetime().optional()
})
.optional(),
// Additional override metadata
override_metadata: MetadataSchema.optional()
// REMOVED: conditions (not evaluated in code)
// REMOVED: endpoint_types (not used)
// REMOVED: metadata overrides (not used)
// REMOVED: last_updated, updated_by (use git)
// REMOVED: override_metadata (not used)
})
// Override container schema for JSON files
// Override list container
export const OverrideListSchema = z.object({
version: VersionSchema,
overrides: z.array(ProviderModelOverrideSchema)
})
// Override application result schema
export const OverrideResultSchema = z.object({
model_id: ModelIdSchema,
provider_id: ProviderIdSchema,
applied: z.boolean(),
applied_overrides: z.array(z.string()), // List of applied override fields
original_values: z.record(z.string(), z.unknown()), // Original values before override
new_values: z.record(z.string(), z.unknown()), // New values after override
override_reason: z.string().optional(),
applied_at: z.iso.datetime().optional()
})
// Override validation result
export const OverrideValidationSchema = z.object({
valid: z.boolean(),
errors: z.array(z.string()),
warnings: z.array(z.string()),
recommendations: z.array(z.string())
})
// Type exports
export type CapabilityOverride = z.infer<typeof CapabilityOverrideSchema>
export type LimitsOverride = z.infer<typeof LimitsOverrideSchema>
export type PricingOverride = z.infer<typeof PricingOverrideSchema>
export type EndpointTypesOverride = z.infer<typeof EndpointTypesOverrideSchema>
export type ReasoningOverride = z.infer<typeof ReasoningOverrideSchema>
export type ParameterSupportOverride = z.infer<typeof ParameterSupportOverrideSchema>
export type MetadataOverride = z.infer<typeof MetadataOverrideSchema>
export type ProviderModelOverride = z.infer<typeof ProviderModelOverrideSchema>
export type OverrideList = z.infer<typeof OverrideListSchema>
export type OverrideResult = z.infer<typeof OverrideResultSchema>
export type OverrideValidation = z.infer<typeof OverrideValidationSchema>

View File

@ -117,7 +117,12 @@ export const ProviderConfigSchema = z.object({
behaviors: ProviderBehaviorsSchema,
// Feature support
supported_endpoints: z.array(EndpointTypeSchema),
supported_endpoints: z
.array(EndpointTypeSchema)
.min(1, 'At least one endpoint must be supported')
.refine((arr) => new Set(arr).size === arr.length, {
message: 'Supported endpoints must be unique'
}),
mcp_support: McpSupportSchema.optional(),
api_compatibility: ApiCompatibilitySchema.optional(),

View File

@ -0,0 +1,72 @@
/**
* AIHubMix one-time importer
* Fetches models from AIHubMix API and saves to JSON file
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import { AiHubMixTransformer } from './transformer'
import type { AiHubMixResponse } from './types'
export class AiHubMixImporter {
private transformer: AiHubMixTransformer
private apiUrl: string
constructor(apiUrl = 'https://aihubmix.com/api/v1') {
this.transformer = new AiHubMixTransformer()
this.apiUrl = apiUrl
}
/**
* Fetch models from AIHubMix API and save to JSON file
* @param outputPath - Path to output JSON file
*/
async importModels(outputPath: string): Promise<void> {
console.log('Fetching models from AIHubMix API...')
console.log(`API URL: ${this.apiUrl}/models`)
const response = await fetch(`${this.apiUrl}/models`)
if (!response.ok) {
throw new Error(`AIHubMix API error: ${response.status} ${response.statusText}`)
}
const json: AiHubMixResponse = await response.json()
console.log(`✓ Fetched ${json.data.length} models from AIHubMix`)
// Transform to internal format
console.log('Transforming models to internal format...')
const models = json.data.map((m) => this.transformer.transform(m))
console.log(`✓ Transformed ${models.length} models`)
// Prepare output matching ModelListSchema format
const output = {
version: new Date().toISOString().split('T')[0].replace(/-/g, '.'),
models
}
// Ensure output directory exists
const outputDir = path.dirname(outputPath)
await fs.mkdir(outputDir, { recursive: true })
// Write to file
await fs.writeFile(outputPath, JSON.stringify(output, null, 2) + '\n', 'utf-8')
console.log(`✓ Saved ${models.length} models to ${outputPath}`)
}
/**
* CLI entry point
*/
static async run(): Promise<void> {
const importer = new AiHubMixImporter()
const outputPath = path.join(process.cwd(), 'data', 'aihubmix-models.json')
try {
await importer.importModels(outputPath)
console.log('\n✓ Import completed successfully')
} catch (error) {
console.error('\n✗ Import failed:', error)
throw error
}
}
}

View File

@ -0,0 +1,214 @@
/**
* AIHubMix data transformer
* Converts AIHubMix API format to internal ModelConfig schema
*/
import type { Modality,ModelCapabilityType, ModelConfig } from '../../../schemas'
import type { AiHubMixModel } from './types'
export class AiHubMixTransformer {
/**
* Transform AIHubMix model to internal ModelConfig
* @param apiModel - Model data from AIHubMix API
* @returns Internal model configuration
*/
transform(apiModel: AiHubMixModel): ModelConfig {
return {
id: apiModel.model_id,
description: apiModel.desc || undefined,
capabilities: this.mapCapabilities(apiModel.types, apiModel.features),
input_modalities: this.mapModalities(apiModel.input_modalities),
output_modalities: this.inferOutputModalities(apiModel.types),
context_window: apiModel.context_length || 0,
max_output_tokens: apiModel.max_output || 0,
pricing: {
input: {
per_million_tokens: apiModel.pricing.input,
currency: 'USD'
},
output: {
per_million_tokens: apiModel.pricing.output,
currency: 'USD'
},
...(apiModel.pricing.cache_read && {
cache_read: {
per_million_tokens: apiModel.pricing.cache_read,
currency: 'USD'
}
}),
...(apiModel.pricing.cache_write && {
cache_write: {
per_million_tokens: apiModel.pricing.cache_write,
currency: 'USD'
}
})
},
metadata: {
source: 'aihubmix',
tags: this.extractTags(apiModel),
category: this.inferCategory(apiModel.types),
original_types: apiModel.types,
original_features: apiModel.features
}
}
}
/**
* Map AIHubMix types and features to internal capabilities
*/
private mapCapabilities(types: string, features: string): ModelCapabilityType[] {
const caps = new Set<ModelCapabilityType>()
// Parse features CSV
const featureList = features
.split(',')
.map((f) => f.trim().toLowerCase())
.filter(Boolean)
// Map features to capabilities
featureList.forEach((feature) => {
switch (feature) {
case 'thinking':
caps.add('REASONING')
break
case 'function_calling':
case 'tools':
caps.add('FUNCTION_CALL')
break
case 'structured_outputs':
caps.add('STRUCTURED_OUTPUT')
break
case 'web':
case 'deepsearch':
caps.add('WEB_SEARCH')
break
}
})
// Map types to capabilities
const typeList = types
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
typeList.forEach((type) => {
switch (type) {
case 'image_generation':
caps.add('IMAGE_GENERATION')
break
case 'video':
caps.add('VIDEO_GENERATION')
break
}
})
// Return as array (deduplicate via Set)
const result = Array.from(caps)
// If no capabilities found, add a default TEXT capability
if (result.length === 0) {
return []
}
return result
}
/**
* Map AIHubMix input_modalities CSV to internal Modality array
*/
private mapModalities(modalitiesCSV: string): Modality[] {
const modalities = new Set<Modality>()
const modalityList = modalitiesCSV
.split(',')
.map((m) => m.trim().toUpperCase())
.filter(Boolean)
modalityList.forEach((m) => {
switch (m) {
case 'TEXT':
modalities.add('TEXT')
break
case 'IMAGE':
modalities.add('VISION')
break
case 'AUDIO':
modalities.add('AUDIO')
break
case 'VIDEO':
modalities.add('VIDEO')
break
}
})
const result = Array.from(modalities)
// Default to TEXT if no modalities found
if (result.length === 0) {
return ['TEXT']
}
return result
}
/**
* Infer output modalities from model type
*/
private inferOutputModalities(types: string): Modality[] {
const typeList = types
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
if (typeList.includes('image_generation')) {
return ['VISION']
}
if (typeList.includes('video')) {
return ['VIDEO']
}
// Default to TEXT for LLMs
return ['TEXT']
}
/**
* Extract tags from model data
*/
private extractTags(apiModel: AiHubMixModel): string[] {
const tags: string[] = []
// Add type-based tags
const types = apiModel.types.split(',').map((t) => t.trim())
tags.push(...types)
// Add feature-based tags
const features = apiModel.features.split(',').map((f) => f.trim())
tags.push(...features)
// Deduplicate and filter empty
return Array.from(new Set(tags)).filter(Boolean)
}
/**
* Infer metadata category from type
*/
private inferCategory(types: string): string {
const typeList = types
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
if (typeList.includes('image_generation')) {
return 'image-generation'
}
if (typeList.includes('video')) {
return 'video-generation'
}
return 'language-model'
}
}

View File

@ -0,0 +1,49 @@
/**
* AIHubMix API response structure types
* Based on https://aihubmix.com/api/v1/models
*/
/**
* Single model entry from AIHubMix API
*/
export interface AiHubMixModel {
/** Model identifier (e.g., "gpt-4", "claude-3-opus") */
model_id: string
/** Model description */
desc: string
/** Pricing information */
pricing: {
/** Cache read pricing (optional, e.g., Anthropic cache hits) */
cache_read?: number
/** Cache write pricing (optional, e.g., Anthropic cache writes) */
cache_write?: number
/** Input pricing per million tokens */
input: number
/** Output pricing per million tokens */
output: number
}
/** Model type: "llm" | "image_generation" | "video" */
types: string
/** Comma-separated features: "thinking,tools,function_calling,web,structured_outputs" */
features: string
/** Comma-separated input modalities: "text,image,audio,video" */
input_modalities: string
/** Maximum output tokens */
max_output: number
/** Context window length */
context_length: number
}
/**
* AIHubMix API response wrapper
*/
export interface AiHubMixResponse {
data: AiHubMixModel[]
}

View File

@ -0,0 +1,8 @@
/**
* External data importers
* One-time import utilities for various AI provider catalogs
*/
export * from './aihubmix/importer'
export * from './aihubmix/transformer'
export * from './aihubmix/types'

View File

@ -0,0 +1,129 @@
/**
* Override application utility
* Provides centralized logic for applying provider-specific model overrides
*/
import type { ModelConfig, ProviderModelOverride } from '../schemas'
/**
* Error thrown when an override cannot be applied
*/
export class OverrideApplicationError extends Error {
constructor(message: string) {
super(message)
this.name = 'OverrideApplicationError'
}
}
/**
* Apply provider-specific overrides to a base model configuration
*
* @param baseModel - The base model configuration
* @param override - The provider-specific override configuration (null if no override)
* @returns The model configuration with overrides applied
* @throws OverrideApplicationError if model is disabled or override is invalid
*/
export function applyOverrides(
baseModel: ModelConfig,
override: ProviderModelOverride | null
): ModelConfig {
if (!override) return baseModel
// Check if model is disabled for this provider
if (override.disabled) {
throw new OverrideApplicationError(
`Model ${baseModel.id} is disabled for provider ${override.provider_id}` +
(override.reason ? `: ${override.reason}` : '') +
(override.replace_with ? `. Use ${override.replace_with} instead` : '')
)
}
// Apply capability modifications
let capabilities = [...baseModel.capabilities]
if (override.capabilities) {
if (override.capabilities.force) {
// Force: completely replace capabilities
capabilities = override.capabilities.force
} else {
// Add new capabilities
if (override.capabilities.add) {
capabilities = [...capabilities, ...override.capabilities.add]
}
// Remove capabilities
if (override.capabilities.remove) {
capabilities = capabilities.filter((cap) => !override.capabilities!.remove!.includes(cap))
}
// Deduplicate (schema validation should already prevent duplicates)
capabilities = [...new Set(capabilities)]
}
}
// Build the overridden model configuration
return {
...baseModel,
capabilities,
// Apply limits overrides
...(override.limits && {
context_window: override.limits.context_window ?? baseModel.context_window,
max_output_tokens: override.limits.max_output_tokens ?? baseModel.max_output_tokens,
max_input_tokens: override.limits.max_input_tokens ?? baseModel.max_input_tokens
}),
// Apply pricing override (complete replacement if provided with required fields)
...(override.pricing?.input &&
override.pricing?.output && { pricing: override.pricing as ModelConfig['pricing'] }),
// Apply reasoning override
...(override.reasoning && { reasoning: override.reasoning }),
// Apply parameter support overrides (merge with base)
...(override.parameters && {
parameters: { ...baseModel.parameters, ...override.parameters }
})
}
}
/**
* Validate that an override can be safely applied to a model
*
* @param baseModel - The base model configuration
* @param override - The provider-specific override configuration
* @returns Array of warning messages (empty if no issues)
*/
export function validateOverride(baseModel: ModelConfig, override: ProviderModelOverride): string[] {
const warnings: string[] = []
// Check if removing all capabilities
if (override.capabilities?.remove) {
const remainingCaps = baseModel.capabilities.filter(
(cap) => !override.capabilities!.remove!.includes(cap)
)
if (remainingCaps.length === 0 && !override.capabilities.add && !override.capabilities.force) {
warnings.push('Override would remove all capabilities from the model')
}
}
// Check if limits are being reduced
if (override.limits) {
if (
override.limits.context_window &&
override.limits.context_window < baseModel.context_window
) {
warnings.push(
`Context window reduced from ${baseModel.context_window} to ${override.limits.context_window}`
)
}
if (
override.limits.max_output_tokens &&
override.limits.max_output_tokens < baseModel.max_output_tokens
) {
warnings.push(
`Max output tokens reduced from ${baseModel.max_output_tokens} to ${override.limits.max_output_tokens}`
)
}
}
// Check if model is disabled without replacement
if (override.disabled && !override.replace_with) {
warnings.push('Model is disabled without providing a replacement model')
}
return warnings
}

View File

@ -15,20 +15,6 @@ import { safeParseWithValidation, validateString, ValidationError, createErrorRe
const DATA_DIR = path.join(process.cwd(), '../data')
// Type-safe helper function to apply overrides to base model
function applyOverrides(baseModel: Model, override: ProviderModelOverride | null): Model {
if (!override) return baseModel
return {
...baseModel,
...(override.limits && {
context_window: override.limits.context_window ?? baseModel.context_window,
max_output_tokens: override.limits.max_output_tokens ?? baseModel.max_output_tokens
}),
...(override.pricing && { pricing: override.pricing })
}
}
// Type-safe helper function to detect model modifications
function detectModifications(
baseModel: Model,
@ -86,27 +72,25 @@ export async function GET(request: NextRequest, { params }: { params: { modelId:
const validProviderId = validateString(providerId, 'providerId')
// Read and validate all data files
const [modelsDataRaw, providersDataRaw, overridesDataRaw] = await Promise.all([
fs.readFile(path.join(DATA_DIR, 'models.json'), 'utf-8'),
fs.readFile(path.join(DATA_DIR, 'providers.json'), 'utf-8'),
fs.readFile(path.join(DATA_DIR, 'overrides.json'), 'utf-8')
])
const [modelsDataRaw] = await fs.readFile(path.join(DATA_DIR, 'models.json'), 'utf-8')
// fs.readFile(path.join(DATA_DIR, 'overrides.json'), 'utf-8')
const modelsData = await safeParseWithValidation(
modelsDataRaw,
ModelsDataFileSchema,
'Invalid models data format in file'
)
const providersData = await safeParseWithValidation(
providersDataRaw,
ProvidersDataFileSchema,
'Invalid providers data format in file'
)
const overridesData = await safeParseWithValidation(
overridesDataRaw,
OverridesDataFileSchema,
'Invalid overrides data format in file'
)
// const providersData = await safeParseWithValidation(
// providersDataRaw,
// ProvidersDataFileSchema,
// 'Invalid providers data format in file'
// )
// const overridesData = await safeParseWithValidation(
// overridesDataRaw,
// OverridesDataFileSchema,
// 'Invalid overrides data format in file'
// )
// Find base model
const baseModel = modelsData.models.find((m) => m.id === validModelId)
@ -115,14 +99,23 @@ export async function GET(request: NextRequest, { params }: { params: { modelId:
}
// Find provider override for this model
const override = overridesData.overrides.find(
(o) => o.model_id === validModelId && o.provider_id === validProviderId
)
// const override = overridesData.overrides.find(
// (o) => o.model_id === validModelId && o.provider_id === validProviderId
// )
// Apply override if exists
const finalModel = applyOverrides(baseModel, override || null)
return NextResponse.json(ModelSchema.parse(finalModel))
// // Apply override if exists - may throw if model is disabled
// try {
// const finalModel = applyOverrides(baseModel, override || null)
// return NextResponse.json(ModelSchema.parse(finalModel))
// } catch (error) {
// if (error instanceof OverrideApplicationError) {
// return NextResponse.json(
// createErrorResponse(error.message, 403),
// { status: 403 }
// )
// }
// throw error
// }
} catch (error) {
if (error instanceof ValidationError) {
console.error('Validation error:', error.message, error.details)
@ -211,8 +204,6 @@ export async function PUT(request: NextRequest, { params }: { params: { modelId:
model_id: validModelId,
disabled: false,
reason: 'Manual configuration update',
last_updated: new Date().toISOString().split('T')[0],
updated_by: 'web-interface',
priority: 100,
...modifications
}
@ -222,8 +213,7 @@ export async function PUT(request: NextRequest, { params }: { params: { modelId:
if (existingOverrideIndex >= 0) {
updatedOverrides[existingOverrideIndex] = {
...updatedOverrides[existingOverrideIndex],
...override,
last_updated: new Date().toISOString().split('T')[0]
...override
}
} else {
updatedOverrides.push(override)

View File

@ -270,23 +270,23 @@ export default function CatalogReview() {
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1 max-w-xs">
{model.capabilities.slice(0, 3).map((cap) => (
{model.capabilities?.slice(0, 3).map((cap) => (
<Badge key={cap} variant="secondary" className="text-xs">
{cap.replace('_', ' ')}
</Badge>
))}
{model.capabilities.length > 3 && (
{model.capabilities && model.capabilities.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{model.capabilities.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell>{model.context_window.toLocaleString()}</TableCell>
<TableCell>{model.context_window?.toLocaleString() || 'N/A'}</TableCell>
<TableCell>
<div className="text-sm">
<div>In: {model.input_modalities?.join(', ')}</div>
<div>Out: {model.output_modalities?.join(', ')}</div>
<div>In: {model.input_modalities?.join(', ') || 'N/A'}</div>
<div>Out: {model.output_modalities?.join(', ') || 'N/A'}</div>
</div>
</TableCell>
<TableCell>

View File

@ -3,9 +3,12 @@
* Replaces manual validation with strict type-safe schemas
*/
//TODO: 从catalog导入
import * as z from 'zod'
// Import schemas from catalog package
import {
ModelConfigSchema,
ModelListSchema
} from '../../src/schemas'
// Base parameter schemas
const ParameterRangeSchema = z.object({
@ -25,21 +28,6 @@ const ParameterUnsupportedSchema = z.object({
const ParameterValueSchema = z.union([ParameterRangeSchema, ParameterBooleanSchema, ParameterUnsupportedSchema])
// Model parameters schema
const ModelParametersSchema = z
.object({
temperature: ParameterValueSchema.optional(),
max_tokens: z.union([
z.boolean(), // Simple boolean support indicator
z.object({
supported: z.literal(true),
default: z.number().positive().optional()
})
]).optional(),
system_message: z.boolean().optional(), // Simple boolean support indicator
top_p: z.union([ParameterValueSchema, ParameterUnsupportedSchema]).optional()
})
.loose() // Allow additional parameter types
// Pricing schema
const PricingInfoSchema = z.object({
@ -53,31 +41,9 @@ const PricingInfoSchema = z.object({
})
})
// Model metadata schema
const ModelMetadataSchema = z
.object({
source: z.string().optional(),
original_provider: z.string().optional(),
supports_caching: z.boolean().optional()
})
.loose() // Allow additional metadata
// Complete Model schema
export const ModelSchema = z.object({
id: z.string().min(1),
name: z.string().optional(),
owned_by: z.string().optional(),
capabilities: z.array(z.string()),
input_modalities: z.array(z.string()),
output_modalities: z.array(z.string()),
context_window: z.number().positive(),
max_output_tokens: z.number().positive(),
max_input_tokens: z.number().positive().optional(),
pricing: PricingInfoSchema.optional(),
parameters: ModelParametersSchema.optional(),
endpoint_types: z.array(z.string()).optional(),
metadata: ModelMetadataSchema.optional()
})
// Complete Model schema - use from catalog package
export const ModelSchema = ModelConfigSchema
// Provider behaviors schema
const ProviderBehaviorsSchema = z
@ -150,11 +116,8 @@ export const ProviderSchema = z.object({
metadata: ProviderMetadataSchema.optional()
})
// Data file schemas
export const ModelsDataFileSchema = z.object({
version: z.string().min(1),
models: z.array(ModelSchema)
})
// Data file schemas - use from catalog package
export const ModelsDataFileSchema = ModelListSchema
export const ProvidersDataFileSchema = z.object({
version: z.string().min(1),