Spaces:
Runtime error
Runtime error
Đào Minh Tú commited on
Commit ·
0ea3919
1
Parent(s): eee0dd7
feat(model-picker): live discovery for Groq + GitHub Models (parallel to Google) — 'Quét live model' button per provider
Browse files- app/api/github/models/route.ts +43 -0
- app/api/groq/models/route.ts +44 -0
- components/model-picker.tsx +36 -33
app/api/github/models/route.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
export const runtime = "edge"
|
| 4 |
+
|
| 5 |
+
interface GitHubModel {
|
| 6 |
+
id: string
|
| 7 |
+
name: string
|
| 8 |
+
friendly_name?: string
|
| 9 |
+
publisher?: string
|
| 10 |
+
task?: string
|
| 11 |
+
summary?: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export async function POST(req: Request) {
|
| 15 |
+
try {
|
| 16 |
+
const body = await req.json().catch(() => ({}))
|
| 17 |
+
const userKey = typeof body?.userKey === "string" ? body.userKey.trim() : ""
|
| 18 |
+
const apiKey = userKey || process.env.GITHUB_TOKEN
|
| 19 |
+
if (!apiKey) {
|
| 20 |
+
return NextResponse.json({ error: "Thiếu GITHUB_TOKEN. Cấu hình ở Cài đặt API keys." }, { status: 400 })
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const res = await fetch("https://models.inference.ai.azure.com/models", {
|
| 24 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 25 |
+
cache: "no-store",
|
| 26 |
+
})
|
| 27 |
+
if (!res.ok) {
|
| 28 |
+
const text = await res.text()
|
| 29 |
+
return NextResponse.json({ error: `GitHub Models API ${res.status}: ${text.slice(0, 300)}` }, { status: 502 })
|
| 30 |
+
}
|
| 31 |
+
const all = (await res.json()) as GitHubModel[]
|
| 32 |
+
|
| 33 |
+
// Chỉ giữ chat-completion. Bỏ embedding / image / audio / rerank.
|
| 34 |
+
const chatModels = (Array.isArray(all) ? all : [])
|
| 35 |
+
.filter((m) => m.task === "chat-completion")
|
| 36 |
+
.map((m) => ({ id: m.name, friendlyName: m.friendly_name, publisher: m.publisher, summary: m.summary }))
|
| 37 |
+
.sort((a, b) => a.id.localeCompare(b.id))
|
| 38 |
+
|
| 39 |
+
return NextResponse.json({ models: chatModels, totalRaw: Array.isArray(all) ? all.length : 0 })
|
| 40 |
+
} catch (err) {
|
| 41 |
+
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 })
|
| 42 |
+
}
|
| 43 |
+
}
|
app/api/groq/models/route.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
export const runtime = "edge"
|
| 4 |
+
|
| 5 |
+
interface GroqModel {
|
| 6 |
+
id: string
|
| 7 |
+
context_window?: number
|
| 8 |
+
active?: boolean
|
| 9 |
+
owned_by?: string
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export async function POST(req: Request) {
|
| 13 |
+
try {
|
| 14 |
+
const body = await req.json().catch(() => ({}))
|
| 15 |
+
const userKey = typeof body?.userKey === "string" ? body.userKey.trim() : ""
|
| 16 |
+
const apiKey = userKey || process.env.GROQ_API_KEY
|
| 17 |
+
if (!apiKey) {
|
| 18 |
+
return NextResponse.json({ error: "Thiếu GROQ_API_KEY. Cấu hình ở Cài đặt API keys." }, { status: 400 })
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const res = await fetch("https://api.groq.com/openai/v1/models", {
|
| 22 |
+
headers: { Authorization: `Bearer ${apiKey}` },
|
| 23 |
+
cache: "no-store",
|
| 24 |
+
})
|
| 25 |
+
if (!res.ok) {
|
| 26 |
+
const text = await res.text()
|
| 27 |
+
return NextResponse.json({ error: `Groq API ${res.status}: ${text.slice(0, 300)}` }, { status: 502 })
|
| 28 |
+
}
|
| 29 |
+
const data = (await res.json()) as { data?: GroqModel[] }
|
| 30 |
+
const all = data.data ?? []
|
| 31 |
+
|
| 32 |
+
// Loại whisper (speech-to-text), TTS, vision-only, guard models — chỉ giữ chat LLM.
|
| 33 |
+
const EXCLUDE = /(whisper|tts|guard|moderation|prompt-guard)/i
|
| 34 |
+
const chatModels = all
|
| 35 |
+
.filter((m) => m.active !== false)
|
| 36 |
+
.filter((m) => !EXCLUDE.test(m.id))
|
| 37 |
+
.map((m) => ({ id: m.id, contextWindow: m.context_window, ownedBy: m.owned_by }))
|
| 38 |
+
.sort((a, b) => a.id.localeCompare(b.id))
|
| 39 |
+
|
| 40 |
+
return NextResponse.json({ models: chatModels, totalRaw: all.length })
|
| 41 |
+
} catch (err) {
|
| 42 |
+
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 })
|
| 43 |
+
}
|
| 44 |
+
}
|
components/model-picker.tsx
CHANGED
|
@@ -54,29 +54,31 @@ export function ModelPicker({
|
|
| 54 |
size = "sm",
|
| 55 |
disabled,
|
| 56 |
}: ModelPickerProps) {
|
| 57 |
-
const [
|
| 58 |
-
const [
|
| 59 |
-
const [discoverError, setDiscoverError] = useState<
|
| 60 |
const isAutoModel = model === "auto"
|
| 61 |
const providerLabel = provider ? PROVIDERS[provider].label : ""
|
| 62 |
|
| 63 |
-
async function
|
| 64 |
-
|
| 65 |
-
setDiscoverError(
|
| 66 |
try {
|
| 67 |
-
const
|
|
|
|
|
|
|
| 68 |
method: "POST",
|
| 69 |
headers: { "Content-Type": "application/json" },
|
| 70 |
-
body: JSON.stringify({ userKey:
|
| 71 |
})
|
| 72 |
const data = await res.json()
|
| 73 |
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`)
|
| 74 |
const ids = (data.models as Array<{ id: string }>).map((m) => m.id)
|
| 75 |
-
|
| 76 |
} catch (err) {
|
| 77 |
-
setDiscoverError(err instanceof Error ? err.message : String(err))
|
| 78 |
} finally {
|
| 79 |
-
|
| 80 |
}
|
| 81 |
}
|
| 82 |
|
|
@@ -129,11 +131,14 @@ export function ModelPicker({
|
|
| 129 |
</>
|
| 130 |
)}
|
| 131 |
{Object.entries(PROVIDERS).map(([pKey, p]) => {
|
| 132 |
-
const
|
|
|
|
| 133 |
const modelList: readonly string[] =
|
| 134 |
-
|
| 135 |
-
? Array.from(new Set([...p.models, ...
|
| 136 |
: p.models
|
|
|
|
|
|
|
| 137 |
return (
|
| 138 |
<div key={pKey}>
|
| 139 |
<DropdownMenuLabel className="flex items-center justify-between">
|
|
@@ -146,7 +151,7 @@ export function ModelPicker({
|
|
| 146 |
return (
|
| 147 |
<DropdownMenuItem
|
| 148 |
key={m}
|
| 149 |
-
onClick={() => onChange(
|
| 150 |
className={cn("font-mono text-xs", (selected || onlyByModel) && "bg-accent")}
|
| 151 |
>
|
| 152 |
<span className="flex-1 break-all">{m}</span>
|
|
@@ -154,25 +159,23 @@ export function ModelPicker({
|
|
| 154 |
</DropdownMenuItem>
|
| 155 |
)
|
| 156 |
})}
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
)}
|
| 173 |
-
{isGoogle && discoverError && (
|
| 174 |
<div className="px-2 py-1 text-[10px] text-destructive break-words [overflow-wrap:anywhere]">
|
| 175 |
-
{
|
| 176 |
</div>
|
| 177 |
)}
|
| 178 |
<DropdownMenuSeparator />
|
|
|
|
| 54 |
size = "sm",
|
| 55 |
disabled,
|
| 56 |
}: ModelPickerProps) {
|
| 57 |
+
const [discovered, setDiscovered] = useState<Partial<Record<ProviderKey, string[]>>>({})
|
| 58 |
+
const [discovering, setDiscovering] = useState<ProviderKey | null>(null)
|
| 59 |
+
const [discoverError, setDiscoverError] = useState<Partial<Record<ProviderKey, string>>>({})
|
| 60 |
const isAutoModel = model === "auto"
|
| 61 |
const providerLabel = provider ? PROVIDERS[provider].label : ""
|
| 62 |
|
| 63 |
+
async function discoverModels(p: ProviderKey) {
|
| 64 |
+
setDiscovering(p)
|
| 65 |
+
setDiscoverError((prev) => ({ ...prev, [p]: undefined }))
|
| 66 |
try {
|
| 67 |
+
const userKey =
|
| 68 |
+
p === "google" ? userKeys?.gemini : p === "groq" ? userKeys?.groq : userKeys?.github
|
| 69 |
+
const res = await fetch(`/api/${p}/models`, {
|
| 70 |
method: "POST",
|
| 71 |
headers: { "Content-Type": "application/json" },
|
| 72 |
+
body: JSON.stringify({ userKey: userKey ?? "" }),
|
| 73 |
})
|
| 74 |
const data = await res.json()
|
| 75 |
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`)
|
| 76 |
const ids = (data.models as Array<{ id: string }>).map((m) => m.id)
|
| 77 |
+
setDiscovered((prev) => ({ ...prev, [p]: ids }))
|
| 78 |
} catch (err) {
|
| 79 |
+
setDiscoverError((prev) => ({ ...prev, [p]: err instanceof Error ? err.message : String(err) }))
|
| 80 |
} finally {
|
| 81 |
+
setDiscovering(null)
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
|
|
|
| 131 |
</>
|
| 132 |
)}
|
| 133 |
{Object.entries(PROVIDERS).map(([pKey, p]) => {
|
| 134 |
+
const pTyped = pKey as ProviderKey
|
| 135 |
+
const discoveredIds = discovered[pTyped]
|
| 136 |
const modelList: readonly string[] =
|
| 137 |
+
discoveredIds && discoveredIds.length > 0
|
| 138 |
+
? Array.from(new Set([...p.models, ...discoveredIds]))
|
| 139 |
: p.models
|
| 140 |
+
const isDiscovering = discovering === pTyped
|
| 141 |
+
const errMsg = discoverError[pTyped]
|
| 142 |
return (
|
| 143 |
<div key={pKey}>
|
| 144 |
<DropdownMenuLabel className="flex items-center justify-between">
|
|
|
|
| 151 |
return (
|
| 152 |
<DropdownMenuItem
|
| 153 |
key={m}
|
| 154 |
+
onClick={() => onChange(pTyped, m)}
|
| 155 |
className={cn("font-mono text-xs", (selected || onlyByModel) && "bg-accent")}
|
| 156 |
>
|
| 157 |
<span className="flex-1 break-all">{m}</span>
|
|
|
|
| 159 |
</DropdownMenuItem>
|
| 160 |
)
|
| 161 |
})}
|
| 162 |
+
<DropdownMenuItem
|
| 163 |
+
onSelect={(e) => {
|
| 164 |
+
e.preventDefault()
|
| 165 |
+
discoverModels(pTyped)
|
| 166 |
+
}}
|
| 167 |
+
className="text-[11px] text-muted-foreground"
|
| 168 |
+
disabled={isDiscovering}
|
| 169 |
+
>
|
| 170 |
+
{isDiscovering
|
| 171 |
+
? "Đang quét..."
|
| 172 |
+
: discoveredIds
|
| 173 |
+
? `↻ Quét lại (${discoveredIds.length} model)`
|
| 174 |
+
: "↻ Quét live model có sẵn với key của bạn"}
|
| 175 |
+
</DropdownMenuItem>
|
| 176 |
+
{errMsg && (
|
|
|
|
|
|
|
| 177 |
<div className="px-2 py-1 text-[10px] text-destructive break-words [overflow-wrap:anywhere]">
|
| 178 |
+
{errMsg}
|
| 179 |
</div>
|
| 180 |
)}
|
| 181 |
<DropdownMenuSeparator />
|