add model admin

This commit is contained in:
songtianlun 2025-08-07 20:39:20 +08:00
parent 52562b5aa7
commit f4de70302b
7 changed files with 922 additions and 3 deletions

View File

@ -278,7 +278,41 @@
"allPromptsDesc": "Review all shared prompts", "allPromptsDesc": "Review all shared prompts",
"loadingAdmin": "Loading admin panel...", "loadingAdmin": "Loading admin panel...",
"loadingDashboard": "Loading dashboard statistics...", "loadingDashboard": "Loading dashboard statistics...",
"loadingPrompts": "Loading prompts for review..." "loadingPrompts": "Loading prompts for review...",
"modelsConfig": "AI Models Configuration",
"modelsConfigDesc": "Manage AI models and configure which models are available for each subscription plan",
"syncModels": "Sync Models",
"syncFromOpenRouter": "Sync from OpenRouter",
"syncFromOpenRouterDesc": "Fetch the latest available models from your OpenRouter account",
"addModelsTo": "Add Models to",
"selectPlan": "Select Plan",
"selectPlanPlaceholder": "Choose a subscription plan...",
"fetchModels": "Fetch Models",
"addSelectedModels": "Add Selected Models",
"modelsSynced": "models synced successfully",
"modelsAdded": "models added to plan",
"noModelsSelected": "Please select at least one model",
"noPlanSelected": "Please select a plan first",
"availableModels": "Available Models",
"currentModels": "Current Models",
"modelDetails": "Model Details",
"provider": "Provider",
"maxTokens": "Max Tokens",
"inputCost": "Input Cost",
"outputCost": "Output Cost",
"perThousandTokens": "per 1K tokens",
"enabledModels": "Enabled Models",
"disabledModels": "Disabled Models",
"toggleStatus": "Toggle Status",
"removeModel": "Remove Model",
"modelEnabled": "Enabled",
"modelDisabled": "Disabled",
"loadingModels": "Loading models configuration...",
"syncingModels": "Syncing models from OpenRouter...",
"addingModels": "Adding models to plan...",
"noModelsConfigured": "No models configured",
"clickSyncModels": "Click \"Sync Models\" to fetch available models from OpenRouter",
"planModelsCount": "models available"
}, },
"plaza": { "plaza": {
"title": "Prompt Plaza", "title": "Prompt Plaza",

View File

@ -278,7 +278,41 @@
"allPromptsDesc": "审核所有共享提示词", "allPromptsDesc": "审核所有共享提示词",
"loadingAdmin": "加载管理员后台中...", "loadingAdmin": "加载管理员后台中...",
"loadingDashboard": "加载统计数据中...", "loadingDashboard": "加载统计数据中...",
"loadingPrompts": "加载审核提示词中..." "loadingPrompts": "加载审核提示词中...",
"modelsConfig": "AI 模型配置",
"modelsConfigDesc": "管理 AI 模型并配置每个订阅套餐可用的模型",
"syncModels": "同步模型",
"syncFromOpenRouter": "从 OpenRouter 同步",
"syncFromOpenRouterDesc": "从您的 OpenRouter 账户获取最新的可用模型",
"addModelsTo": "添加模型到",
"selectPlan": "选择套餐",
"selectPlanPlaceholder": "选择一个订阅套餐...",
"fetchModels": "获取模型",
"addSelectedModels": "添加选中模型",
"modelsSynced": "个模型同步成功",
"modelsAdded": "个模型已添加到套餐",
"noModelsSelected": "请至少选择一个模型",
"noPlanSelected": "请先选择一个套餐",
"availableModels": "可用模型",
"currentModels": "当前模型",
"modelDetails": "模型详情",
"provider": "提供商",
"maxTokens": "最大令牌数",
"inputCost": "输入成本",
"outputCost": "输出成本",
"perThousandTokens": "每千令牌",
"enabledModels": "启用的模型",
"disabledModels": "禁用的模型",
"toggleStatus": "切换状态",
"removeModel": "移除模型",
"modelEnabled": "已启用",
"modelDisabled": "已禁用",
"loadingModels": "加载模型配置中...",
"syncingModels": "从 OpenRouter 同步模型中...",
"addingModels": "添加模型到套餐中...",
"noModelsConfigured": "未配置模型",
"clickSyncModels": "点击「同步模型」从 OpenRouter 获取可用模型",
"planModelsCount": "个可用模型"
}, },
"plaza": { "plaza": {
"title": "提示词广场", "title": "提示词广场",

View File

@ -68,6 +68,7 @@ model SubscriptionPlan {
// 关联关系 // 关联关系
users User[] users User[]
subscriptions Subscription[] subscriptions Subscription[]
models Model[]
@@map("subscription_plans") @@map("subscription_plans")
} }
@ -204,3 +205,29 @@ model Subscription {
@@map("subscriptions") @@map("subscriptions")
} }
// AI 模型配置表 - 每个模型记录直接关联一个套餐
model Model {
id String @id @default(cuid())
subscriptionPlanId String // 关联的套餐 ID
modelId String // OpenRouter 的模型 ID如 "openai/gpt-4"
name String // 显示名称,如 "GPT-4"
provider String // 提供商,如 "OpenAI"
description String? // 模型描述
maxTokens Int? // 最大 token 数
inputCostPer1k Float? // 输入成本每1K tokens
outputCostPer1k Float? // 输出成本每1K tokens
supportedFeatures Json? // 支持的特性(如 function_calling, vision 等)
metadata Json? // 其他元数据
customLimits Json? // 自定义限制(如每日调用次数、最大 tokens 等)
isActive Boolean @default(true) // 是否启用
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联关系
subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade)
// 同一个套餐内不能有相同的模型ID
@@unique([subscriptionPlanId, modelId])
@@map("models")
}

