Fix GPT-5 API compatibility and add AIUsageLog migration
Build and Push Docker Image / build (push) Successful in 8m50s Details

- Add AIUsageLog table migration for token tracking
- Fix GPT-5 temperature parameter (not supported, like o-series)
- Add usesNewTokenParam() and supportsTemperature() functions
- Add GPT-5+ category to model selection UI
- Update model sorting to show GPT-5+ first

GPT-5 and newer models use max_completion_tokens and don't support
custom temperature values, similar to reasoning models.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-03 15:04:16 +01:00
parent c0ce6f9f1f
commit 3986da172f
8 changed files with 423 additions and 41 deletions

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "AIUsageLog" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"model" TEXT NOT NULL,
"promptTokens" INTEGER NOT NULL,
"completionTokens" INTEGER NOT NULL,
"totalTokens" INTEGER NOT NULL,
"estimatedCostUsd" DECIMAL(10,6),
"batchSize" INTEGER,
"itemsProcessed" INTEGER,
"status" TEXT NOT NULL,
"errorMessage" TEXT,
"detailsJson" JSONB,
CONSTRAINT "AIUsageLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AIUsageLog_userId_idx" ON "AIUsageLog"("userId");
-- CreateIndex
CREATE INDEX "AIUsageLog_action_idx" ON "AIUsageLog"("action");
-- CreateIndex
CREATE INDEX "AIUsageLog_createdAt_idx" ON "AIUsageLog"("createdAt");
-- CreateIndex
CREATE INDEX "AIUsageLog_model_idx" ON "AIUsageLog"("model");

View File

