From 0765264a476381e95b447ba684c23fba2e711158 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 01:26:07 +0800 Subject: [PATCH] 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 --- apps/server/src/modules/pi/models.ts | 471 ++++++++++++++++++ apps/server/src/modules/pi/routes.ts | 22 +- apps/server/src/modules/pi/service.ts | 4 + apps/server/test/api-registry.test.ts | 3 + apps/server/test/pi/models.test.ts | 213 ++++++++ apps/server/test/pi/upstreams.test.ts | 4 +- apps/web/app/api/pi.ts | 18 + .../web/app/components/settings/pi-shared.tsx | 195 +++++++- .../app/components/settings/pi-upstreams.tsx | 41 +- .../web/app/components/settings/pi-wizard.tsx | 108 +++- apps/web/scripts/smoke.mjs | 35 ++ docs/runbook/agent.md | 19 +- packages/contracts/src/api.ts | 20 +- packages/contracts/src/pi.ts | 52 +- .../db/migrations/0044_upstream_models.ts | 23 + packages/db/src/types.ts | 2 + 16 files changed, 1178 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/modules/pi/models.ts create mode 100644 apps/server/test/pi/models.test.ts create mode 100644 packages/db/migrations/0044_upstream_models.ts diff --git a/apps/server/src/modules/pi/models.ts b/apps/server/src/modules/pi/models.ts new file mode 100644 index 00000000..f9524774 --- /dev/null +++ b/apps/server/src/modules/pi/models.ts @@ -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; +} +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 = { 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 => + v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; +const has = (v: unknown, x: string) => Array.isArray(v) && v.includes(x); + +type Remote = Omit; + +function normalizeOpenAI(item: Record): 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): 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): 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(); + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/server/src/modules/pi/routes.ts b/apps/server/src/modules/pi/routes.ts index b3177106..7f4f9de6 100644 --- a/apps/server/src/modules/pi/routes.ts +++ b/apps/server/src/modules/pi/routes.ts @@ -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,16 +126,21 @@ 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); const r = await deps.oauth().start(row, body as PiOAuthStartRequest); diff --git a/apps/server/src/modules/pi/service.ts b/apps/server/src/modules/pi/service.ts index edaf09ec..c1ec85ce 100644 --- a/apps/server/src/modules/pi/service.ts +++ b/apps/server/src/modules/pi/service.ts @@ -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() diff --git a/apps/server/test/api-registry.test.ts b/apps/server/test/api-registry.test.ts index 74bb9447..74f8e8ae 100644 --- a/apps/server/test/api-registry.test.ts +++ b/apps/server/test/api-registry.test.ts @@ -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", diff --git a/apps/server/test/pi/models.test.ts b/apps/server/test/pi/models.test.ts new file mode 100644 index 00000000..d5386f0d --- /dev/null +++ b/apps/server/test/pi/models.test.ts @@ -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((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((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) => { + 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); + }); +}); diff --git a/apps/server/test/pi/upstreams.test.ts b/apps/server/test/pi/upstreams.test.ts index 5b88e400..ad82b41a 100644 --- a/apps/server/test/pi/upstreams.test.ts +++ b/apps/server/test/pi/upstreams.test.ts @@ -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 = ( diff --git a/apps/web/app/api/pi.ts b/apps/web/app/api/pi.ts index a04d526c..840b7513 100644 --- a/apps/web/app/api/pi.ts +++ b/apps/web/app/api/pi.ts @@ -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("/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("/pi/models/probe", { method: "POST", body }), + }); +} + function useInvalidatePi() { const qc = useQueryClient(); return () => { diff --git a/apps/web/app/components/settings/pi-shared.tsx b/apps/web/app/components/settings/pi-shared.tsx index 6584c463..65b4cd55 100644 --- a/apps/web/app/components/settings/pi-shared.tsx +++ b/apps/web/app/components/settings/pi-shared.tsx @@ -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 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(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) => { + 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 ( - <> +
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")} /> - - {models.map((m) => ( - - ))} - - + {open && !disabled && filtered.length ? ( +
+ {filtered.map((m, i) => ( +
{ + 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]", + )} + > + + {m.id} + {m.displayName ? ( + {m.displayName} + ) : null} + + {fmtContext(m.contextWindow) ? ( + + {fmtContext(m.contextWindow)} + + ) : null} + {m.supportsVision ? 视觉 : null} + {m.supportsTools ? 工具 : null} +
+ ))} +
+ ) : null} +
+ ); +} + +/** 「获取模型列表」按钮 + 来源行(来源:远程 / 静态 · 更新时间;失败一句话) */ +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 ( +
+ {unsupported ? null : ( + + )} + + {disabledReason && !unsupported + ? disabledReason + : remote + ? `来源:远程 · ${formatRelative(data?.fetchedAt)}更新` + : unsupported + ? "来源:静态(这个 provider 没有模型列表接口)" + : data + ? "来源:静态" + : null} + + {failed ? ( + + 获取失败:{failed} + + ) : null} +
); } diff --git a/apps/web/app/components/settings/pi-upstreams.tsx b/apps/web/app/components/settings/pi-upstreams.tsx index 935da84c..038c16cc 100644 --- a/apps/web/app/components/settings/pi-upstreams.tsx +++ b/apps/web/app/components/settings/pi-upstreams.tsx @@ -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" > + + refresh.mutate(upstream.id, { onError: (err) => toast.error(errorMessage(err, "获取失败")) }) + } + disabledReason={fetchBlocked} + /> 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} /> {showThinking ? ( diff --git a/apps/web/app/components/settings/pi-wizard.tsx b/apps/web/app/components/settings/pi-wizard.tsx index 259e28e7..66a73e1a 100644 --- a/apps/web/app/components/settings/pi-wizard.tsx +++ b/apps/web/app/components/settings/pi-wizard.tsx @@ -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("provider"); const [provider, setProvider] = useState(null); const [created, setCreated] = useState(null); + const [fetched, setFetched] = useState(false); const close = () => { onClose(); @@ -69,13 +86,14 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: () setStep("provider")} - onCreated={(u) => { + onCreated={(u, f) => { setCreated(u); + setFetched(f); setStep("verify"); }} /> ) : created ? ( - + ) : null} ); @@ -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(provider.authKinds[0] ?? "none"); const [name, setName] = useState(provider.name); @@ -158,6 +179,29 @@ function ConfigStep({ const [env, setEnv] = useState>({}); const [errors, setErrors] = useState>({}); 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" > + 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(); 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({ ) : null} - - + + + refresh.mutate(upstream.id, { onError: (err) => toast.error(errorMessage(err, "获取失败")) }) + } + disabledReason={loggedIn ? null : "登录后可获取"} + /> + { + setModel(v); + setModelError(undefined); + }} + models={models.data?.models ?? []} + invalid={!!modelError} + /> diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index d0147128..3017658d 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -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; diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index a81d4a73..2a2e085b 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -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:`),重新登录成功后事项自动关闭。 diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index fc8ac71f..bd7f01af 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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", diff --git a/packages/contracts/src/pi.ts b/packages/contracts/src/pi.ts index 32cdae73..67d417f1 100644 --- a/packages/contracts/src/pi.ts +++ b/packages/contracts/src/pi.ts @@ -299,15 +299,57 @@ export const PiUpstreamTestResult = z.object({ }); export type PiUpstreamTestResult = z.infer; +/** 模型列表来源:remote = 从上游的模型列表接口拉取(已存库);static = 随包静态目录(没拉过、拉取失败或 provider 没有列表接口) */ +export const PiModelListSource = z.enum(["remote", "static"]); +export type PiModelListSource = z.infer; + +/** 列表里的一个模型(字段按上游接口能给的归一化;拿不到为 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; + +/** 获取失败的分类:unsupported = provider 没有模型列表接口 */ +export const PiModelFetchErrorKind = z.enum(["auth", "network", "unsupported", "config", "other"]); +export type PiModelFetchErrorKind = z.infer; + +/** + * 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; +/** 新建上游前试拉模型列表(不落库;凭证只用于这一次请求) */ +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; + // ── 订阅登录状态机 ── export const PiOAuthStartRequest = z.object({ diff --git a/packages/db/migrations/0044_upstream_models.ts b/packages/db/migrations/0044_upstream_models.ts new file mode 100644 index 00000000..28fa2359 --- /dev/null +++ b/packages/db/migrations/0044_upstream_models.ts @@ -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): Promise { + 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): Promise { + await execDdl( + db, + `alter table pi_upstream drop column if exists models_fetched_at; +alter table pi_upstream drop column if exists models;`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 82677901..7b7b42ff 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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 {