mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
Fix model list auth merge and unify model testing via probe
This commit is contained in:
+79
-2
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user