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
+1177 -51

No files matched your search

+471
View File
@@ -0,0 +1,471 @@
import {
classifyUpstreamError,
PiUpstreamClient,
providerInfo,
redactString,
type UpstreamCredential,
type UpstreamCredentialStore,
type UpstreamSpec,
} from "@umt/agent";
import type {
PiListedModel,
PiModelFetchErrorKind,
PiModelInfo,
PiModelsProbeRequest,
PiUpstreamModels,
} from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { clientFor } from "./service.ts";
import { dbCredentialStore, getRow, parseExtra, specOf } from "./store.ts";
// PI-2 上游模型列表:「获取模型列表」按 provider 调模型列表接口(用该上游的凭证;出站走进程的 HTTP(S)_PROXY),
// 归一化后存 pi_upstream.models + models_fetched_at。读取时与随包静态目录合并去重(同 id 远程优先,缺的字段用静态补)。
// pi-ai 0.87.1 只有 Radius 自带 refreshModels,其它 provider 按通用规则:
// OpenAI 兼容 GET {base}/models(Bearer);Anthropic GET /v1/models(x-api-key + anthropic-version);
// Google GET /v1beta/models?key=;OpenRouter GET /api/v1/models;订阅登录、云凭证等没有公开接口的 → 静态表。
type Shape = "openai" | "anthropic" | "google";
interface ListEndpoint {
shape: Shape;
url: URL;
headers: Record<string, string>;
}
type Plan = { kind: "http"; endpoint: ListEndpoint } | { kind: "pi" } | { kind: "unsupported"; reason: string };
class FetchError extends Error {
constructor(
readonly kind: PiModelFetchErrorKind,
message: string,
) {
super(message);
}
}
/** 模型列表接口是 OpenAI 形状(GET {base}/models)的内置 provider */
const OPENAI_SHAPED = new Set([
"ant-ling",
"baseten",
"cerebras",
"deepseek",
"fireworks",
"groq",
"huggingface",
"meta",
"mistral",
"moonshotai",
"moonshotai-cn",
"nvidia",
"openai",
"opencode",
"opencode-go",
"openrouter",
"qwen-token-plan",
"qwen-token-plan-cn",
"qwen-token-plan-individual",
"together",
"vercel-ai-gateway",
"xai",
"xiaomi",
"xiaomi-token-plan-ams",
"xiaomi-token-plan-cn",
"xiaomi-token-plan-sgp",
"zai",
"zai-coding-cn",
]);
const TIMEOUT_MS = 15_000;
/** 明显不是对话模型的 id(嵌入、语音、审核、重排),列表里去掉 */
const NON_CHAT = /embed|whisper|tts|dall-e|moderation|transcri|rerank/i;
const trimSlash = (u: string) => u.replace(/\/+$/, "");
/** base 以 /v1、/v4、/v1beta 这类版本段结尾 → {base}/models,否则 {base}/v1/models */
const openaiModelsUrl = (base: string) =>
new URL(/\/v\d+[a-z0-9]*$/i.test(trimSlash(base)) ? `${trimSlash(base)}/models` : `${trimSlash(base)}/v1/models`);
function keyOf(cred: UpstreamCredential | undefined): string | null {
if (cred?.type === "api_key") return cred.key || null;
if (cred?.type === "oauth") return cred.access || null;
return null;
}
/** 按 provider / 认证方式挑模型列表接口 */
function planFor(spec: UpstreamSpec, cred: UpstreamCredential | undefined): Plan {
const extra = spec.extra ?? {};
if (spec.provider === "fake") return { kind: "unsupported", reason: "Fake 上游没有远程模型列表" };
if (spec.provider === "radius") return { kind: "pi" };
const custom = spec.provider === "openai-compatible";
// 订阅登录只有 OpenRouter 换到的是普通 API Key,其它订阅 token 不能调公开的模型列表接口
if (spec.authKind === "oauth" && spec.provider !== "openrouter")
return { kind: "unsupported", reason: "订阅登录的上游没有公开的模型列表接口,用内置模型表" };
const key = keyOf(cred);
if (spec.authKind !== "none" && !key && !custom) throw new FetchError("auth", "还没有配置 API Key");
let shape: Shape | null = null;
if (custom) {
const api = extra.api ?? "openai-completions";
shape = api === "anthropic-messages" ? "anthropic" : api === "google-generative-ai" ? "google" : "openai";
} else if (spec.provider === "anthropic") shape = "anthropic";
else if (spec.provider === "google") shape = "google";
else if (OPENAI_SHAPED.has(spec.provider)) shape = "openai";
if (!shape) return { kind: "unsupported", reason: "这个 provider 没有模型列表接口,用内置模型表" };
const base = spec.baseUrl || providerInfo(spec.provider)?.defaultBaseUrl;
if (!base) throw new FetchError("config", "没有 Base URL");
const headers: Record<string, string> = { accept: "application/json", ...(custom ? (extra.headers ?? {}) : {}) };
let url: URL;
try {
if (shape === "anthropic") {
url = new URL(`${trimSlash(base).replace(/\/v1$/, "")}/v1/models`);
url.searchParams.set("limit", "1000");
if (key) headers["x-api-key"] = key;
headers["anthropic-version"] = "2023-06-01";
} else if (shape === "google") {
const b = trimSlash(base);
url = new URL(/\/v1(beta)?$/.test(b) ? `${b}/models` : `${b}/v1beta/models`);
url.searchParams.set("pageSize", "1000");
if (key) url.searchParams.set("key", key);
} else {
url = openaiModelsUrl(base);
if (key) headers.authorization = `Bearer ${key}`;
}
} catch {
throw new FetchError("config", `Base URL 不是合法地址:${base}`);
}
return { kind: "http", endpoint: { shape, url, headers } };
}
// ── 归一化 ──
const num = (v: unknown): number | null => {
const n = typeof v === "string" ? Number(v) : v;
return typeof n === "number" && Number.isFinite(n) ? n : null;
};
const int = (v: unknown): number | null => {
const n = num(v);
return n !== null && n > 0 ? Math.round(n) : null;
};
const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim().slice(0, 300) : null);
const obj = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
const has = (v: unknown, x: string) => Array.isArray(v) && v.includes(x);
type Remote = Omit<PiListedModel, "origin">;
function normalizeOpenAI(item: Record<string, unknown>): Remote | null {
const id = str(item.id);
if (!id) return null;
const arch = obj(item.architecture);
const caps = obj(item.capabilities);
const top = obj(item.top_provider);
const pricing = obj(item.pricing);
const params = item.supported_parameters;
const inputs = arch.input_modalities ?? item.input_modalities ?? item.modalities;
const vision = Array.isArray(inputs)
? has(inputs, "image")
: typeof caps.vision === "boolean"
? caps.vision
: typeof item.supports_vision === "boolean"
? item.supports_vision
: null;
const tools = Array.isArray(params)
? has(params, "tools")
: typeof caps.function_calling === "boolean"
? caps.function_calling
: typeof caps.tools === "boolean"
? caps.tools
: typeof item.supports_tools === "boolean"
? item.supports_tools
: null;
// OpenRouter:pricing.prompt/completion 是每 token 美元(字符串);Together 等:pricing.input/output 已是每百万
let price: Remote["pricing"] = null;
if (pricing.prompt !== undefined || pricing.completion !== undefined) {
const p = num(pricing.prompt);
const c = num(pricing.completion);
price = { input: p === null ? null : +(p * 1e6).toFixed(4), output: c === null ? null : +(c * 1e6).toFixed(4) };
} else if (pricing.input !== undefined || pricing.output !== undefined)
price = { input: num(pricing.input), output: num(pricing.output) };
return {
id,
displayName: str(item.name) ?? str(item.display_name),
contextWindow:
int(item.context_length) ??
int(item.context_window) ??
int(item.max_context_length) ??
int(item.max_model_len) ??
int(item.max_input_tokens) ??
int(top.context_length),
supportsVision: vision,
supportsTools: tools,
pricing: price,
};
}
function normalizeAnthropic(item: Record<string, unknown>): Remote | null {
const id = str(item.id);
if (!id) return null;
return {
id,
displayName: str(item.display_name),
contextWindow: int(item.max_input_tokens) ?? int(item.context_window),
supportsVision: null,
supportsTools: null,
pricing: null,
};
}
function normalizeGoogle(item: Record<string, unknown>): Remote | null {
const name = str(item.name);
if (!name) return null;
const methods = item.supportedGenerationMethods;
if (Array.isArray(methods) && !methods.includes("generateContent")) return null;
return {
id: name.replace(/^models\//, ""),
displayName: str(item.displayName),
contextWindow: int(item.inputTokenLimit),
supportsVision: null,
supportsTools: null,
pricing: null,
};
}
function parseList(shape: Shape, body: unknown): Remote[] {
const b = obj(body);
const raw = Array.isArray(body) ? body : Array.isArray(b.data) ? b.data : Array.isArray(b.models) ? b.models : null;
if (!raw) throw new FetchError("other", "上游返回的不是模型列表");
const norm = shape === "anthropic" ? normalizeAnthropic : shape === "google" ? normalizeGoogle : normalizeOpenAI;
const out: Remote[] = [];
const seen = new Set<string>();
for (const it of raw) {
const m = norm(obj(it));
if (!m || seen.has(m.id) || NON_CHAT.test(m.id)) continue;
seen.add(m.id);
out.push(m);
}
return out;
}
async function fetchList(ep: ListEndpoint): Promise<Remote[]> {
const host = ep.url.host;
let res: Response;
try {
res = await fetch(ep.url, { headers: ep.headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (err) {
const e = err as Error;
// 错误信息里可能带完整 URL(Google 的 key 在查询串里),只留主机名
const why =
e.name === "TimeoutError"
? `${TIMEOUT_MS / 1000} 秒没有响应`
: redactString(e.message.replaceAll(ep.url.toString(), host)).slice(0, 160);
throw new FetchError("network", `连不上 ${host}:${why}`);
}
if (res.status === 401 || res.status === 403) {
await res.body?.cancel();
throw new FetchError("auth", `${host} 拒绝了凭证(HTTP ${res.status})`);
}
if (res.status === 404 || res.status === 405) {
await res.body?.cancel();
throw new FetchError("unsupported", `${host} 没有模型列表接口(HTTP ${res.status})`);
}
if (!res.ok) {
await res.body?.cancel();
throw new FetchError("other", `${host} 返回 HTTP ${res.status}`);
}
let body: unknown;
try {
body = await res.json();
} catch {
throw new FetchError("other", `${host} 返回的不是 JSON`);
}
const list = parseList(ep.shape, body);
if (!list.length) throw new FetchError("other", `${host} 返回的模型列表是空的`);
return list;
}
// ── 静态目录与合并 ──
const fromInfo = (m: PiModelInfo): Remote => ({
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,
});
/** 随包静态目录(已配置凭证时按 provider 过滤,如 Copilot);自定义端点只有已填的模型 */
async function staticList(client: PiUpstreamClient | null): Promise<Remote[]> {
if (!client) return [];
try {
const r = await client.listModels({ refresh: false });
return r.items.map(fromInfo);
} catch {
return [];
}
}
/** 远程在前、同 id 远程优先(缺的字段用静态补),然后是只在静态目录里的 */
export function mergeModels(remote: Remote[] | null, stat: Remote[]): PiListedModel[] {
const byId = new Map(stat.map((m) => [m.id, m]));
const out: PiListedModel[] = [];
const seen = new Set<string>();
for (const r of remote ?? []) {
if (seen.has(r.id)) continue;
seen.add(r.id);
const s = byId.get(r.id);
out.push({
id: r.id,
displayName: r.displayName ?? s?.displayName ?? null,
contextWindow: r.contextWindow ?? s?.contextWindow ?? null,
supportsVision: r.supportsVision ?? s?.supportsVision ?? null,
supportsTools: r.supportsTools ?? s?.supportsTools ?? null,
pricing: r.pricing ?? s?.pricing ?? null,
origin: "remote",
});
}
for (const s of stat) {
if (seen.has(s.id)) continue;
seen.add(s.id);
out.push({ ...s, origin: "static" });
}
return out;
}
function storedRemote(row: PiUpstreamRow): Remote[] | null {
if (!row.models || !row.models_fetched_at || !Array.isArray(row.models)) return null;
return (row.models as unknown[]).map((m) => obj(m) as unknown as Remote).filter((m) => typeof m.id === "string");
}
function remoteSupported(spec: UpstreamSpec): boolean {
try {
return planFor(spec, { type: "api_key", key: "x" }).kind !== "unsupported";
} catch {
return true;
}
}
/** GET …/models:拉过远程列表 → 远程 + 静态合并;否则静态目录 */
export async function readUpstreamModels(ctx: ServerContext, row: PiUpstreamRow): Promise<PiUpstreamModels> {
const remote = storedRemote(row);
const stat = await staticList(safeClient(() => clientFor(ctx, row)));
return {
source: remote ? "remote" : "static",
fetchedAt: remote && row.models_fetched_at ? row.models_fetched_at.toISOString() : null,
models: mergeModels(remote, stat),
remoteSupported: remoteSupported(specOf(row)),
error: null,
errorKind: null,
};
}
function safeClient(make: () => PiUpstreamClient): PiUpstreamClient | null {
try {
return make();
} catch {
return null;
}
}
/** 拉一次远程列表(不落库);失败抛 FetchError */
async function fetchRemote(
spec: UpstreamSpec,
cred: UpstreamCredential | undefined,
client: PiUpstreamClient | null,
): Promise<Remote[]> {
const plan = planFor(spec, cred);
if (plan.kind === "unsupported") throw new FetchError("unsupported", plan.reason);
if (plan.kind === "http") return await fetchList(plan.endpoint);
// Radius:pi-ai 自带的动态刷新
if (!client) throw new FetchError("config", "上游配置不完整");
const r = await client.listModels({ refresh: true });
if (!r.refreshed) throw toFetchError(new Error(r.refreshError ?? "刷新失败"));
return r.items.map(fromInfo);
}
function toFetchError(err: unknown): FetchError {
if (err instanceof FetchError) return err;
const e = classifyUpstreamError(err);
const kind: PiModelFetchErrorKind =
e.kind === "auth" || e.kind === "oauth"
? "auth"
: e.kind === "network"
? "network"
: e.kind === "config"
? "config"
: "other";
return new FetchError(kind, e.message);
}
/** POST …/models/refresh:成功存库;失败不动已存的列表,返回当前列表(没拉过就是静态表)并带上原因 */
export async function refreshUpstreamModels(ctx: ServerContext, row: PiUpstreamRow): Promise<PiUpstreamModels> {
const client = safeClient(() => clientFor(ctx, row));
const spec = { ...specOf(row), extra: parseExtra(row.extra) };
try {
const cred = await dbCredentialStore(ctx, row.id).read();
const remote = await fetchRemote(spec, cred, client);
await ctx.db
.updateTable("pi_upstream")
.set({ models: JSON.stringify(remote), models_fetched_at: new Date() })
.where("id", "=", row.id)
.execute();
return await readUpstreamModels(ctx, (await getRow(ctx, row.id)) ?? row);
} catch (err) {
const e = toFetchError(err);
if (e.kind !== "unsupported")
ctx.logger.info({ upstreamId: row.id, provider: row.provider, kind: e.kind, msg: e.message }, "获取模型列表失败");
return { ...(await readUpstreamModels(ctx, row)), error: e.message, errorKind: e.kind };
}
}
/** 只在内存里的凭证(试拉用,不写库) */
function memoryStore(cred: UpstreamCredential | undefined): UpstreamCredentialStore {
let c = cred;
return {
read: async () => c,
modify: async (fn) => {
const next = await fn(c);
if (next !== undefined) c = next;
return c;
},
delete: async () => {
c = undefined;
},
};
}
/** POST /pi/models/probe:新建上游前试拉(不落库) */
export async function probeModels(body: PiModelsProbeRequest): Promise<PiUpstreamModels> {
const spec: UpstreamSpec = {
id: "probe",
provider: body.provider,
authKind: body.authKind,
baseUrl: body.baseUrl ?? null,
model: null,
extra: body.extra ?? {},
};
const cred: UpstreamCredential | undefined = body.apiKey ? { type: "api_key", key: body.apiKey } : undefined;
const client = safeClient(() => new PiUpstreamClient(spec, memoryStore(cred)));
const stat = await staticList(client);
const base = { remoteSupported: remoteSupported(spec) };
try {
const remote = await fetchRemote(spec, cred, client);
return {
...base,
source: "remote",
fetchedAt: new Date().toISOString(),
models: mergeModels(remote, stat),
error: null,
errorKind: null,
};
} catch (err) {
const e = toFetchError(err);
return {
...base,
source: "static",
fetchedAt: null,
models: mergeModels(null, stat),
error: e.message,
errorKind: e.kind,
};
}
}
+14 -8
View File
@@ -5,6 +5,7 @@ import {
Api,
apiResponse,
type EndpointDef,
type PiModelsProbeRequest,
type PiOAuthCompleteRequest,
type PiOAuthStartRequest,
type PiUpstreamCreate,
@@ -13,8 +14,8 @@ import {
} from "@umt/contracts";
import type { z } from "zod";
import { requireAdminSession, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { probeModels, readUpstreamModels, refreshUpstreamModels } from "./models.ts";
import type { OAuthSessions } from "./oauth.ts";
import { requireRow } from "./oauth.ts";
import { refreshUpstream, resolveExpiredDecision } from "./refresh.ts";
@@ -125,15 +126,20 @@ export function registerPiRoutes(
return { ...out, mode, latencyMs: Date.now() - started, upstream: await toView(ctx, (await getRow(ctx, row.id))!) };
});
route(app, Api.pi.models, async ({ params }) => {
route(app, Api.pi.models, async ({ params }) => await readUpstreamModels(ctx, await requireRow(ctx, params.id)));
route(app, Api.pi.modelsRefresh, async ({ params, actor }) => {
const row = await requireRow(ctx, params.id);
try {
return await clientFor(ctx, row, deps.client).listModels();
} catch (err) {
if (err instanceof UpstreamError) throw new ApiException("validation_failed", err.message);
throw err;
}
const r = await refreshUpstreamModels(ctx, row);
await auditPi(ctx, actor, "pi_upstream.models_refresh", row.id, {
source: r.source,
count: r.models.length,
errorKind: r.errorKind,
});
return r;
});
route(app, Api.pi.modelsProbe, async ({ body }) => await probeModels(body as PiModelsProbeRequest));
route(app, Api.pi.oauthStart, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
+4
View File
@@ -159,6 +159,10 @@ export async function updateUpstream(
...(body.thinkingLevel !== undefined ? { thinking_level: body.thinkingLevel } : {}),
...(body.extra !== undefined ? { extra: JSON.stringify(body.extra) } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
// 端点变了,之前拉到的模型列表作废(回到静态表,等下次获取)
...(baseUrl !== row.base_url || (body.extra !== undefined && body.extra.api !== parseExtra(row.extra).api)
? { models: null, models_fetched_at: null }
: {}),
})
.where("id", "=", row.id)
.execute()
+3
View File
@@ -81,6 +81,9 @@ describe("Api 注册表鉴权标注", () => {
"POST /pi/upstreams/{id}/oauth/start",
"POST /pi/upstreams/{id}/oauth/complete",
"POST /pi/upstreams/{id}/oauth/logout",
// PI-2 获取模型列表(会用上游凭证出站)
"POST /pi/upstreams/{id}/models/refresh",
"POST /pi/models/probe",
// W7-C 定时任务看板:全部只接受控制台会话
"GET /schedules",
"POST /schedules",
+213
View File
@@ -0,0 +1,213 @@
import { createServer, type Server } from "node:http";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts";
import { createPiModule } from "../../src/modules/pi/index.ts";
import { mergeModels } from "../../src/modules/pi/models.ts";
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// PI-2 获取模型列表:mock 一个 OpenAI 兼容 /models(Bearer 校验),覆盖成功存库、合并去重、401 与网络失败回退静态表、
// 不支持列表的 provider、新建前试拉(不落库)、改 Base URL 后作废已存列表。
const hasPg = await pgAvailable();
const GOOD = "sk-good-models-key-1234";
describe("mergeModels:远程优先,缺的字段用静态补", () => {
it("同 id 合并,远程在前,静态独有的标 static", () => {
const base = { displayName: null, contextWindow: null, supportsVision: null, supportsTools: null, pricing: null };
const out = mergeModels(
[
{ ...base, id: "a", supportsTools: true },
{ ...base, id: "b", contextWindow: 9000 },
],
[
{ ...base, id: "b", displayName: "B 静态", contextWindow: 1000, supportsVision: true },
{ ...base, id: "c" },
],
);
expect(out.map((m) => [m.id, m.origin])).toEqual([
["a", "remote"],
["b", "remote"],
["c", "static"],
]);
expect(out[1]).toMatchObject({ displayName: "B 静态", contextWindow: 9000, supportsVision: true });
});
});
describe.skipIf(!hasPg)("获取模型列表(真实 PG + mock /models)", () => {
let h: Harness;
let admin: Browser;
let mock: Server;
let mockUrl = "";
let deadUrl = "";
const hits: string[] = [];
beforeAll(async () => {
h = await createHarness({ extraModules: [createPiModule({ refreshIntervalMs: 0 })] });
const token = await ensureSetupToken(h.ctx);
admin = new Browser(h.app);
expect(
(
await admin.req("POST", "/auth/setup", {
setupToken: token,
username: "admin",
password: "correct horse battery",
})
).status,
).toBe(200);
mock = createServer((req, res) => {
hits.push(`${req.method} ${req.url} ${req.headers.authorization ?? ""}`);
if (req.url !== "/v1/models") {
res.writeHead(404).end();
return;
}
if (req.headers.authorization !== `Bearer ${GOOD}`) {
res.writeHead(401, { "content-type": "application/json" }).end('{"error":"invalid api key"}');
return;
}
res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
object: "list",
data: [
{
id: "vision-pro",
name: "Vision Pro",
context_length: 200000,
architecture: { input_modalities: ["text", "image"] },
supported_parameters: ["tools", "temperature"],
pricing: { prompt: "0.000003", completion: "0.000015" },
},
{ id: "text-mini", object: "model", owned_by: "me" },
{ id: "text-embedding-3-small", object: "model" },
{ id: "text-mini", object: "model" },
],
}),
);
});
await new Promise<void>((r) => mock.listen(0, "127.0.0.1", r));
mockUrl = `http://127.0.0.1:${(mock.address() as { port: number }).port}/v1`;
// 取一个空闲端口后立刻关掉,当"连不上"的地址
const tmp = createServer();
await new Promise<void>((r) => tmp.listen(0, "127.0.0.1", r));
deadUrl = `http://127.0.0.1:${(tmp.address() as { port: number }).port}/v1`;
await new Promise((r) => tmp.close(r));
}, 120_000);
afterAll(async () => {
await new Promise((r) => mock?.close(r));
await h?.close();
});
const req = async (method: string, path: string, body?: unknown) => {
const res = await admin.req(method, path, body);
return { status: res.status, body: await json(res) };
};
const create = async (body: Record<string, unknown>) => {
const r = await req("POST", "/pi/upstreams", body);
expect(r.status, JSON.stringify(r.body)).toBe(200);
return r.body.data as { id: string };
};
const custom = (name: string, baseUrl: string, apiKey: string) =>
create({ name, provider: "openai-compatible", authKind: "api_key", apiKey, baseUrl, defaultModel: "my-default" });
it("成功:归一化(视觉/工具/价格/上下文)、去掉嵌入模型、去重、存库;读取返回远程 + 静态合并", async () => {
const u = await custom("兼容端点 A", mockUrl, GOOD);
const before = (await req("GET", `/pi/upstreams/${u.id}/models`)).body.data;
expect(before).toMatchObject({ source: "static", fetchedAt: null, remoteSupported: true, error: null });
expect(before.models.map((m: { id: string }) => m.id)).toEqual(["my-default"]);
const r = await req("POST", `/pi/upstreams/${u.id}/models/refresh`);
expect(r.status).toBe(200);
const d = r.body.data;
expect(d).toMatchObject({ source: "remote", error: null, errorKind: null });
expect(d.fetchedAt).toBeTruthy();
expect(d.models.map((m: { id: string; origin: string }) => [m.id, m.origin])).toEqual([
["vision-pro", "remote"],
["text-mini", "remote"],
["my-default", "static"],
]);
expect(d.models[0]).toMatchObject({
displayName: "Vision Pro",
contextWindow: 200000,
supportsVision: true,
supportsTools: true,
pricing: { input: 3, output: 15 },
});
expect(hits.at(-1)).toBe(`GET /v1/models Bearer ${GOOD}`);
const row = await h.db
.selectFrom("pi_upstream")
.select(["models", "models_fetched_at"])
.where("id", "=", u.id)
.executeTakeFirstOrThrow();
expect((row.models as unknown[]).length).toBe(2);
expect(row.models_fetched_at).toBeTruthy();
const again = (await req("GET", `/pi/upstreams/${u.id}/models`)).body.data;
expect(again).toMatchObject({ source: "remote", fetchedAt: d.fetchedAt });
expect(again.models).toHaveLength(3);
// Bearer admin key 只能读,获取(会用凭证出站)要控制台会话
expect((await bearer(h.app, DEV_KEY)("POST", `/pi/upstreams/${u.id}/models/refresh`)).status).toBe(403);
expect((await bearer(h.app, DEV_KEY)("GET", `/pi/upstreams/${u.id}/models`)).status).toBe(200);
// 改 Base URL:已存列表作废,回到静态表
await req("PATCH", `/pi/upstreams/${u.id}`, { baseUrl: `${mockUrl}/` });
expect((await req("GET", `/pi/upstreams/${u.id}/models`)).body.data.source).toBe("static");
});
it("401:回退静态表,errorKind=auth,消息可读且不含 key", async () => {
const u = await custom("兼容端点 坏 key", mockUrl, "sk-bad-models-key-9999");
const d = (await req("POST", `/pi/upstreams/${u.id}/models/refresh`)).body.data;
expect(d).toMatchObject({ source: "static", fetchedAt: null, errorKind: "auth" });
expect(d.error).toMatch(/拒绝了凭证.*401/);
expect(d.error).not.toContain("sk-bad");
expect(d.models.map((m: { id: string }) => m.id)).toEqual(["my-default"]);
});
it("网络失败:回退静态表,errorKind=network", async () => {
const u = await custom("兼容端点 连不上", deadUrl, GOOD);
const d = (await req("POST", `/pi/upstreams/${u.id}/models/refresh`)).body.data;
expect(d).toMatchObject({ source: "static", errorKind: "network" });
expect(d.error).toMatch(/连不上 127\.0\.0\.1/);
});
it("没有列表接口的 provider(订阅登录 / fake):unsupported,返回静态表", async () => {
const f = await create({ name: "fake 列表", provider: "fake", authKind: "none" });
const d = (await req("POST", `/pi/upstreams/${f.id}/models/refresh`)).body.data;
expect(d).toMatchObject({ source: "static", errorKind: "unsupported", remoteSupported: false });
expect(d.models.map((m: { id: string }) => m.id)).toEqual(["fake-model"]);
const codex = await create({ name: "codex 列表", provider: "openai-codex", authKind: "oauth" });
const c = (await req("POST", `/pi/upstreams/${codex.id}/models/refresh`)).body.data;
expect(c).toMatchObject({ source: "static", errorKind: "unsupported" });
expect(c.models.length).toBeGreaterThan(3);
});
it("新建前试拉:用填的 key 拉一次,不落库;401 回退", async () => {
const ok = (
await req("POST", "/pi/models/probe", {
provider: "openai-compatible",
authKind: "api_key",
apiKey: GOOD,
baseUrl: mockUrl,
})
).body.data;
expect(ok).toMatchObject({ source: "remote", error: null });
expect(ok.models.map((m: { id: string }) => m.id)).toEqual(["vision-pro", "text-mini"]);
const bad = (
await req("POST", "/pi/models/probe", {
provider: "openai-compatible",
authKind: "api_key",
apiKey: "sk-nope",
baseUrl: mockUrl,
})
).body.data;
expect(bad).toMatchObject({ source: "static", errorKind: "auth", models: [] });
// OpenAI 没填 key:直接提示,不出站
const n = hits.length;
const noKey = (await req("POST", "/pi/models/probe", { provider: "openai", authKind: "api_key" })).body.data;
expect(noKey).toMatchObject({ source: "static", errorKind: "auth" });
expect(noKey.models.length).toBeGreaterThan(10);
expect(hits.length).toBe(n);
expect(h.lines.join("\n")).not.toContain(GOOD);
});
});
+2 -2
View File
@@ -164,8 +164,8 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
expect(tb).toMatchObject({ ok: true, mode: "auth", upstream: { status: "error", lastVerifiedAt: null } });
expect(tb.message).toMatch(/最小补全/);
const models = (await req("GET", `/pi/upstreams/${a.id}/models`)).body.data;
expect(models.items.length).toBeGreaterThan(10);
expect(models.refreshed).toBe(false);
expect(models.models.length).toBeGreaterThan(10);
expect(models).toMatchObject({ source: "static", fetchedAt: null, remoteSupported: true });
// 删除默认上游 → 默认清空,secret 一并删除
const secretRef = (
+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 () => {
+179 -14
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>
{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>
))}
</datalist>
</>
</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;
+17 -2
View File
@@ -129,7 +129,7 @@ curl -X POST localhost:4380/api/v1/automation-grants -H "Authorization: Bearer <
## 上游与登录(W7-A)
实现:`apps/server/src/modules/pi/`(上游 CRUD、能力表、测试、服务端订阅登录、刷新任务)、`packages/agent/src/pi/{catalog,upstream,fake-upstream}.ts`、
合同 `packages/contracts/src/pi.ts`、迁移 `0027_pi_upstreams`。研究结论见 `docs/design/pi-providers.md`。
合同 `packages/contracts/src/pi.ts`、迁移 `0027_pi_upstreams`、`0044_upstream_models`。研究结论见 `docs/design/pi-providers.md`。
**模型**:一个上游(`pi_upstream`)= 一个 pi provider + 一份凭据。provider 是 pi-ai 0.87.1 内置的 41 个之一,或
`openai-compatible`(自定义兼容端点,baseUrl 与模型必填,`extra.api` 选 openai-completions / openai-responses / anthropic-messages / google-generative-ai),
@@ -155,11 +155,26 @@ runAgent 按 `runAgent{upstreamId}` → `agent_run.upstream_id` → 默认上游
| `GET /pi/upstreams`、`GET /pi/upstreams/{id}` | 不含密钥,只给 `hasCredential`、`credentialMask`(前 4 后 4)、`account`、`oauthExpiresAt`、`isDefault` |
| `POST /pi/upstreams`、`PATCH /pi/upstreams/{id}`、`DELETE …` | `apiKey` 只写(PATCH 传 null 清除);provider 不可改;改 authKind 会删掉旧凭据;删除连同 secret |
| `POST /pi/upstreams/{id}/test {mode?: auth\|completion, model?}` | 缺省 `auth`:解析凭据(OAuth 必要时刷新)+ 校验模型在目录中,**不产生费用**;`completion` 再发一次最多 16 token 的补全 |
| `GET /pi/upstreams/{id}/models` | 静态目录(已配置凭据时按 provider 过滤,如 Copilot);Radius 先动态刷新,失败时回静态表并给 `refreshError` |
| `GET /pi/upstreams/{id}/models` | `{source: remote\|static, fetchedAt, models[], remoteSupported, error, errorKind}`:拉过远程列表 → 远程与静态目录合并去重(同 id 远程优先,远程缺的字段用静态补,远程在前);没拉过 → 静态目录(已配置凭据时按 provider 过滤,如 Copilot) |
| `POST /pi/upstreams/{id}/models/refresh` | 「获取模型列表」:用该上游的凭据调 provider 的模型列表接口,成功存 `pi_upstream.models` + `models_fetched_at`(迁移 0044)。失败不动已存列表,返回当前列表 + 一句话 `error`,`errorKind` = auth(401/403)/ network / unsupported / config / other。改 Base URL 或 `extra.api` 会清掉已存列表 |
| `POST /pi/models/probe {provider, authKind, apiKey?, baseUrl?, extra?}` | 新建上游前试拉(凭据只用这一次,不落库);新建向导创建后会用已存凭据再拉一次并存库 |
| `POST /pi/upstreams/{id}/oauth/start {mode?, enterpriseDomain?}` | 开始订阅登录,见下 |
| `POST /pi/upstreams/{id}/oauth/complete {sessionId, pastedUrlOrCode?, waitMs?}` | 完成 / 轮询登录 |
| `POST /pi/upstreams/{id}/oauth/logout` | 删除本地 token,状态回到 `auth_required`(不会撤销提供方侧授权) |
**模型列表**(PI-2,`apps/server/src/modules/pi/models.ts`):pi-ai 0.87.1 只有 Radius 自带 `refreshModels`(照旧走 pi 的动态刷新),其它按通用规则直连,出站走进程的 `HTTP(S)_PROXY`,超时 15 秒:
| provider | 接口 |
|---|---|
| OpenAI 形状(openai、deepseek、groq、xai、mistral、together、fireworks、nvidia、huggingface、moonshot、qwen/xiaomi token plan、zai、opencode、vercel-ai-gateway、baseten、cerebras、ant-ling、meta;自定义端点 `extra.api` 为 openai-*) | `GET {base}/models`(base 以 `/v1`、`/v4`、`/v1beta` 这类版本段结尾时),否则 `GET {base}/v1/models`;`Authorization: Bearer` |
| anthropic(API Key)、自定义端点 anthropic-messages | `GET /v1/models?limit=1000`,`x-api-key` + `anthropic-version: 2023-06-01` |
| google、自定义端点 google-generative-ai | `GET /v1beta/models?key=…&pageSize=1000`,只留支持 generateContent 的 |
| openrouter(API Key 或登录换到的永久 Key) | `GET /api/v1/models`(带价格、上下文、`input_modalities`、`supported_parameters`) |
| 其它订阅登录、云凭证(Bedrock / Vertex / Azure / Cloudflare)、Copilot、Kimi、MiniMax、fake | 没有公开接口 → `unsupported`,用静态目录(界面不显示获取按钮) |
归一化字段 `{id, displayName, contextWindow, supportsVision, supportsTools, pricing{input, output}(美元 / 百万 token)}`,拿不到为 null;去掉嵌入、语音、审核、重排类 id。
界面:新建向导第 2 步凭据填好后「获取模型列表」(试拉),第 3 步与编辑抽屉「获取 / 更新模型列表」;下拉可搜索,显示 id、上下文、视觉 / 工具标记,也可手填 id;来源行「来源:远程 · 几分钟前更新」或「来源:静态」;失败一句话。设为默认上游时默认模型必填(界面校验)。
**上游状态**(服务端维护):`auth_required`(新建 oauth / 未填 key / 已登出)→ 登录或填 key → `ok`;测试失败 → `error`(凭据被拒 → `auth_required`);
OAuth 刷新失败 → `expired` + 待处理事项「模型上游 X 登录已失效,需要重新登录」(去重键 `pi_upstream_expired:<id>`),重新登录成功后事项自动关闭。
+19 -1
View File
@@ -1792,10 +1792,28 @@ export const Api = {
method: "get",
path: "/pi/upstreams/{id}/models",
scope: "admin",
summary: "上游可用模型(静态目录;Radius 先动态刷新)",
summary: "上游模型列表:已获取过远程列表时返回远程与静态目录的合并(远程优先),否则静态目录",
params: IdParam,
response: pi.PiUpstreamModels,
}),
modelsRefresh: ep({
method: "post",
path: "/pi/upstreams/{id}/models/refresh",
scope: "admin",
auth: "session",
summary: "获取模型列表:用该上游的凭证调 provider 的模型列表接口并存库;失败或不支持时回静态目录并给出原因",
params: IdParam,
response: pi.PiUpstreamModels,
}),
modelsProbe: ep({
method: "post",
path: "/pi/models/probe",
scope: "admin",
auth: "session",
summary: "新建上游前试拉模型列表(凭证只用于这一次请求,不落库)",
body: pi.PiModelsProbeRequest,
response: pi.PiUpstreamModels,
}),
oauthStart: ep({
method: "post",
path: "/pi/upstreams/{id}/oauth/start",
+47 -5
View File
@@ -299,15 +299,57 @@ export const PiUpstreamTestResult = z.object({
});
export type PiUpstreamTestResult = z.infer<typeof PiUpstreamTestResult>;
/** 模型列表来源:remote = 从上游的模型列表接口拉取(已存库);static = 随包静态目录(没拉过、拉取失败或 provider 没有列表接口) */
export const PiModelListSource = z.enum(["remote", "static"]);
export type PiModelListSource = z.infer<typeof PiModelListSource>;
/** 列表里的一个模型(字段按上游接口能给的归一化;拿不到为 null) */
export const PiListedModel = z.object({
id: z.string().min(1).max(300),
displayName: z.string().max(300).nullable(),
contextWindow: z.number().int().nonnegative().nullable(),
/** 支持图片输入 */
supportsVision: z.boolean().nullable(),
/** 支持工具调用 */
supportsTools: z.boolean().nullable(),
/** 价格(美元 / 百万 token) */
pricing: z.object({ input: z.number().nullable(), output: z.number().nullable() }).nullable(),
/** 这一条来自哪里:remote = 上游列表;static = 只在静态目录里 */
origin: PiModelListSource,
});
export type PiListedModel = z.infer<typeof PiListedModel>;
/** 获取失败的分类:unsupported = provider 没有模型列表接口 */
export const PiModelFetchErrorKind = z.enum(["auth", "network", "unsupported", "config", "other"]);
export type PiModelFetchErrorKind = z.infer<typeof PiModelFetchErrorKind>;
/**
* GET /pi/upstreams/{id}/models、POST …/models/refresh、POST /pi/models/probe 的响应。
* models = 远程列表与静态目录合并去重(同 id 远程优先,远程缺的字段用静态目录补),远程在前。
*/
export const PiUpstreamModels = z.object({
items: z.array(PiModelInfo),
/** 动态模型表是否已从上游刷新成功(静态 provider 恒为 false) */
refreshed: z.boolean(),
/** 动态刷新失败原因(仍返回静态表) */
refreshError: z.string().nullable(),
source: PiModelListSource,
/** 远程列表拉取时间;static 为 null */
fetchedAt: Timestamp.nullable(),
models: z.array(PiListedModel),
/** 该 provider 有模型列表接口(没有时「获取模型列表」直接回静态表) */
remoteSupported: z.boolean(),
/** 本次获取失败的原因(一句话,已脱敏);成功或只是读取时为 null */
error: z.string().nullable(),
errorKind: PiModelFetchErrorKind.nullable(),
});
export type PiUpstreamModels = z.infer<typeof PiUpstreamModels>;
/** 新建上游前试拉模型列表(不落库;凭证只用于这一次请求) */
export const PiModelsProbeRequest = z.object({
provider: PiProviderId,
authKind: PiAuthKind,
apiKey: ApiKeyInput.optional(),
baseUrl: z.url().nullable().optional(),
extra: PiUpstreamExtra.optional(),
});
export type PiModelsProbeRequest = z.infer<typeof PiModelsProbeRequest>;
// ── 订阅登录状态机 ──
export const PiOAuthStartRequest = z.object({
@@ -0,0 +1,23 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// PI-2 上游模型列表:「获取模型列表」从上游的模型列表接口拉到的结果(归一化后的 [{id, displayName, contextWindow,
// supportsVision, supportsTools, pricing}])存 models,拉取时间存 models_fetched_at。null = 从没拉过,用随包静态表。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`alter table pi_upstream add column models jsonb;
alter table pi_upstream add column models_fetched_at timestamptz;`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`alter table pi_upstream drop column if exists models_fetched_at;
alter table pi_upstream drop column if exists models;`,
);
}
+2
View File
@@ -1086,6 +1086,8 @@ export interface PiUpstreamTable {
oauth_expires_at: Timestamptz | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
models: Json | null;
models_fetched_at: Timestamptz | null;
}
export interface PolicyRevisionTable {