Skip to content

Commit df4bbe5

Browse files
committed
feat(web): auto-discover models — connection-card datalist + refresh button
Backend already had GET /api/models proxying the endpoint's /v1/models; the SPA was ignoring it and forcing users to type the full model name. - api.ts: listModels({base_url, adapter, api_key_env}) returning ModelInfo[] (id / owned_by / created). - store.ts: availableModels / modelsLoading / modelsError / modelsKey state plus loadModels() with module-level token cancellation so rapid baseUrl edits don't race-write a stale list. - connection-card.tsx: - Mount-time + debounced (350ms) auto-fetch when baseUrl / adapter / apiKeyEnv settle, gated on s.hydrated so the very first paint hits the .env baseUrl rather than the static OpenAI default. - <datalist id="evalbox-model-options"> populated with the live list, attached via list= on the model input → native autocomplete in Chrome/Edge/Safari/Firefox. Free typing still works for gateways that don't expose /v1/models or that omit some served models (proxies in front of vLLM commonly do). - <ModelStatusPill> shows loading spinner / "{count} models" with a refresh button / amber error pill when /v1/models is missing. - i18n: ko + en strings for models_loading / models_count / models_refresh / models_error. Verified live against cliproxy.kch3dri4n.kr — 8 models discovered, error pill renders when baseUrl is bogus, restoring the URL re-fetches.
1 parent 7a9bdce commit df4bbe5

5 files changed

Lines changed: 160 additions & 7 deletions

File tree

web_src/src/components/connection-card.tsx

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22
import { useTranslation } from "react-i18next";
3-
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
3+
import { CheckCircle2, XCircle, Loader2, RefreshCw } from "lucide-react";
44

