Fix model list auth merge and unify model testing via probe

This commit is contained in:
xmanrui
2026-02-28 05:07:04 +08:00
parent 04feff63a0
commit 1da5a209ec
4 changed files with 247 additions and 103 deletions
+79 -2
View File
@@ -380,12 +380,21 @@ export async function GET() {
const feishuAgentIds = agentsWithStatus.filter((a: any) => a.platforms.some((p: any) => p.name === "feishu")).map((a: any) => a.id);
const groupChats = getGroupChats(agentIds, agentMap, feishuAgentIds, sessionsMap);
const authProviderIds = new Set<string>();
if (config.auth?.profiles) {
for (const profileKey of Object.keys(config.auth.profiles)) {
const profile = config.auth.profiles[profileKey];
const providerId = profile?.provider || profileKey.split(":")[0];
if (providerId) authProviderIds.add(providerId);
}
}
// 提取模型 providers
const providers = Object.entries(config.models?.providers || {}).map(
let providers = Object.entries(config.models?.providers || {}).map(
([providerId, provider]: [string, any]) => {
const models = (provider.models || []).map((m: any) => ({
id: m.id,
name: m.name,
name: m.name || m.id,
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
reasoning: m.reasoning,
@@ -400,12 +409,80 @@ export async function GET() {
return {
id: providerId,
api: provider.api,
accessMode: "api_key",
models,
usedBy,
};
}
);
// 始终合并 auth.profiles + agents/defaults 推断的 provider/model
// 兼容 models.providers 与 auth.profiles 同时存在的配置。
const providerModels: Record<string, { id: string; name?: string }[]> = {};
const ensureProvider = (providerId: string) => {
if (providerId && !providerModels[providerId]) providerModels[providerId] = [];
};
const addModelRef = (modelKey?: string, alias?: string) => {
if (!modelKey || typeof modelKey !== "string") return;
const slashIdx = modelKey.indexOf("/");
if (slashIdx <= 0 || slashIdx >= modelKey.length - 1) return;
const providerId = modelKey.slice(0, slashIdx);
const modelId = modelKey.slice(slashIdx + 1);
ensureProvider(providerId);
if (!providerModels[providerId].some((m) => m.id === modelId)) {
providerModels[providerId].push({ id: modelId, ...(alias && { name: alias }) });
}
};
// 从 auth.profiles 提取 provider 名称
for (const providerId of authProviderIds) ensureProvider(providerId);
// 从 agents.defaults.models 提取模型列表
const defaultsModels = config.agents?.defaults?.models || {};
for (const modelKey of Object.keys(defaultsModels)) {
const alias = defaultsModels[modelKey]?.alias;
addModelRef(modelKey, alias);
}
// 从主模型和 fallback 模型补充
addModelRef(defaultModel);
for (const fallback of fallbacks) addModelRef(fallback);
// 从每个 agent 的当前模型补充
for (const agent of agentsWithStatus) addModelRef(agent.model);
for (const [providerId, inferredModels] of Object.entries(providerModels)) {
let target = providers.find((p: any) => p.id === providerId);
if (!target) {
const usedBy = agentsWithStatus
.filter((a: any) => a.model.startsWith(providerId + "/"))
.map((a: any) => ({ id: a.id, emoji: a.emoji, name: a.name }));
target = { id: providerId, api: undefined, accessMode: authProviderIds.has(providerId) ? "auth" : "api_key", models: [], usedBy };
providers.push(target);
}
if (!target.accessMode) {
target.accessMode = authProviderIds.has(providerId) ? "auth" : "api_key";
}
for (const m of inferredModels) {
const exists = target.models.find((x: any) => x.id === m.id);
if (!exists) {
target.models.push({
id: m.id,
name: m.name || m.id,
contextWindow: undefined,
maxTokens: undefined,
reasoning: undefined,
input: undefined,
});
} else if (!exists.name) {
exists.name = m.name || exists.id;
}
}
}
const data = {
agents: agentsWithStatus,
providers,
+91 -86
View File
@@ -1,9 +1,53 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { execFile } from "child_process";
import { promisify } from "util";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
const execFileAsync = promisify(execFile);
interface ProbeResult {
provider?: string;
model?: string;
mode?: "api_key" | "oauth" | string;
status?: "ok" | "error" | "unknown" | string;
error?: string;
latencyMs?: number;
}
function parseJsonFromMixedOutput(output: string): any {
// `openclaw models status --json` may print warnings/logs before JSON.
for (let i = 0; i < output.length; i++) {
if (output[i] !== "{") continue;
let depth = 0;
let inString = false;
let escaped = false;
for (let j = i; j < output.length; j++) {
const ch = output[j];
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === "\"") inString = false;
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
const candidate = output.slice(i, j + 1).trim();
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") return parsed;
} catch {}
break;
}
}
}
}
throw new Error("Failed to parse JSON output from openclaw models status --probe --json");
}
export async function POST(req: Request) {
try {
@@ -12,90 +56,51 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Missing provider or modelId" }, { status: 400 });
}
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(raw);
const provider = config.models?.providers?.[providerId];
if (!provider) {
return NextResponse.json({ error: `Provider "${providerId}" not found` }, { status: 404 });
const startedAt = Date.now();
const { stdout, stderr } = await execFileAsync(
"openclaw",
["models", "status", "--probe", "--json", "--probe-provider", String(providerId)],
{
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, FORCE_COLOR: "0" },
}
);
const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`);
const results: ProbeResult[] = parsed?.auth?.probes?.results || [];
const fullModel = `${providerId}/${modelId}`;
const exact =
results.find((r) => r.provider === providerId && r.model === fullModel) ||
results.find((r) => r.provider === providerId && typeof r.model === "string" && r.model.endsWith(`/${modelId}`));
const matched = exact || results.find((r) => r.provider === providerId);
if (!matched) {
return NextResponse.json(
{
ok: false,
error: `No probe result for provider ${providerId}`,
elapsed: Date.now() - startedAt,
model: fullModel,
},
{ status: 404 }
);
}
const baseUrl = provider.baseUrl;
const apiKey = provider.apiKey || "";
const api = provider.api;
const headers: Record<string, string> = { "Content-Type": "application/json" };
const startTime = Date.now();
if (api === "anthropic-messages") {
// Check for custom auth header
const authHeader = provider.authHeader || "x-api-key";
headers[authHeader] = apiKey;
headers["anthropic-version"] = "2023-06-01";
if (provider.headers) Object.assign(headers, provider.headers);
const body = {
model: modelId,
max_tokens: 32,
messages: [{ role: "user", content: "Say hi in 3 words." }],
};
const resp = await fetch(`${baseUrl}/v1/messages`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(100000),
});
const elapsed = Date.now() - startTime;
const data = await resp.json();
if (!resp.ok) {
return NextResponse.json({
ok: false,
status: resp.status,
error: data.error?.message || JSON.stringify(data),
elapsed,
});
}
const text = data.content?.[0]?.text || "";
return NextResponse.json({ ok: true, text, elapsed, model: data.model });
} else if (api === "openai-completions") {
headers["Authorization"] = `Bearer ${apiKey}`;
const body = {
model: modelId,
max_tokens: 32,
messages: [{ role: "user", content: "Say hi in 3 words." }],
};
const resp = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(100000),
});
const elapsed = Date.now() - startTime;
const data = await resp.json();
if (!resp.ok) {
return NextResponse.json({
ok: false,
status: resp.status,
error: data.error?.message || JSON.stringify(data),
elapsed,
});
}
const text = data.choices?.[0]?.message?.content || "";
return NextResponse.json({ ok: true, text, elapsed, model: data.model });
} else {
return NextResponse.json({ error: `Unknown API type: ${api}` }, { status: 400 });
}
const ok = matched.status === "ok";
const error = matched.error || (!ok ? `Probe status: ${matched.status || "unknown"}` : undefined);
return NextResponse.json({
ok,
elapsed: matched.latencyMs ?? Date.now() - startedAt,
model: matched.model || fullModel,
mode: matched.mode || "unknown",
status: matched.status || "unknown",
error,
text: ok ? "OK (openclaw models status --probe)" : undefined,
});
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message, elapsed: 0 }, { status: 500 });
return NextResponse.json(
{ ok: false, error: err.message || "Probe failed", elapsed: 0 },
{ status: 500 }
);
}
}
+71 -15
View File
@@ -16,6 +16,7 @@ interface Model {
interface Provider {
id: string;
api: string;
accessMode?: "api_key" | "auth";
models: Model[];
usedBy: { id: string; emoji: string; name: string }[];
}
@@ -90,18 +91,62 @@ export default function ModelsPage() {
const testAllModels = async () => {
if (!data) return;
const pairs: { provider: string; modelId: string }[] = [];
const providerModels: Record<string, string[]> = {};
for (const p of data.providers) {
if (p.models.length > 0) {
for (const m of p.models) pairs.push({ provider: p.id, modelId: m.id });
providerModels[p.id] = Array.from(new Set(p.models.map((m) => m.id)));
} else {
const knownModels = Object.values(modelStats).filter(s => s.provider === p.id);
for (const s of knownModels) pairs.push({ provider: s.provider, modelId: s.modelId });
providerModels[p.id] = Array.from(new Set(knownModels.map((s) => s.modelId)));
}
}
for (const pair of pairs) {
testModel(pair.provider, pair.modelId);
}
await Promise.all(
Object.entries(providerModels)
.filter(([, modelIds]) => modelIds.length > 0)
.map(async ([providerId, modelIds]) => {
const keys = modelIds.map((id) => `${providerId}/${id}`);
const probeModelId = modelIds[0];
setTesting((prev) => {
const next = { ...prev };
for (const key of keys) next[key] = true;
return next;
});
setTestResults((prev) => {
const next = { ...prev };
for (const key of keys) delete next[key];
return next;
});
try {
const resp = await fetch("/api/test-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, modelId: probeModelId }),
});
const result = await resp.json();
setTestResults((prev) => {
const next = { ...prev };
for (const key of keys) next[key] = result;
return next;
});
} catch (err: any) {
const result = { ok: false, error: err.message, elapsed: 0 };
setTestResults((prev) => {
const next = { ...prev };
for (const key of keys) next[key] = result;
return next;
});
} finally {
setTesting((prev) => {
const next = { ...prev };
for (const key of keys) next[key] = false;
return next;
});
}
})
);
};
// 首次加载 - 从 localStorage 恢复测试状态
@@ -236,15 +281,19 @@ export default function ModelsPage() {
{provider.models.length > 0 ? (
<div className="overflow-x-auto">
{(() => {
const hasDetail = provider.models.some((m: any) => m.contextWindow || m.maxTokens);
return (
<table className="w-full text-sm">
<thead>
<tr className="text-[var(--text-muted)] text-xs border-b border-[var(--border)]">
<th className="text-left py-2 pr-4">{t("models.colModelId")}</th>
<th className="text-left py-2 pr-4">{t("models.colName")}</th>
<th className="text-left py-2 pr-4">{t("models.colContext")}</th>
<th className="text-left py-2 pr-4">{t("models.colMaxOutput")}</th>
<th className="text-left py-2 pr-4">{t("models.colInputType")}</th>
<th className="text-left py-2 pr-4">{t("models.colReasoning")}</th>
<th className="text-left py-2 pr-4">{t("models.colAccessMode")}</th>
{hasDetail && <th className="text-left py-2 pr-4">{t("models.colContext")}</th>}
{hasDetail && <th className="text-left py-2 pr-4">{t("models.colMaxOutput")}</th>}
{hasDetail && <th className="text-left py-2 pr-4">{t("models.colInputType")}</th>}
{hasDetail && <th className="text-left py-2 pr-4">{t("models.colReasoning")}</th>}
<th className="text-right py-2 pr-4">{t("models.colInputToken")}</th>
<th className="text-right py-2 pr-4">{t("models.colOutputToken")}</th>
<th className="text-right py-2 pr-4">{t("models.colAvgResponse")}</th>
@@ -260,10 +309,15 @@ export default function ModelsPage() {
return (
<tr key={m.id} className="border-b border-[var(--border)]/50">
<td className="py-2 pr-4 font-mono text-[var(--accent)]">{m.id}</td>
<td className="py-2 pr-4">{m.name}</td>
<td className="py-2 pr-4">{formatNum(m.contextWindow)}</td>
<td className="py-2 pr-4">{formatNum(m.maxTokens)}</td>
<td className="py-2 pr-4">{m.name || "-"}</td>
<td className="py-2 pr-4">
<span className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-xs">
{provider.accessMode === "auth" ? t("models.accessModeAuth") : t("models.accessModeApiKey")}
</span>
</td>
{hasDetail && <td className="py-2 pr-4">{formatNum(m.contextWindow)}</td>}
{hasDetail && <td className="py-2 pr-4">{formatNum(m.maxTokens)}</td>}
{hasDetail && <td className="py-2 pr-4">
<div className="flex gap-1">
{(m.input || []).map((inputType) => (
<span
@@ -274,8 +328,8 @@ export default function ModelsPage() {
</span>
))}
</div>
</td>
<td className="py-2 pr-4">{m.reasoning ? "✅" : "❌"}</td>
</td>}
{hasDetail && <td className="py-2 pr-4">{m.reasoning ? "✅" : "❌"}</td>}
<td className="py-2 pr-4 text-right text-blue-400 font-mono text-xs">{stat ? formatTokens(stat.inputTokens) : "-"}</td>
<td className="py-2 pr-4 text-right text-emerald-400 font-mono text-xs">{stat ? formatTokens(stat.outputTokens) : "-"}</td>
<td className="py-2 pr-4 text-right text-amber-400 font-mono text-xs">{stat ? formatMs(stat.avgResponseMs) : "-"}</td>
@@ -304,6 +358,8 @@ export default function ModelsPage() {
})}
</tbody>
</table>
);
})()}
</div>
) : (
<div>
+6
View File
@@ -132,6 +132,9 @@ const translations: Record<Locale, Record<string, string>> = {
"models.testingAll": "⏳ 测试中...",
"models.colModelId": "模型 ID",
"models.colName": "名称",
"models.colAccessMode": "接入方式",
"models.accessModeApiKey": "api_key",
"models.accessModeAuth": "auth",
"models.colContext": "上下文窗口",
"models.colMaxOutput": "最大输出",
"models.colInputType": "输入类型",
@@ -361,6 +364,9 @@ const translations: Record<Locale, Record<string, string>> = {
"models.testingAll": "⏳ Testing...",
"models.colModelId": "Model ID",
"models.colName": "Name",
"models.colAccessMode": "Access Mode",
"models.accessModeApiKey": "api_key",
"models.accessModeAuth": "auth",
"models.colContext": "Context Window",
"models.colMaxOutput": "Max Output",
"models.colInputType": "Input Type",