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

+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="测试">