Đà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 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 [discoveredGoogleModels, setDiscoveredGoogleModels] = useState<string[] | null>(null)
58
- const [discoveringGoogle, setDiscoveringGoogle] = useState(false)
59
- const [discoverError, setDiscoverError] = useState<string | null>(null)
60
  const isAutoModel = model === "auto"
61
  const providerLabel = provider ? PROVIDERS[provider].label : ""
62
 
63
- async function discoverGoogleModels() {
64
- setDiscoveringGoogle(true)
65
- setDiscoverError(null)
66
  try {
67
- const res = await fetch("/api/google/models", {
 
 
68
  method: "POST",
69
  headers: { "Content-Type": "application/json" },
70
- body: JSON.stringify({ userKey: userKeys?.gemini ?? "" }),
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
- setDiscoveredGoogleModels(ids)
76
  } catch (err) {
77
- setDiscoverError(err instanceof Error ? err.message : String(err))
78
  } finally {
79
- setDiscoveringGoogle(false)
80
  }
81
  }
82
 
@@ -129,11 +131,14 @@ export function ModelPicker({
129
  </>
130
  )}
131
  {Object.entries(PROVIDERS).map(([pKey, p]) => {
132
- const isGoogle = pKey === "google"
 
133
  const modelList: readonly string[] =
134
- isGoogle && discoveredGoogleModels && discoveredGoogleModels.length > 0
135
- ? Array.from(new Set([...p.models, ...discoveredGoogleModels]))
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(pKey as ProviderKey, m)}
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
- {isGoogle && (
158
- <DropdownMenuItem
159
- onSelect={(e) => {
160
- e.preventDefault()
161
- discoverGoogleModels()
162
- }}
163
- className="text-[11px] text-muted-foreground"
164
- disabled={discoveringGoogle}
165
- >
166
- {discoveringGoogle
167
- ? "Đang quét..."
168
- : discoveredGoogleModels
169
- ? `↻ Quét lại (${discoveredGoogleModels.length} model)`
170
- : "↻ Quét live model có sẵn với key của bạn"}
171
- </DropdownMenuItem>
172
- )}
173
- {isGoogle && discoverError && (
174
  <div className="px-2 py-1 text-[10px] text-destructive break-words [overflow-wrap:anywhere]">
175
- {discoverError}
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 />