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

@@ -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 ? (