diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 4fe3274..f78a4ae 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -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(); + 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 = {}; + + 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, diff --git a/app/api/test-model/route.ts b/app/api/test-model/route.ts index fa9371e..2438d5d 100644 --- a/app/api/test-model/route.ts +++ b/app/api/test-model/route.ts @@ -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 = { "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 } + ); } } diff --git a/app/models/page.tsx b/app/models/page.tsx index fc9020d..9b9bb88 100644 --- a/app/models/page.tsx +++ b/app/models/page.tsx @@ -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 = {}; 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 ? (
+ {(() => { + const hasDetail = provider.models.some((m: any) => m.contextWindow || m.maxTokens); + return ( - - - - + + {hasDetail && } + {hasDetail && } + {hasDetail && } + {hasDetail && } @@ -260,10 +309,15 @@ export default function ModelsPage() { return ( - - - + + {hasDetail && } + {hasDetail && } + {hasDetail && - + } + {hasDetail && } @@ -304,6 +358,8 @@ export default function ModelsPage() { })}
{t("models.colModelId")} {t("models.colName")}{t("models.colContext")}{t("models.colMaxOutput")}{t("models.colInputType")}{t("models.colReasoning")}{t("models.colAccessMode")}{t("models.colContext")}{t("models.colMaxOutput")}{t("models.colInputType")}{t("models.colReasoning")}{t("models.colInputToken")} {t("models.colOutputToken")} {t("models.colAvgResponse")}
{m.id}{m.name}{formatNum(m.contextWindow)}{formatNum(m.maxTokens)}{m.name || "-"} + + {provider.accessMode === "auth" ? t("models.accessModeAuth") : t("models.accessModeApiKey")} + + {formatNum(m.contextWindow)}{formatNum(m.maxTokens)}
{(m.input || []).map((inputType) => ( ))}
-
{m.reasoning ? "✅" : "❌"}{m.reasoning ? "✅" : "❌"}{stat ? formatTokens(stat.inputTokens) : "-"} {stat ? formatTokens(stat.outputTokens) : "-"} {stat ? formatMs(stat.avgResponseMs) : "-"}
+ ); + })()}
) : (
diff --git a/lib/i18n.tsx b/lib/i18n.tsx index c055494..e419282 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -132,6 +132,9 @@ const translations: Record> = { "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> = { "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",