55
import { Badge } from "@/components/ui/badge";
66
import { Button } from "@/components/ui/button";
@@ -16,6 +16,22 @@ export function ConnectionCard({ compact = false }: { compact?: boolean }) {
1616
const [busy, setBusy] = useState(false);
1717
const [err, setErr] = useState<string | null>(null);
1818

19+
// Auto-fetch the model list whenever connection inputs settle (debounced).
20+
// This gives users a native datalist autocomplete instead of having to type
21+
// the full model name. Gateways that don't expose /v1/models surface an
22+
// error and we keep the input free-typing.
23+
useEffect(() => {
24+
if (!s.hydrated) return;
25+
if (compact) return;
26+
const baseUrl = s.baseUrl.trim();
27+
if (!baseUrl) return;
28+
const handle = setTimeout(() => {
29+
void s.loadModels();
30+
}, 350);
31+
return () => clearTimeout(handle);
32+
// eslint-disable-next-line react-hooks/exhaustive-deps
33+
}, [s.hydrated, s.baseUrl, s.adapter, s.apiKeyEnv, compact]);
34+
1935
async function test() {
2036
setBusy(true);
2137
setErr(null);
@@ -65,12 +81,32 @@ export function ConnectionCard({ compact = false }: { compact?: boolean }) {
6581
/>
6682
</div>
6783
<div className="space-y-1">
68-
<Label>{t("connection.model")}</Label>
84+
<Label className="flex items-center justify-between">
85+
<span>{t("connection.model")}</span>
86+
<ModelStatusPill />
87+
</Label>
6988
<Input
7089
value={s.model}
7190
placeholder={t("connection.model_placeholder")!}
91+
list="evalbox-model-options"
92+
autoComplete="off"
93+
spellCheck={false}
7294
onChange={(e) => s.setConnection({ model: e.target.value })}
95+
onFocus={() => {
96+
// Refresh on focus if we never loaded — covers the case where
97+
// hydration finished after the initial debounce window.
98+
if (s.availableModels.length === 0 && !s.modelsLoading) {
99+
void s.loadModels();
100+
}
101+
}}
73102
/>
103+
<datalist id="evalbox-model-options">
104+
{s.availableModels.map((m) => (
105+
<option key={m.id} value={m.id}>
106+
{m.owned_by ? `${m.owned_by}` : ""}
107+
</option>
108+
))}
109+
</datalist>
74110
</div>
75111
<div className="space-y-1">
76112
<Label>{t("connection.adapter")}</Label>
@@ -163,6 +199,50 @@ function CapabilityBadge({ ok, label }: { ok: boolean; label: string }) {
163199
);
164200
}
165201

202+
function ModelStatusPill() {
203+
const { t } = useTranslation();
204+
const loading = useApp((s) => s.modelsLoading);
205+
const error = useApp((s) => s.modelsError);
206+
const count = useApp((s) => s.availableModels.length);
207+
const loadModels = useApp((s) => s.loadModels);
208+
209+
if (loading) {
210+
return (
211+
<span className="inline-flex items-center gap-1 text-[0.7rem] font-normal text-muted-foreground">
212+
<Loader2 className="h-3 w-3 animate-spin" />
213+
{t("connection.models_loading")}
214+
</span>
215+
);
216+
}
217+
if (error) {
218+
return (
219+
<span
220+
className="text-[0.7rem] font-normal text-amber-500"
221+
title={error}
222+
>
223+
{t("connection.models_error")}
224+
</span>
225+
);
226+
}
227+
if (count > 0) {
228+
return (
229+
<span className="inline-flex items-center gap-1 text-[0.7rem] font-normal text-muted-foreground">
230+
{t("connection.models_count", { count })}
231+
<button
232+
type="button"
233+
onClick={() => void loadModels({ force: true })}
234+
className="inline-flex items-center text-muted-foreground/70 transition hover:text-foreground"
235+
title={t("connection.models_refresh")!}
236+
aria-label={t("connection.models_refresh")!}
237+
>
238+
<RefreshCw className="h-3 w-3" />
239+
</button>
240+
</span>
241+
);
242+
}
243+
return null;
244+
}
245+
166246
function hostOnly(url: string): string {
167247
try {
168248
return new URL(url).host;

web_src/src/i18n/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
"model_count": "{{count}} models exposed",
3333
"latency": "Dry call: {{ms}}ms",
3434
"thinking_observed": "Thinking observed in dry call",
35-
"learned_drop_params": "doctor learned drop_params: {{params}}"
35+
"learned_drop_params": "doctor learned drop_params: {{params}}",
36+
"models_loading": "Loading models…",
37+
"models_count": "{{count}} models",
38+
"models_refresh": "Refresh",
39+
"models_error": "This gateway doesn't expose /v1/models — type the model name"
3640
},
3741
"capability": {
3842
"title": "Capability",

web_src/src/i18n/ko.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
"model_count": "{{count}}개 모델 노출",
3333
"latency": "Dry call: {{ms}}ms",
3434
"thinking_observed": "Dry call 에서 thinking 감지됨",
35-
"learned_drop_params": "doctor 학습 drop_params: {{params}}"
35+
"learned_drop_params": "doctor 학습 drop_params: {{params}}",
36+
"models_loading": "모델 목록 불러오는 중…",
37+
"models_count": "{{count}}개 모델",
38+
"models_refresh": "새로고침",
39+
"models_error": "이 게이트웨이는 /v1/models 를 노출하지 않음 — 모델명 직접 입력"
3640
},
3741
"capability": {
3842
"title": "Capability",

web_src/src/lib/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,25 @@ export interface ServerDefaults {
9999
api_keys: Record<string, boolean>;
100100
}
101101

102+
export interface ModelInfo {
103+
id: string;
104+
owned_by: string | null;
105+
created: number | null;
106+
}
107+
102108
export const api = {
103109
health: () => request<{ status: string; version: string }>("/api/health"),
104110
defaults: () => request<ServerDefaults>("/api/defaults"),
105111
benchmarks: () => request<BenchmarkInfo[]>("/api/benchmarks"),
112+
listModels: (
113+
args: { base_url: string; adapter?: string; api_key_env?: string },
114+
init?: RequestInit,
115+
) => {
116+
const qs = new URLSearchParams({ base_url: args.base_url });
117+
if (args.adapter) qs.set("adapter", args.adapter);
118+
if (args.api_key_env) qs.set("api_key_env", args.api_key_env);
119+
return request<ModelInfo[]>(`/api/models?${qs.toString()}`, init);
120+
},
106121
testConnection: (req: ConnectionRequest) =>
107122
request<ConnectionResponse>("/api/connection/test", {
108123
method: "POST",

web_src/src/lib/store.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Global app state: connection config, selected benches, run lifecycle.
33

44
import { create } from "zustand";
5-
import type { CapabilityInfo, ConnectionResponse, ServerDefaults } from "./api";
5+
import { api } from "./api";
6+
import type { CapabilityInfo, ConnectionResponse, ModelInfo, ServerDefaults } from "./api";
67

78
export type Stage = "setup" | "running" | "results";
89

@@ -39,6 +40,17 @@ interface AppState {
3940
setConnResponse: (resp: ConnectionResponse) => void;
4041
hydrateFromServer: (d: ServerDefaults) => void;
4142

43+
// Model discovery (proxy to GET /v1/models on the configured endpoint)
44+
availableModels: ModelInfo[];
45+
modelsLoading: boolean;
46+
/** Last error from /api/models — most gateways without /v1/models surface
47+
a 502 here; we keep the input free-typing so users can still set a model. */
48+
modelsError: string | null;
49+
/** Tuple of inputs the last successful list was fetched for — lets us skip
50+
redundant fetches when the user types but hasn't changed the connection. */
51+
modelsKey: string | null;
52+
loadModels: (opts?: { force?: boolean }) => Promise<void>;
53+
4254
// Setup
4355
selectedBenches: Set<string>;
4456
toggleBench: (name: string) => void;
@@ -75,6 +87,11 @@ interface AppState {
7587
setFinalResult: (r: any | null) => void;
7688
}
7789

90+
// Module-level token so concurrent loadModels() calls cancel earlier in-flights.
91+
// (AbortController would also work; this is simpler since fetch() in api.ts
92+
// doesn't take a signal yet and we only need "ignore stale results".)
93+
let _modelsLoadToken = 0;
94+
7895
const detectDefaultBaseUrl = (): string => {
7996
// Try to read EVALBOX_BASE_URL via a meta tag if the host injected one.
8097
if (typeof document !== "undefined") {
@@ -84,7 +101,7 @@ const detectDefaultBaseUrl = (): string => {
84101
return "https://api.openai.com/v1";
85102
};
86103

87-
export const useApp = create<AppState>((set) => ({
104+
export const useApp = create<AppState>((set, get) => ({
88105
baseUrl: detectDefaultBaseUrl(),
89106
model: "gpt-4o-mini",
90107
adapter: "auto",
@@ -95,6 +112,39 @@ export const useApp = create<AppState>((set) => ({
95112
hasServerApiKey: false,
96113
serverApiKeys: {},
97114
hydrated: false,
115+
availableModels: [],
116+
modelsLoading: false,
117+
modelsError: null,
118+
modelsKey: null,
119+
loadModels: async (opts) => {
120+
const s = get();
121+
const baseUrl = s.baseUrl.trim();
122+
if (!baseUrl) {
123+
set({ availableModels: [], modelsError: null, modelsKey: null });
124+
return;
125+
}
126+
const key = `${baseUrl}|${s.adapter}|${s.apiKeyEnv}`;
127+
if (!opts?.force && key === s.modelsKey && s.availableModels.length > 0) return;
128+
const token = ++_modelsLoadToken;
129+
set({ modelsLoading: true, modelsError: null });
130+
try {
131+
const list = await api.listModels({
132+
base_url: baseUrl,
133+
adapter: s.adapter,
134+
api_key_env: s.apiKeyEnv,
135+
});
136+
if (token !== _modelsLoadToken) return; // stale
137+
set({ availableModels: list, modelsLoading: false, modelsKey: key });
138+
} catch (e: any) {
139+
if (token !== _modelsLoadToken) return;
140+
set({
141+
availableModels: [],
142+
modelsLoading: false,
143+
modelsError: e?.message?.slice(0, 200) || "list_models failed",
144+
modelsKey: null,
145+
});
146+
}
147+
},
98148
setConnection: (patch) =>
99149
set((prev) => {
100150
const next: Partial<AppState> = { ...patch };

0 commit comments

Comments
 (0)