View File

@ -0,0 +1,519 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import { Badge } from '@/components/ui/badge'
import {
RefreshCw,
Database,
Plus,
DollarSign,
Cpu,
Zap,
Trash2,
Eye,
EyeOff,
AlertCircle
} from 'lucide-react'
interface Model {
id: string
subscriptionPlanId: string
modelId: string
name: string
provider: string
description?: string
maxTokens?: number
inputCostPer1k?: number
outputCostPer1k?: number
supportedFeatures?: Record<string, unknown>
customLimits?: Record<string, unknown>
isActive: boolean
createdAt: string
updatedAt: string
subscriptionPlan: {
id: string
name: string
displayName: string
}
}
interface SubscriptionPlan {
id: string
displayName: string
name: string
}
interface AvailableModel {
modelId: string
name: string
provider: string
description?: string
maxTokens?: number
inputCostPer1k?: number
outputCostPer1k?: number
supportedFeatures?: Record<string, unknown>
metadata?: Record<string, unknown>
}
export default function AdminModelsPage() {
const t = useTranslations('admin')
const [models, setModels] = useState<Model[]>([])
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isFetching, setIsFetching] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const [selectedPlan, setSelectedPlan] = useState<string>('')
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [showAvailableModels, setShowAvailableModels] = useState(false)
useEffect(() => {
loadInitialData()
}, [])
const loadInitialData = async () => {
try {
setIsLoading(true)
const [modelsRes, plansRes] = await Promise.all([
fetch('/api/admin/models'),
fetch('/api/admin/subscription-plans')
])
if (modelsRes.ok) {
const modelsData = await modelsRes.json()
setModels(modelsData.models || [])
}
if (plansRes.ok) {
const plansData = await plansRes.json()
setPlans(plansData.plans || [])
}
} catch (error) {
console.error('Error loading data:', error)
} finally {
setIsLoading(false)
}
}
const fetchModelsFromOpenRouter = async () => {
if (!selectedPlan) {
alert(t('noPlanSelected'))
return
}
try {
setIsFetching(true)
const response = await fetch('/api/admin/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'sync',
planId: selectedPlan
})
})
if (response.ok) {
const result = await response.json()
setAvailableModels(result.availableModels || [])
setShowAvailableModels(true)
setSelectedModels([])
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error fetching models:', error)
alert('Failed to fetch models from OpenRouter')
} finally {
setIsFetching(false)
}
}
const addSelectedModelsToplan = async () => {
if (!selectedPlan || selectedModels.length === 0) {
alert(selectedModels.length === 0 ? t('noModelsSelected') : t('noPlanSelected'))
return
}
try {
setIsAdding(true)
const selectedModelData = availableModels.filter(model =>
selectedModels.includes(model.modelId)
)
const response = await fetch('/api/admin/models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'add',
planId: selectedPlan,
selectedModels: selectedModelData
})
})
if (response.ok) {
const result = await response.json()
await loadInitialData() // 重新加载数据
setShowAvailableModels(false)
setSelectedModels([])
alert(`${result.models?.length || 0} ${t('modelsAdded')}`)
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error adding models:', error)
alert('Failed to add models to plan')
} finally {
setIsAdding(false)
}
}
const toggleModelStatus = async (modelId: string, currentStatus: boolean) => {
try {
const response = await fetch('/api/admin/models', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
modelId,
isActive: !currentStatus
})
})
if (response.ok) {
await loadInitialData()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error updating model status:', error)
alert('Failed to update model status')
}
}
const removeModel = async (modelId: string) => {
if (!confirm('Are you sure you want to remove this model?')) {
return
}
try {
const response = await fetch(`/api/admin/models?id=${modelId}`, {
method: 'DELETE'
})
if (response.ok) {
await loadInitialData()
} else {
const error = await response.json()
alert(`Error: ${error.error}`)
}
} catch (error) {
console.error('Error removing model:', error)
alert('Failed to remove model')
}
}
const toggleModelSelection = (modelId: string) => {
setSelectedModels(prev =>
prev.includes(modelId)
? prev.filter(id => id !== modelId)
: [...prev, modelId]
)
}
const getModelsByPlan = (planId: string) => {
return models.filter(model => model.subscriptionPlanId === planId)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-muted-foreground">{t('loadingModels')}</p>
</div>
</div>
)
}
return (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">{t('modelsConfig')}</h1>
<p className="text-muted-foreground">
{t('modelsConfigDesc')}
</p>
</div>
{/* Add Models Section */}
<Card className="p-6 mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold mb-2 flex items-center">
<Plus className="h-5 w-5 mr-2" />
{t('addModelsTo')} {selectedPlan && plans.find(p => p.id === selectedPlan)?.displayName}
</h2>
<p className="text-muted-foreground">
{t('syncFromOpenRouterDesc')}
</p>
</div>
</div>
<div className="space-y-4">
{/* Plan Selection */}
<div>
<label className="block text-sm font-medium mb-2">{t('selectPlan')}</label>
<select
value={selectedPlan}
onChange={(e) => {
setSelectedPlan(e.target.value)
setShowAvailableModels(false)
setSelectedModels([])
}}
className="w-full md:w-64 bg-background border border-input rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">{t('selectPlanPlaceholder')}</option>
{plans.map(plan => (
<option key={plan.id} value={plan.id}>
{plan.displayName}
</option>
))}
</select>
</div>
{/* Fetch Models Button */}
{selectedPlan && (
<div className="flex items-center space-x-4">
<Button
onClick={fetchModelsFromOpenRouter}
disabled={isFetching}
className="flex items-center space-x-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? t('syncingModels') : t('fetchModels')}</span>
</Button>
{showAvailableModels && (
<div className="text-sm text-muted-foreground">
{availableModels.length} models available {selectedModels.length} selected
</div>
)}
</div>
)}
{/* Available Models Selection */}
{showAvailableModels && availableModels.length > 0 && (
<div className="border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{t('availableModels')} ({availableModels.length})</h3>
<Button
onClick={addSelectedModelsToplan}
disabled={selectedModels.length === 0 || isAdding}
size="sm"
>
{isAdding ? (
<>
<LoadingSpinner size="sm" />
<span className="ml-2">{t('addingModels')}</span>
</>
) : (
`${t('addSelectedModels')} (${selectedModels.length})`
)}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
{availableModels.map(model => {
const isSelected = selectedModels.includes(model.modelId)
const isAlreadyAdded = models.some(m =>
m.modelId === model.modelId && m.subscriptionPlanId === selectedPlan
)
return (
<div
key={model.modelId}
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
isAlreadyAdded
? 'border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20'
: isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent'
}`}
onClick={() => !isAlreadyAdded && toggleModelSelection(model.modelId)}
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h4 className="font-medium text-sm truncate">{model.name}</h4>
<Badge variant="secondary" className="text-xs">
{model.provider}
</Badge>
</div>
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
{model.maxTokens && (
<span className="flex items-center">
<Zap className="h-3 w-3 mr-1" />
{model.maxTokens.toLocaleString()}
</span>
)}
{model.inputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.inputCostPer1k.toFixed(4)}
</span>
)}
</div>
{isAlreadyAdded && (
<div className="flex items-center mt-1 text-xs text-yellow-600 dark:text-yellow-400">
<AlertCircle className="h-3 w-3 mr-1" />
Already added
</div>
)}
</div>
{!isAlreadyAdded && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => e.stopPropagation()}
className="h-4 w-4 text-primary focus:ring-primary border-input rounded"
/>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</Card>
{/* Current Models by Plan */}
<div className="space-y-6">
{plans.map(plan => {
const planModels = getModelsByPlan(plan.id)
return (
<Card key={plan.id} className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-foreground">
{plan.displayName}
</h2>
<p className="text-sm text-muted-foreground">
{planModels.length} {t('planModelsCount')}
</p>
</div>
</div>
{planModels.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-2">{t('noModelsConfigured')}</p>
<p className="text-xs text-muted-foreground">
Select this plan above and fetch models to get started
</p>
</div>
) : (
<div className="space-y-3">
{planModels.map(model => (
<div
key={model.id}
className="border border-border rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="font-medium text-foreground">{model.name}</h3>
<Badge variant="secondary" className="text-xs">
{model.provider}
</Badge>
<Badge
variant={model.isActive ? "default" : "outline"}
className="text-xs"
>
{model.isActive ? t('modelEnabled') : t('modelDisabled')}
</Badge>
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<span className="flex items-center">
<Cpu className="h-3 w-3 mr-1" />
{model.modelId}
</span>
{model.maxTokens && (
<span className="flex items-center">
<Zap className="h-3 w-3 mr-1" />
{model.maxTokens.toLocaleString()} tokens
</span>
)}
{model.inputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.inputCostPer1k.toFixed(4)}/1K in
</span>
)}
{model.outputCostPer1k && (
<span className="flex items-center">
<DollarSign className="h-3 w-3 mr-1" />
${model.outputCostPer1k.toFixed(4)}/1K out
</span>
)}
</div>
{model.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{model.description}
</p>
)}
</div>
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => toggleModelStatus(model.id, model.isActive)}
>
{model.isActive ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => removeModel(model.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
)
})}
</div>
</div>
)
}

View File

@ -3,7 +3,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Users, FileText, Share, CheckCircle, Database } from 'lucide-react' import { Users, FileText, Share, CheckCircle, Database, Cpu } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
interface AdminStats { interface AdminStats {
@ -154,6 +154,25 @@ export default function AdminDashboard() {
</div> </div>
</Link> </Link>
<Link
href="/admin/models"
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-purple-50 dark:bg-purple-900/30 group-hover:bg-purple-100 dark:group-hover:bg-purple-900/50 transition-colors">
<Cpu className="h-4 w-4 text-purple-600" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground group-hover:text-purple-600 transition-colors">
AI Models Config
</div>
<div className="text-sm text-muted-foreground mt-0.5">
Manage AI models and plan configurations
</div>
</div>
</div>
</Link>
<Link <Link
href="/debug/cache" href="/debug/cache"
className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200" className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"

View File

@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { OpenRouterService } from '@/lib/openrouter'
// GET /api/admin/models - 获取所有模型(按套餐分组)
export async function GET() {
try {
const models = await prisma.model.findMany({
include: {
subscriptionPlan: {
select: {
id: true,
name: true,
displayName: true
}
}
},
orderBy: [
{ subscriptionPlan: { sortOrder: 'asc' } },
{ provider: 'asc' },
{ name: 'asc' }
]
})
return NextResponse.json({ models })
} catch (error) {
console.error('Error fetching models:', error)
return NextResponse.json(
{ error: 'Failed to fetch models' },
{ status: 500 }
)
}
}
// POST /api/admin/models - 为指定套餐添加模型
export async function POST(request: NextRequest) {
try {
const { action, planId, selectedModels } = await request.json()
if (action === 'sync') {
if (!planId) {
return NextResponse.json(
{ error: 'Plan ID is required for sync action' },
{ status: 400 }
)
}
// 验证套餐是否存在
const plan = await prisma.subscriptionPlan.findUnique({
where: { id: planId }
})
if (!plan) {
return NextResponse.json(
{ error: 'Subscription plan not found' },
{ status: 404 }
)
}
// 从 OpenRouter 获取可用模型
const openRouterService = new OpenRouterService()
const openRouterModels = await openRouterService.getAvailableModels()
return NextResponse.json({
message: 'Models fetched successfully',
availableModels: openRouterModels.map(model =>
openRouterService.transformModelForDB(model)
),
planId
})
}
if (action === 'add') {
if (!planId || !selectedModels || !Array.isArray(selectedModels)) {
return NextResponse.json(
{ error: 'Plan ID and selected models are required' },
{ status: 400 }
)
}
// 批量创建模型记录
const results = []
for (const modelData of selectedModels) {
const result = await prisma.model.upsert({
where: {
subscriptionPlanId_modelId: {
subscriptionPlanId: planId,
modelId: modelData.modelId
}
},
update: {
...modelData,
subscriptionPlanId: planId,
updatedAt: new Date()
},
create: {
...modelData,
subscriptionPlanId: planId
},
include: {
subscriptionPlan: true
}
})
results.push(result)
}
return NextResponse.json({
message: `Successfully added ${results.length} models to ${planId} plan`,
models: results
})
}
return NextResponse.json(
{ error: 'Invalid action' },
{ status: 400 }
)
} catch (error) {
console.error('Error handling models:', error)
return NextResponse.json(
{ error: 'Failed to process models request' },
{ status: 500 }
)
}
}
// PUT /api/admin/models - 更新模型状态
export async function PUT(request: NextRequest) {
try {
const { modelId, isActive } = await request.json()
if (!modelId) {
return NextResponse.json(
{ error: 'Model ID is required' },
{ status: 400 }
)
}
const model = await prisma.model.update({
where: { id: modelId },
data: { isActive },
include: {
subscriptionPlan: true
}
})
return NextResponse.json({ model })
} catch (error) {
console.error('Error updating model:', error)
return NextResponse.json(
{ error: 'Failed to update model' },
{ status: 500 }
)
}
}
// DELETE /api/admin/models - 删除模型
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const modelId = searchParams.get('id')
if (!modelId) {
return NextResponse.json(
{ error: 'Model ID is required' },
{ status: 400 }
)
}
await prisma.model.delete({
where: { id: modelId }
})
return NextResponse.json({ message: 'Model deleted successfully' })
} catch (error) {
console.error('Error deleting model:', error)
return NextResponse.json(
{ error: 'Failed to delete model' },
{ status: 500 }
)
}
}

104
src/lib/openrouter.ts Normal file
View File

@ -0,0 +1,104 @@
interface OpenRouterModel {
id: string
name: string
description?: string
context_length: number
architecture: {
modality: string
tokenizer: string
instruct_type?: string
}
pricing: {
prompt: string
completion: string
request?: string
image?: string
}
top_provider: {
context_length: number
max_completion_tokens?: number
is_moderated: boolean
}
per_request_limits?: {
prompt_tokens: string
completion_tokens: string
}
}
interface OpenRouterResponse {
data: OpenRouterModel[]
}
export class OpenRouterService {
private apiKey: string
private baseUrl = 'https://openrouter.ai/api/v1'
constructor() {
this.apiKey = process.env.OPENROUTER_API_KEY || ''
if (!this.apiKey) {
throw new Error('OPENROUTER_API_KEY environment variable is required')
}
}
async getAvailableModels(): Promise<OpenRouterModel[]> {
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`)
}
const data: OpenRouterResponse = await response.json()
return data.data || []
} catch (error) {
console.error('Error fetching OpenRouter models:', error)
throw error
}
}
// 将 OpenRouter 模型转换为我们数据库的格式
transformModelForDB(model: OpenRouterModel) {
return {
modelId: model.id,
name: model.name,
provider: this.extractProvider(model.id),
description: model.description || null,
maxTokens: model.context_length || null,
inputCostPer1k: parseFloat(model.pricing.prompt) * 1000 || null,
outputCostPer1k: parseFloat(model.pricing.completion) * 1000 || null,
supportedFeatures: {
modality: model.architecture.modality,
tokenizer: model.architecture.tokenizer,
instruct_type: model.architecture.instruct_type,
is_moderated: model.top_provider.is_moderated,
max_completion_tokens: model.top_provider.max_completion_tokens,
},
metadata: {
per_request_limits: model.per_request_limits,
architecture: model.architecture,
top_provider: model.top_provider,
},
}
}
private extractProvider(modelId: string): string {
// 从模型 ID 中提取提供商名称,如 "openai/gpt-4" -> "OpenAI"
const providerMap: Record<string, string> = {
'openai': 'OpenAI',
'anthropic': 'Anthropic',
'google': 'Google',
'meta-llama': 'Meta',
'microsoft': 'Microsoft',
'mistralai': 'Mistral AI',
'cohere': 'Cohere',
}
const provider = modelId.split('/')[0] || 'Unknown'
return providerMap[provider] || provider.charAt(0).toUpperCase() + provider.slice(1)
}
}