feat(pi): fetch and refresh upstream model lists, searchable model picker

- POST /pi/upstreams/{id}/models/refresh calls the provider's model list
  endpoint (OpenAI-shaped /models, Anthropic /v1/models, Google
  /v1beta/models, OpenRouter /api/v1/models; Radius via pi-ai refresh),
  normalizes {id, displayName, contextWindow, supportsVision,
  supportsTools, pricing} and stores it in pi_upstream.models (0044)
- GET .../models returns {source, fetchedAt, models[]}: remote merged
  with the static catalog (remote first); failures (401/network/
  unsupported) fall back to static with a one-line error
- POST /pi/models/probe for trying credentials before creating
- wizard and edit drawer: fetch button, searchable dropdown with
  vision/tool tags, manual id, source line; default upstream needs a model
- tests: mock /models, 401, network failure; smoke step; runbook

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 01:26:07 +08:00
1 parent e5abfa6217
commit 0765264a47
16 files changed
+1178 -52

No files matched your search

+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({