- 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]>
274 lines
9.1 KiB
TypeScript
274 lines
9.1 KiB
TypeScript
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 上游")
|
|
|
|
export const GROUP_LABEL: Record<PiProviderGroup, string> = {
|
|
api_key: "API Key",
|
|
subscription: "订阅登录",
|
|
cloud: "云凭证",
|
|
custom: "兼容端点",
|
|
test: "测试",
|
|
};
|
|
export const GROUP_ORDER: PiProviderGroup[] = ["api_key", "subscription", "cloud", "custom", "test"];
|
|
|
|
export const AUTH_LABEL: Record<PiAuthKind, string> = {
|
|
api_key: "API Key",
|
|
oauth: "订阅登录",
|
|
none: "不需要凭证",
|
|
};
|
|
|
|
export const THINKING_LABEL: Record<PiThinkingLevel, string> = {
|
|
off: "关闭",
|
|
minimal: "最低",
|
|
low: "低",
|
|
medium: "中",
|
|
high: "高",
|
|
xhigh: "很高",
|
|
max: "最高",
|
|
};
|
|
export const THINKING_OPTIONS = (Object.keys(THINKING_LABEL) as PiThinkingLevel[]).map((v) => ({
|
|
value: v,
|
|
label: `${THINKING_LABEL[v]}(${v})`,
|
|
}));
|
|
|
|
export const API_OPTIONS = [
|
|
{ value: "openai-completions", label: "OpenAI Chat Completions" },
|
|
{ value: "openai-responses", label: "OpenAI Responses" },
|
|
{ value: "anthropic-messages", label: "Anthropic Messages" },
|
|
{ value: "google-generative-ai", label: "Google Generative AI" },
|
|
];
|
|
|
|
export function upstreamStatus(u: Pick<PiUpstream, "status" | "authKind" | "enabled">): {
|
|
label: string;
|
|
tone: StatusTone;
|
|
} {
|
|
if (!u.enabled) return { label: "已停用", tone: "neutral" };
|
|
switch (u.status) {
|
|
case "ok":
|
|
return { label: "可用", tone: "success" };
|
|
case "auth_required":
|
|
return { label: u.authKind === "oauth" ? "待登录" : "缺 API Key", tone: "warning" };
|
|
case "expired":
|
|
return { label: "登录已失效", tone: "destructive" };
|
|
case "error":
|
|
return { label: "出错", tone: "destructive" };
|
|
}
|
|
}
|
|
|
|
export function UpstreamStatusBadge({ u }: { u: Pick<PiUpstream, "status" | "authKind" | "enabled"> }) {
|
|
const s = upstreamStatus(u);
|
|
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
|
|
}
|
|
|
|
export const providerName = (providers: PiProviderInfo[] | undefined, id: string) =>
|
|
providers?.find((p) => p.id === id)?.name ?? 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,
|
|
onChange,
|
|
models,
|
|
disabled,
|
|
invalid,
|
|
placeholder,
|
|
}: {
|
|
id: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
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}
|
|
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);
|
|
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"
|
|
spellCheck={false}
|
|
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中搜索,或手填 id` : "模型 id")}
|
|
/>
|
|
{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>
|
|
);
|
|
}
|