@ -0,0 +1,232 @@
'use client'
import { use, useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
export default function EditAwardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const router = useRouter()
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
const updateAward = trpc.specialAward.update.useMutation()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [criteriaText, setCriteriaText] = useState('')
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
const [useAiEligibility, setUseAiEligibility] = useState(true)
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
// Load existing values when award data arrives
useEffect(() => {
if (award) {
setName(award.name)
setDescription(award.description || '')
setCriteriaText(award.criteriaText || '')
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
setUseAiEligibility(award.useAiEligibility)
setMaxRankedPicks(String(award.maxRankedPicks || 3))
}
}, [award])
const handleSubmit = async () => {
if (!name.trim()) return
try {
await updateAward.mutateAsync({
id: awardId,
name: name.trim(),
description: description.trim() || undefined,
criteriaText: criteriaText.trim() || undefined,
useAiEligibility,
scoringMode,
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
})
toast.success('Award updated')
router.push(`/admin/awards/${awardId}`)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update award'
)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-[400px] w-full" />
</div>
)
}
if (!award) return null
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/awards/${awardId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Award
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Edit Award
</h1>
<p className="text-muted-foreground">
Update award settings and eligibility criteria
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Award Details</CardTitle>
<CardDescription>
Configure the award name, criteria, and scoring mode
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Award Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Mediterranean Entrepreneurship Award"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this award"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="criteria">Eligibility Criteria</Label>
<Textarea
id="criteria"
value={criteriaText}
onChange={(e) => setCriteriaText(e.target.value)}
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
rows={4}
/>
<p className="text-xs text-muted-foreground">
This text will be used by AI to determine which projects are
eligible for this award.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="ai-toggle">AI Eligibility</Label>
<p className="text-xs text-muted-foreground">
Use AI to automatically evaluate project eligibility based on the criteria above.
Turn off for awards decided by feeling or manual selection.
</p>
</div>
<Switch
id="ai-toggle"
checked={useAiEligibility}
onCheckedChange={setUseAiEligibility}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="scoring">Scoring Mode</Label>
<Select
value={scoringMode}
onValueChange={(v) =>
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
}
>
<SelectTrigger id="scoring">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">
Pick Winner Each juror picks 1
</SelectItem>
<SelectItem value="RANKED">
Ranked Each juror ranks top N
</SelectItem>
<SelectItem value="SCORED">
Scored Use evaluation form
</SelectItem>
</SelectContent>
</Select>
</div>
{scoringMode === 'RANKED' && (
<div className="space-y-2">
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
<Input
id="maxPicks"
type="number"
min="1"
max="20"
value={maxRankedPicks}
onChange={(e) => setMaxRankedPicks(e.target.value)}
/>
</div>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
</Button>
<Button
onClick={handleSubmit}
disabled={updateAward.isPending || !name.trim()}
>
{updateAward.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
)
}

View File

@ -46,8 +46,8 @@ import {
UserPlus,
X,
Play,
Pause,
Lock,
Pencil,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@ -208,6 +208,12 @@ export default function AwardDetailPage({
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/awards/${awardId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{award.status === 'DRAFT' && (
<Button
variant="outline"

View File

@ -4,11 +4,13 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Bot, Loader2, Zap } from 'lucide-react'
import { Bot, Loader2, Zap, AlertCircle, RefreshCw, Brain } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import {
Form,
FormControl,
@ -21,7 +23,9 @@ import {
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
@ -60,6 +64,17 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
},
})
// Fetch available models from OpenAI API
const {
data: modelsData,
isLoading: modelsLoading,
error: modelsError,
refetch: refetchModels,
} = trpc.settings.listAIModels.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false,
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('AI settings saved successfully')
@ -73,7 +88,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
const testConnection = trpc.settings.testAIConnection.useMutation({
onSuccess: (result) => {
if (result.success) {
toast.success('AI connection successful')
toast.success(`AI connection successful! Model: ${result.model || result.modelTested}`)
// Refetch models after successful API key save/test
refetchModels()
} else {
toast.error(`Connection failed: ${result.error}`)
}
@ -99,6 +116,29 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
updateSettings.mutate({ settings: settingsToUpdate })
}
// Group models by category for better display
type ModelInfo = { id: string; name: string; isReasoning: boolean; category: string }
const groupedModels = modelsData?.models?.reduce<Record<string, ModelInfo[]>>(
(acc, model) => {
const category = model.category
if (!acc[category]) acc[category] = []
acc[category].push(model)
return acc
},
{}
)
const categoryLabels: Record<string, string> = {
'gpt-5+': 'GPT-5+ Series (Latest)',
'gpt-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series',
'gpt-3.5': 'GPT-3.5 Series',
reasoning: 'Reasoning Models (o1, o3, o4)',
other: 'Other Models',
}
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
@ -147,38 +187,6 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
/>
<FormField
control={form.control}
name="ai_model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gpt-4o">GPT-4o (Recommended)</SelectItem>
<SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem>
<SelectItem value="gpt-5">GPT-5</SelectItem>
<SelectItem value="gpt-5-mini">GPT-5 Mini</SelectItem>
<SelectItem value="o3">o3</SelectItem>
<SelectItem value="o3-mini">o3 Mini</SelectItem>
<SelectItem value="o4-mini">o4 Mini</SelectItem>
<SelectItem value="gpt-4-turbo">GPT-4 Turbo</SelectItem>
<SelectItem value="gpt-4">GPT-4</SelectItem>
</SelectContent>
</Select>
<FormDescription>
OpenAI model to use for AI features
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="openai_api_key"
@ -200,6 +208,88 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
/>
<FormField
control={form.control}
name="ai_model"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Model</FormLabel>
{modelsData?.success && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => refetchModels()}
className="h-6 px-2 text-xs"
>
<RefreshCw className="mr-1 h-3 w-3" />
Refresh
</Button>
)}
</div>
{modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsError || !modelsData?.success ? (
<div className="space-y-2">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{modelsError?.message || modelsData?.error || 'Failed to load models. Save your API key first and test the connection.'}
</AlertDescription>
</Alert>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="Enter model ID manually (e.g., gpt-4o)"
/>
</div>
) : (
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOrder
.filter((cat) => groupedModels?.[cat]?.length)
.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
{categoryLabels[category] || category}
</SelectLabel>
{groupedModels?.[category]?.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
{model.isReasoning && (
<Brain className="h-3 w-3 text-purple-500" />
)}
<span>{model.name}</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)}
<FormDescription>
{form.watch('ai_model')?.startsWith('o') ? (
<span className="flex items-center gap-1 text-purple-600">
<Brain className="h-3 w-3" />
Reasoning model - optimized for complex analysis tasks
</span>
) : (
'OpenAI model to use for AI features'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_send_descriptions"

View File

@ -25,6 +25,12 @@ const REASONING_MODEL_PREFIXES = ['o1', 'o3', 'o4']
*/
const NEW_TOKEN_PARAM_PREFIXES = ['o1', 'o3', 'o4', 'gpt-5', 'gpt-6', 'gpt-7']
/**
* Models that don't support custom temperature values.
* These only accept the default temperature (1).
*/
const NO_TEMPERATURE_PREFIXES = ['o1', 'o3', 'o4', 'gpt-5', 'gpt-6', 'gpt-7']
/**
* Check if a model is a reasoning model (o1, o3, o4 series)
* These models have additional restrictions (no temperature, no json_object, etc.)
@ -51,6 +57,19 @@ export function usesNewTokenParam(model: string): boolean {
)
}
/**
* Check if a model supports custom temperature values.
* Newer models (o-series, GPT-5+) only accept default temperature (1).
*/
export function supportsTemperature(model: string): boolean {
const modelLower = model.toLowerCase()
return !NO_TEMPERATURE_PREFIXES.some(prefix =>
modelLower.startsWith(prefix) ||
modelLower.includes(`/${prefix}`) ||
modelLower.includes(`-${prefix}`)
)
}
// ─── Chat Completion Parameter Builder ───────────────────────────────────────
type MessageRole = 'system' | 'user' | 'assistant' | 'developer'
@ -113,8 +132,8 @@ export function buildCompletionParams(
}
}
// Reasoning models don't support temperature
if (!isReasoning && options.temperature !== undefined) {
// Newer models (o-series, GPT-5+) don't support custom temperature
if (supportsTemperature(model) && options.temperature !== undefined) {
params.temperature = options.temperature
}

View File

@ -9,6 +9,8 @@ import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils
*/
function categorizeModel(modelId: string): string {
const id = modelId.toLowerCase()
// GPT-5+ series (newest models)
if (id.startsWith('gpt-5') || id.startsWith('gpt-6') || id.startsWith('gpt-7')) return 'gpt-5+'
if (id.startsWith('gpt-4o')) return 'gpt-4o'
if (id.startsWith('gpt-4')) return 'gpt-4'
if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
@ -220,11 +222,11 @@ export const settingsRouter = router({
category: categorizeModel(model),
}))
// Sort: GPT-4o first, then other GPT-4, then GPT-3.5, then reasoning models
// Sort: GPT-5+ first, then GPT-4o, then other GPT-4, then GPT-3.5, then reasoning models
const sorted = categorizedModels.sort((a, b) => {
const order = ['gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning']
const aOrder = order.findIndex(cat => a.category.startsWith(cat))
const bOrder = order.findIndex(cat => b.category.startsWith(cat))
const order = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
const aOrder = order.findIndex(cat => a.category === cat)
const bOrder = order.findIndex(cat => b.category === cat)
if (aOrder !== bOrder) return aOrder - bOrder
return a.id.localeCompare(b.id)
})