Skip to content

Commit eccdbac

Browse files
authored
implement dropdown when hf inference provider is selected. (#14)
* implement dropdown when hf inference provider is selected. * address feedback
1 parent bd8ac2f commit eccdbac

7 files changed

Lines changed: 330 additions & 1 deletion

File tree

docs/web-app.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ The most-specific matching config wins when a review is submitted.
5959
1. Open the New Review page.
6060
2. Enter a PR URL or `owner/repo#123`.
6161
3. Enter a trigger comment, for example `@askserge please review`.
62-
4. Pick the provider and model.
62+
4. Pick the provider and model. When the provider is Hugging Face, the model
63+
field becomes a dropdown of tool-capable models served by the
64+
[HF Inference Providers](https://router.huggingface.co) router; other
65+
providers keep a free-text model field.
6366
5. Start the review and watch the stream.
6467
6. Edit the summary and comments.
6568
7. Publish or discard the draft.

reviewbot/static/admin.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ <h2 id="form-title">Add a provider config</h2>
7373
<label for="default_model">Default model</label>
7474
<div class="hint">Used when the submit form leaves the model field empty.</div>
7575
<input type="text" id="default_model" name="default_model" placeholder="model name">
76+
<select id="default-model-select" style="display: none;"></select>
7677
</div>
7778
</div>
7879

reviewbot/static/admin.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
const apiKeyEl = document.getElementById("api_key");
1010
const apiKeyHint = document.getElementById("api-key-hint");
1111
const defaultModelEl = document.getElementById("default_model");
12+
const defaultModelSelectEl = document.getElementById("default-model-select");
1213
const repoPatternEl = document.getElementById("repo_pattern");
1314
const allowedUsersEl = document.getElementById("allowed_users");
1415
const allowedOrgsEl = document.getElementById("allowed_orgs");
@@ -51,6 +52,61 @@
5152
if (def) defaultModelEl.value = def;
5253
}
5354

55+
// HF Router model catalogue, lazily fetched once and cached. null until
56+
// loaded; [] when unreachable (we then keep the free-text input).
57+
let hfModels = null;
58+
59+
async function ensureHfModels() {
60+
if (hfModels !== null) return hfModels;
61+
try {
62+
const r = await fetch("/llm-options/hf-models");
63+
const data = r.ok ? await r.json() : {};
64+
hfModels = Array.isArray(data.models) ? data.models : [];
65+
} catch {
66+
hfModels = [];
67+
}
68+
return hfModels;
69+
}
70+
71+
function populateModelSelect() {
72+
// The text input stays the source of truth; the dropdown mirrors it.
73+
// A leading blank option keeps "no default" expressible (the submit
74+
// form's model then wins). Preserve any current value even if the
75+
// router doesn't list it.
76+
const current = defaultModelEl.value.trim();
77+
const models = [...(hfModels || [])];
78+
if (current && !models.includes(current)) models.unshift(current);
79+
defaultModelSelectEl.replaceChildren();
80+
const blank = document.createElement("option");
81+
blank.value = "";
82+
blank.textContent = "— none (use submit-form model) —";
83+
defaultModelSelectEl.appendChild(blank);
84+
for (const m of models) {
85+
const opt = document.createElement("option");
86+
opt.value = m;
87+
opt.textContent = m;
88+
defaultModelSelectEl.appendChild(opt);
89+
}
90+
defaultModelSelectEl.value = current;
91+
}
92+
93+
// Show a dropdown of HF Router models when the HF provider is selected;
94+
// other providers keep the free-text input. Falls back to the text input
95+
// when the model list can't be fetched.
96+
async function updateModelControl() {
97+
if (providerEl.value === "hf") {
98+
const models = await ensureHfModels();
99+
if (providerEl.value === "hf" && models.length) {
100+
populateModelSelect();
101+
defaultModelSelectEl.style.display = "";
102+
defaultModelEl.style.display = "none";
103+
return;
104+
}
105+
}
106+
defaultModelSelectEl.style.display = "none";
107+
defaultModelEl.style.display = "";
108+
}
109+
54110
function resetForm() {
55111
editing = null;
56112
configIdEl.value = "";
@@ -69,6 +125,7 @@
69125
submitBtn.textContent = "Save";
70126
cancelBtn.style.display = "none";
71127
updateProviderRow();
128+
updateModelControl();
72129
}
73130

74131
function startEdit(cfg) {
@@ -88,6 +145,7 @@
88145
submitBtn.textContent = "Save changes";
89146
cancelBtn.style.display = "";
90147
updateProviderRow();
148+
updateModelControl();
91149
window.scrollTo({ top: form.offsetTop - 16, behavior: "smooth" });
92150
}
93151

@@ -264,6 +322,11 @@
264322
if (!editing) {
265323
defaultModelEl.value = defaultModels[providerEl.value] || "";
266324
}
325+
updateModelControl();
326+
});
327+
328+
defaultModelSelectEl.addEventListener("change", () => {
329+
defaultModelEl.value = defaultModelSelectEl.value;
267330
});
268331

