feat(pi): fetch and refresh upstream model lists, searchable model picker

- POST /pi/upstreams/{id}/models/refresh calls the provider's model list
  endpoint (OpenAI-shaped /models, Anthropic /v1/models, Google
  /v1beta/models, OpenRouter /api/v1/models; Radius via pi-ai refresh),
  normalizes {id, displayName, contextWindow, supportsVision,
  supportsTools, pricing} and stores it in pi_upstream.models (0044)
- GET .../models returns {source, fetchedAt, models[]}: remote merged
  with the static catalog (remote first); failures (401/network/
  unsupported) fall back to static with a one-line error
- POST /pi/models/probe for trying credentials before creating
- wizard and edit drawer: fetch button, searchable dropdown with
  vision/tool tags, manual id, source line; default upstream needs a model
- tests: mock /models, 401, network failure; smoke step; runbook

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:26:07 +08:00
1 parent e5abfa6217
commit 0765264a47
16 files changed
+1178 -52

No files matched your search

+18
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
PiModelsProbeRequest,
PiOAuthCompleteRequest,
PiOAuthCompleteResult,
PiOAuthStartRequest,
@@ -54,6 +55,23 @@ export function usePiUpstreamModels(id: string | null) {
});
}
/** 获取模型列表:用该上游的凭证调 provider 的模型列表接口;失败时响应里仍是当前列表(静态表)并带 error */
export function useRefreshPiModels() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiData<PiUpstreamModels>("/pi/upstreams/{id}/models/refresh", { method: "POST", pathParams: { id }, body: {} }),
onSuccess: (data, id) => qc.setQueryData(piKeys.models(id), data),
});
}
/** 新建上游前试拉模型列表(凭证只用这一次,不落库) */
export function useProbePiModels() {
return useMutation({
mutationFn: (body: PiModelsProbeRequest) => apiData<PiUpstreamModels>("/pi/models/probe", { method: "POST", body }),
});
}
function useInvalidatePi() {
const qc = useQueryClient();
return () => {
+180 -15
View File
@@ -1,6 +1,20 @@
import type { PiAuthKind, PiProviderGroup, PiProviderInfo, PiThinkingLevel, PiUpstream } from "@umt/contracts";
import type {
PiAuthKind,
PiListedModel,
PiModelInfo,
PiProviderGroup,
PiProviderInfo,
PiThinkingLevel,
PiUpstream,
PiUpstreamModels,
} from "@umt/contracts";
import { LoaderCircle, RefreshCw } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { cn } from "~/lib/utils.ts";
// Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游")
@@ -65,7 +79,24 @@ export function UpstreamStatusBadge({ u }: { u: Pick<PiUpstream, "status" | "aut
export const providerName = (providers: PiProviderInfo[] | undefined, id: string) =>
providers?.find((p) => p.id === id)?.name ?? id;
/** 模型 id:可从列表挑,也可手填(目录滞后于上游时) */
/** 静态目录(能力表的 PiModelInfo)转成列表项 */
export const listedFromInfo = (models: PiModelInfo[]): PiListedModel[] =>
models.map((m) => ({
id: m.id,
displayName: m.name && m.name !== m.id ? m.name : null,
contextWindow: m.contextWindow || null,
supportsVision: m.input.includes("image"),
supportsTools: null,
pricing: null,
origin: "static" as const,
}));
const TAG = "shrink-0 rounded-tight border border-border/60 px-1 text-meta text-muted-foreground";
const fmtContext = (n: number | null) =>
!n ? null : n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`;
/** 模型 id:可搜索的下拉(显示 id 与视觉 / 工具标记),也可手填(目录滞后于上游时) */
export function ModelInput({
id,
value,
@@ -78,31 +109,165 @@ export function ModelInput({
id: string;
value: string;
onChange: (v: string) => void;
models: { id: string; name: string }[];
models: PiListedModel[];
disabled?: boolean;
invalid?: boolean;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState<string | null>(null);
const [active, setActive] = useState(0);
const listId = `${id}-models`;
// 打开时先列全部;输入后按输入筛选(id 与显示名)
const q = (query ?? "").trim().toLowerCase();
const filtered = useMemo(
() =>
(q
? models.filter((m) => m.id.toLowerCase().includes(q) || m.displayName?.toLowerCase().includes(q))
: models
).slice(0, 200),
[models, q],
);
const pick = (m: PiListedModel) => {
onChange(m.id);
setOpen(false);
setQuery(null);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!open) setOpen(true);
const n = filtered.length;
if (n) setActive((a) => (e.key === "ArrowDown" ? (a + 1) % n : (a - 1 + n) % n));
} else if (e.key === "Enter" && open && filtered[active]) {
e.preventDefault();
pick(filtered[active]);
}
};
// 下拉开着时 Esc 只收起下拉:抽屉 / 对话框在 document 捕获阶段监听 Esc,这里在更早的 window 捕获阶段拦下
useEffect(() => {
if (!open) return;
const onEsc = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
setOpen(false);
};
window.addEventListener("keydown", onEsc, true);
return () => window.removeEventListener("keydown", onEsc, true);
}, [open]);
return (
<>
<div className="relative">
<Input
id={id}
list={listId}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && filtered[active] ? `${listId}-${active}` : undefined}
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
onChange(e.target.value);
setQuery(e.target.value);
setActive(0);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => {
setOpen(false);
setQuery(null);
}}
onKeyDown={onKeyDown}
disabled={disabled}
aria-invalid={invalid}
autoComplete="off"
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")}
spellCheck={false}
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中搜索,或手填 id` : "模型 id")}
/>
<datalist id={listId}>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</datalist>
</>
{open && !disabled && filtered.length ? (
<div
id={listId}
role="listbox"
className="absolute inset-x-0 top-full z-30 mt-1 max-h-72 overflow-auto pg-float pg-float-solid rounded-2xl p-1"
>
{filtered.map((m, i) => (
<div
key={m.id}
id={`${listId}-${i}`}
role="option"
tabIndex={-1}
aria-selected={m.id === value}
// mousedown 先于 input blur,阻止默认才能选中
onMouseDown={(e) => {
e.preventDefault();
pick(m);
}}
onMouseEnter={() => setActive(i)}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-caption",
i === active && "bg-foreground/[0.06]",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono">{m.id}</span>
{m.displayName ? (
<span className="block truncate text-meta text-muted-foreground">{m.displayName}</span>
) : null}
</span>
{fmtContext(m.contextWindow) ? (
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{fmtContext(m.contextWindow)}
</span>
) : null}
{m.supportsVision ? <span className={TAG}>视觉</span> : null}
{m.supportsTools ? <span className={TAG}>工具</span> : null}
</div>
))}
</div>
) : null}
</div>
);
}
/** 「获取模型列表」按钮 + 来源行(来源:远程 / 静态 · 更新时间;失败一句话) */
export function ModelFetchBar({
data,
pending,
onFetch,
disabledReason,
}: {
data: PiUpstreamModels | undefined;
pending: boolean;
onFetch: () => void;
/** 不能获取的原因(凭证没填等);有值时按钮禁用并显示它 */
disabledReason?: string | null;
}) {
const remote = data?.source === "remote";
const unsupported = data ? !data.remoteSupported : false;
const failed = data?.error && data.errorKind !== "unsupported" ? data.error : null;
return (
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{unsupported ? null : (
<Button type="button" variant="outline" size="xs" onClick={onFetch} disabled={pending || !!disabledReason}>
{pending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{remote ? "更新模型列表" : "获取模型列表"}
</Button>
)}
<span className="text-meta text-muted-foreground">
{disabledReason && !unsupported
? disabledReason
: remote
? `来源:远程 · ${formatRelative(data?.fetchedAt)}更新`
: unsupported
? "来源:静态(这个 provider 没有模型列表接口)"
: data
? "来源:静态"
: null}
</span>
{failed ? (
<span role="alert" className="basis-full text-meta text-destructive">
获取失败:{failed}
</span>
) : null}
</div>
);
}
@@ -7,6 +7,7 @@ import {
usePiProviders,
usePiUpstreamModels,
usePiUpstreams,
useRefreshPiModels,
useRemovePiUpstream,
useUpdatePiUpstream,
} from "~/api/pi.ts";
@@ -22,6 +23,7 @@ import { formatDateTime } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import {
AUTH_LABEL,
ModelFetchBar,
ModelInput,
providerName,
THINKING_LABEL,
@@ -113,6 +115,7 @@ function UpstreamDrawer({
const remove = useRemovePiUpstream();
const logout = useLogoutPiOAuth();
const models = usePiUpstreamModels(upstream?.id ?? null);
const refresh = useRefreshPiModels();
const [deleting, setDeleting] = useState(false);
const [v, setV] = useState(() => ({
name: upstream?.name ?? "",
@@ -139,6 +142,16 @@ function UpstreamDrawer({
v.enabled !== upstream.enabled ||
envFields.some((f) => (v.env[f.name] ?? "") !== (upstream.extra.env?.[f.name] ?? ""));
// 获取用已保存的配置:凭证或端点改了没保存时先保存
const fetchBlocked =
upstream.authKind !== "none" && !custom && !upstream.hasCredential
? upstream.authKind === "oauth"
? "登录后可获取"
: "先配置 API Key"
: v.apiKey.trim() || v.baseUrl !== (upstream.baseUrl ?? "")
? "先保存 API Key / Base URL 再获取"
: null;
const save = async (e: React.FormEvent) => {
e.preventDefault();
const env = Object.fromEntries(Object.entries(v.env).filter(([, x]) => x.trim()));
@@ -166,8 +179,16 @@ function UpstreamDrawer({
};
const makeDefault = async () => {
const model = v.model.trim();
if (!model) {
setErrors((e) => ({ ...e, defaultModel: "设为默认上游前先选默认模型" }));
return;
}
try {
await update.mutateAsync({ id: upstream.id, body: { makeDefault: true } });
await update.mutateAsync({
id: upstream.id,
body: { makeDefault: true, ...(model !== (upstream.defaultModel ?? "") ? { defaultModel: model } : {}) },
});
toast.success(`${upstream.name}已设为默认上游`);
} catch (err) {
toast.error(errorMessage(err, "设置失败"));
@@ -294,15 +315,27 @@ function UpstreamDrawer({
label="默认模型"
htmlFor="pu-model"
error={errors.defaultModel}
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
hint="可从列表选,也可手填"
className="sm:col-span-2"
>
<ModelFetchBar
data={models.data}
pending={refresh.isPending}
onFetch={() =>
refresh.mutate(upstream.id, { onError: (err) => toast.error(errorMessage(err, "获取失败")) })
}
disabledReason={fetchBlocked}
/>
<ModelInput
id="pu-model"
value={v.model}
onChange={(x) => setV((s) => ({ ...s, model: x }))}
models={models.data?.items ?? []}
onChange={(x) => {
setV((s) => ({ ...s, model: x }));
setErrors(({ defaultModel: _, ...rest }) => rest);
}}
models={models.data?.models ?? []}
disabled={pending}
invalid={!!errors.defaultModel}
/>
</FormField>
{showThinking ? (
+93 -15
View File
@@ -6,9 +6,16 @@ import {
PiUpstreamCreate,
} from "@umt/contracts";
import { ChevronLeft, LoaderCircle, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useCreatePiUpstream, usePiProviders, usePiUpstreamModels, useUpdatePiUpstream } from "~/api/pi.ts";
import {
useCreatePiUpstream,
usePiProviders,
usePiUpstreamModels,
useProbePiModels,
useRefreshPiModels,
useUpdatePiUpstream,
} from "~/api/pi.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
@@ -18,7 +25,16 @@ import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import { API_OPTIONS, AUTH_LABEL, GROUP_LABEL, GROUP_ORDER, ModelInput, THINKING_OPTIONS } from "./pi-shared.tsx";
import {
API_OPTIONS,
AUTH_LABEL,
GROUP_LABEL,
GROUP_ORDER,
listedFromInfo,
ModelFetchBar,
ModelInput,
THINKING_OPTIONS,
} from "./pi-shared.tsx";
import { PiTestPanel } from "./pi-test.tsx";
import { FieldControl } from "./resource-section.tsx";
@@ -30,6 +46,7 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
const [step, setStep] = useState<Step>("provider");
const [provider, setProvider] = useState<PiProviderInfo | null>(null);
const [created, setCreated] = useState<PiUpstream | null>(null);
const [fetched, setFetched] = useState(false);
const close = () => {
onClose();
@@ -69,13 +86,14 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
<ConfigStep
provider={provider}
onBack={() => setStep("provider")}
onCreated={(u) => {
onCreated={(u, f) => {
setCreated(u);
setFetched(f);
setStep("verify");
}}
/>
) : created ? (
<VerifyStep upstream={created} provider={provider} onDone={close} />
<VerifyStep upstream={created} provider={provider} autoFetch={fetched} onDone={close} />
) : null}
</DetailSheet>
);
@@ -138,14 +156,17 @@ function ConfigStep({
}: {
provider: PiProviderInfo;
onBack: () => void;
onCreated: (u: PiUpstream) => void;
/** fetched = 创建前已成功试拉过模型列表(下一步自动获取并存库) */
onCreated: (u: PiUpstream, fetched: boolean) => void;
}) {
const full = usePiProviders(true);
const create = useCreatePiUpstream();
const models = useMemo(
() => full.data?.providers.find((p) => p.id === provider.id)?.models ?? [],
const probe = useProbePiModels();
const staticModels = useMemo(
() => listedFromInfo(full.data?.providers.find((p) => p.id === provider.id)?.models ?? []),
[full.data, provider.id],
);
const models = probe.data?.models ?? staticModels;
const custom = provider.id === "openai-compatible";
const [authKind, setAuthKind] = useState<PiAuthKind>(provider.authKinds[0] ?? "none");
const [name, setName] = useState(provider.name);
@@ -158,6 +179,29 @@ function ConfigStep({
const [env, setEnv] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const showBaseUrl = provider.baseUrlOverridable && authKind !== "oauth";
// 凭证填好才能获取(订阅登录要先创建再登录,在下一步获取)
const fetchBlocked =
authKind === "oauth"
? "登录后在下一步获取"
: custom && !baseUrl.trim()
? "填好 Base URL 后可获取"
: authKind === "api_key" && !custom && !apiKey.trim()
? "填好 API Key 后可获取"
: null;
const fetchModels = async () => {
try {
const r = await probe.mutateAsync({
provider: provider.id,
authKind,
...(authKind === "api_key" && apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
baseUrl: showBaseUrl && baseUrl.trim() ? baseUrl.trim() : null,
...(custom ? { extra: { api: api as never } } : {}),
});
if (r.source === "remote") toast.success(`获取到 ${r.models.filter((m) => m.origin === "remote").length} 个模型`);
} catch (err) {
toast.error(errorMessage(err, "获取模型列表失败"));
}
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -190,7 +234,7 @@ function ConfigStep({
try {
const u = await create.mutateAsync(parsed.data);
toast.success(`${u.name}已创建`);
onCreated(u);
onCreated(u, probe.data?.source === "remote");
} catch (err) {
toast.error(errorMessage(err, "创建失败"));
}
@@ -301,6 +345,12 @@ function ConfigStep({
hint={custom ? "端点上的模型 id" : "留空用 provider 的默认模型"}
className="sm:col-span-2"
>
<ModelFetchBar
data={probe.data}
pending={probe.isPending}
onFetch={fetchModels}
disabledReason={fetchBlocked}
/>
<ModelInput
id="pw-model"
value={model}
@@ -338,19 +388,34 @@ function ConfigStep({
function VerifyStep({
upstream,
provider,
autoFetch,
onDone,
}: {
upstream: PiUpstream;
provider: PiProviderInfo | null;
autoFetch: boolean;
onDone: () => void;
}) {
const update = useUpdatePiUpstream();
const models = usePiUpstreamModels(upstream.id);
const refresh = useRefreshPiModels();
// 创建前试拉成功过:创建后用已存的凭证再拉一次并存库(只触发一次)
const autoFetched = useRef(false);
useEffect(() => {
if (!autoFetch || autoFetched.current) return;
autoFetched.current = true;
refresh.mutate(upstream.id);
}, [autoFetch, upstream.id, refresh.mutate]);
const [model, setModel] = useState(upstream.defaultModel ?? "");
const [modelError, setModelError] = useState<string | undefined>();
const [makeDefault, setMakeDefault] = useState(upstream.isDefault);
const [loggedIn, setLoggedIn] = useState(upstream.authKind !== "oauth");
const finish = async () => {
if (makeDefault && !model.trim()) {
setModelError("设为默认上游前先选默认模型");
return;
}
const body = {
...(model.trim() !== (upstream.defaultModel ?? "") ? { defaultModel: model.trim() || null } : {}),
...(makeDefault && !upstream.isDefault ? { makeDefault: true } : {}),
@@ -372,12 +437,25 @@ function VerifyStep({
</DetailSection>
) : null}
<DetailSection title="模型">
<FormField
label="默认模型"
htmlFor="pv-model"
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
>
<ModelInput id="pv-model" value={model} onChange={setModel} models={models.data?.items ?? []} />
<FormField label="默认模型" htmlFor="pv-model" error={modelError} hint="可从列表选,也可手填">
<ModelFetchBar
data={models.data}
pending={refresh.isPending}
onFetch={() =>
refresh.mutate(upstream.id, { onError: (err) => toast.error(errorMessage(err, "获取失败")) })
}
disabledReason={loggedIn ? null : "登录后可获取"}
/>
<ModelInput
id="pv-model"
value={model}
onChange={(v) => {
setModel(v);
setModelError(undefined);
}}
models={models.data?.models ?? []}
invalid={!!modelError}
/>
</FormField>
</DetailSection>
<DetailSection title="测试">
+35
View File
@@ -215,6 +215,41 @@ async function main() {
if (SHOTS) await page.screenshot({ path: join(OUT, `${name}.png`), fullPage: true }).catch(() => {});
results.push({ path, ms: Date.now() - t0, problems: [...new Set(problems)] });
}
// ── 交互:Agent 上游抽屉的模型选择(PI-2;只看来源行与可搜索下拉,不点「获取」,避免拿真实凭证出站)──
{
const label = "/console/settings#agent → 上游抽屉 · 模型选择";
const up = await page.request.get(`${BASE}/api/v1/pi/upstreams`);
const first = up.ok() ? (await up.json().catch(() => null))?.data?.items?.[0] : null;
if (!first) skipped.push(`${label}(没有上游)`);
else {
problems = [];
const t0 = Date.now();
try {
await page.goto("about:blank");
await page.goto(`${BASE}/console/settings#agent`, { waitUntil: "networkidle", timeout: 30_000 });
await page.getByText(first.name, { exact: true }).first().click();
const input = page.locator("#pu-model");
await input.waitFor({ timeout: 10_000 });
await page
.getByText(/^来源:/)
.first()
.waitFor({ timeout: 10_000 });
const models = (await page.request.get(`${BASE}/api/v1/pi/upstreams/${first.id}/models`).then((r) => r.json()))
?.data?.models;
if (models?.length) {
await input.fill("");
await input.focus();
await page.locator('[role="listbox"] [role="option"]').first().waitFor({ timeout: 5_000 });
}
await page.keyboard.press("Escape");
} catch (err) {
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
}
if (SHOTS) await page.screenshot({ path: join(OUT, "settings_agent_upstream_models.png") }).catch(() => {});
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
}
}
// ── 旧链接重定向 ──
for (const r of REDIRECT_ROUTES) {
let from = r.from;