269332
resetForm();

reviewbot/static/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ <h2>Ask Serge to review a PR</h2>
5050
<div>
5151
<label for="llm-model">Model</label>
5252
<input type="text" id="llm-model" name="llm_model" required placeholder="model name">
53+
<select id="llm-model-select" style="display: none;"></select>
5354
</div>
5455
</div>
5556
<div id="custom-base-row" style="display: none;">

reviewbot/static/index.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
const commentEl = document.getElementById("comment");
77
const providerEl = document.getElementById("llm-provider");
88
const modelEl = document.getElementById("llm-model");
9+
const modelSelectEl = document.getElementById("llm-model-select");
910
const baseUrlEl = document.getElementById("llm-base-url");
1011
const customBaseRow = document.getElementById("custom-base-row");
1112
const providerHint = document.getElementById("provider-hint");
@@ -125,6 +126,66 @@
125126
modelEl.value = providerDefaultModels[providerEl.value] || "";
126127
}
127128

129+
// HF Router model catalogue, lazily fetched once and cached. null until
130+
// loaded; [] when the endpoint is unreachable (we then fall back to the
131+
// free-text input rather than an empty dropdown).
132+
let hfModels = null;
133+
134+
async function ensureHfModels() {
135+
if (hfModels !== null) return hfModels;
136+
try {
137+
const r = await fetch("/llm-options/hf-models");
138+
const data = r.ok ? await r.json() : {};
139+
hfModels = Array.isArray(data.models) ? data.models : [];
140+
} catch {
141+
hfModels = [];
142+
}
143+
return hfModels;
144+
}
145+
146+
function populateModelSelect() {
147+
// The text input stays the source of truth for the submitted value;
148+
// the dropdown mirrors it. Keep the current value selectable even if
149+
// the router doesn't list it (e.g. a config default not yet live), so
150+
// switching to HF never silently drops a configured model.
151+
const current = modelEl.value.trim();
152+
const options = [...(hfModels || [])];
153+
if (current && !options.includes(current)) options.unshift(current);
154+
modelSelectEl.replaceChildren();
155+
for (const m of options) {
156+
const opt = document.createElement("option");
157+
opt.value = m;
158+
opt.textContent = m;
159+
modelSelectEl.appendChild(opt);
160+
}
161+
if (current && options.includes(current)) {
162+
modelSelectEl.value = current;
163+
} else if (options.length) {
164+
modelSelectEl.value = options[0];
165+
modelEl.value = options[0];
166+
}
167+
}
168+
169+
// Show a dropdown of HF Router models when the HF provider is selected;
170+
// every other provider keeps the free-text input. Falls back to the text
171+
// input when the model list can't be fetched.
172+
async function updateModelControl() {
173+
if (providerEl.value === "hf") {
174+
const models = await ensureHfModels();
175+
// Guard against the provider changing while the fetch was in flight.
176+
if (providerEl.value === "hf" && models.length) {
177+
populateModelSelect();
178+
modelSelectEl.style.display = "";
179+
modelEl.style.display = "none";
180+
modelEl.required = false;
181+
return;
182+
}
183+
}
184+
modelSelectEl.style.display = "none";
185+
modelEl.style.display = "";
186+
modelEl.required = true;
187+
}
188+
128189
function ingestProviderDefaults(providers) {
129190
if (!Array.isArray(providers)) return;
130191
for (const p of providers) {
@@ -151,10 +212,12 @@
151212
applySavedLlmPrefs();
152213
applyProviderModelDefault();
153214
updateProviderFields();
215+
await updateModelControl();
154216
} catch {
155217
applySavedLlmPrefs();
156218
applyProviderModelDefault();
157219
updateProviderFields();
220+
await updateModelControl();
158221
}
159222
}
160223

@@ -276,6 +339,7 @@
276339
baseUrlEl.value = match.api_base;
277340
}
278341
updateProviderFields();
342+
updateModelControl();
279343
const modelPart = match.default_model ? ` · model ${match.default_model}` : "";
280344
const scope =
281345
match.repo_pattern && match.repo_pattern !== `${parsed.owner}/${parsed.repo}`
@@ -323,9 +387,14 @@
323387
providerEl.addEventListener("change", () => {
324388
updateProviderFields();
325389
resetModelToProviderDefault();
390+
updateModelControl();
326391
saveLlmPrefs();
327392
});
328393
modelEl.addEventListener("change", saveLlmPrefs);
394+
modelSelectEl.addEventListener("change", () => {
395+
modelEl.value = modelSelectEl.value;
396+
saveLlmPrefs();
397+
});
329398
baseUrlEl.addEventListener("change", saveLlmPrefs);
330399

331400
// Auto-fill provider/model from the matching DB config whenever the

reviewbot/webapp.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@
9797
_LLM_PROVIDER_ANTHROPIC: "claude-opus-4-6",
9898
}
9999

100+
# HF Inference Providers catalogue. The public router /v1/models endpoint
101+
# lists every model the HF Router can serve along with per-provider tool
102+
# support; we surface the tool-capable subset as a dropdown on the submit /
103+
# admin forms so users don't have to hand-type model ids. Cached in-process
104+
# (the list is large and changes slowly) and refreshed lazily on expiry.
105+
_HF_MODELS_URL = f"{_LLM_PROVIDER_BASES[_LLM_PROVIDER_HF]}/models"
106+
_HF_MODELS_TTL_SECONDS = 15 * 60
107+
_hf_models_lock = threading.Lock()
108+
_hf_models_cache: dict[str, Any] = {"fetched_at": 0.0, "models": []}
109+
100110

101111
# ---------------------------------------------------------------------------
102112
# Session handling: signed cookies via itsdangerous (no DB).
@@ -1343,6 +1353,72 @@ def _provider_entry(pid: str, label: str, base_url: str) -> dict:
13431353
)
13441354

13451355

1356+
def _tool_capable_hf_models(payload: Any) -> list[str]:
1357+
"""Extract the ids of HF Router models that can drive an agentic
1358+
review — at least one ``live`` provider advertising ``supports_tools``.
1359+
Serge's review loop relies on browse tools, so models that can't call
1360+
tools would degrade silently; we keep them out of the dropdown. Sorted
1361+
case-insensitively and de-duplicated."""
1362+
data = payload.get("data") if isinstance(payload, dict) else None
1363+
if not isinstance(data, list):
1364+
return []
1365+
ids: set[str] = set()
1366+
for entry in data:
1367+
if not isinstance(entry, dict):
1368+
continue
1369+
model_id = entry.get("id")
1370+
if not isinstance(model_id, str) or not model_id:
1371+
continue
1372+
providers = entry.get("providers")
1373+
if not isinstance(providers, list):
1374+
continue
1375+
if any(
1376+
isinstance(p, dict)
1377+
and p.get("status") == "live"
1378+
and p.get("supports_tools") is True
1379+
for p in providers
1380+
):
1381+
ids.add(model_id)
1382+
return sorted(ids, key=str.lower)
1383+
1384+
1385+
async def _fetch_hf_router_models() -> list[str]:
1386+
"""Tool-capable HF Router model ids, cached for ``_HF_MODELS_TTL_SECONDS``.
1387+
On a fetch error the last good list (possibly empty) is returned so a
1388+
transient router blip doesn't blank out the dropdown — the form falls
1389+
back to a free-text box when the list is empty."""
1390+
now = time.monotonic()
1391+
with _hf_models_lock:
1392+
# Use fetched_at (0.0 until the first successful fetch) rather than
1393+
# the list's truthiness to gauge freshness — a valid fetch that
1394+
# yields no tool-capable models is still a fetch worth caching, and
1395+
# keying on truthiness would re-hit the router on every call.
1396+
fetched_at = _hf_models_cache["fetched_at"]
1397+
if fetched_at > 0 and now - fetched_at < _HF_MODELS_TTL_SECONDS:
1398+
return _hf_models_cache["models"]
1399+
try:
1400+
async with httpx.AsyncClient(timeout=10) as client:
1401+
resp = await client.get(_HF_MODELS_URL)
1402+
resp.raise_for_status()
1403+
models = _tool_capable_hf_models(resp.json())
1404+
except (httpx.HTTPError, ValueError) as exc:
1405+
log.warning("Failed to fetch HF router models from %s: %s", _HF_MODELS_URL, exc)
1406+
with _hf_models_lock:
1407+
return _hf_models_cache["models"]
1408+
with _hf_models_lock:
1409+
_hf_models_cache["models"] = models
1410+
_hf_models_cache["fetched_at"] = now
1411+
return models
1412+
1413+
1414+
@app.get("/llm-options/hf-models")
1415+
async def hf_models(request: Request) -> JSONResponse:
1416+
"""Tool-capable models served by the HF Router, for the provider
1417+
dropdown that appears when ``hf`` is the selected provider."""
1418+
_require_user(request)
1419+
return JSONResponse({"models": await _fetch_hf_router_models()})
1420+
1421+
13461422
@app.get("/reviews/lookup-provider")
13471423
def lookup_provider(request: Request, owner: str, repo: str) -> JSONResponse:
13481424
"""Pre-flight match for the submit form: given a (owner, repo)

0 commit comments

Comments
 (0)