import fs from "node:fs/promises"; import path from "node:path"; const ROOT = process.env.CONFIG_CENTER_ROOT || process.cwd(); const ZENMUX_FILE = process.env.ZENMUX_FILE || "/tmp/zenmux-models.json"; const FIELD_TABLE_DIR = path.join(ROOT, "字段取值表"); const OUTPUT_DIR = path.join(ROOT, "outputs", "field-audit"); const TARGET_DIRS = [ path.join(ROOT, "compute", "providers"), path.join(ROOT, "compute", "coding-plans"), ]; const PROVIDER_OWNED_BY = { openai: ["openai"], anthropic: ["anthropic"], deepseek: ["deepseek"], google: ["google"], moonshot: ["moonshotai"], zhipu: ["z-ai"], "zhipu-embedding": ["z-ai"], dashscope: ["qwen"], minimax: ["minimax"], baidu: ["baidu"], tencent: ["tencent"], volcengine: ["bytedance", "volcengine"], xai: ["x-ai"], mistral: ["mistralai"], kwai: ["kuaishou"], lingyiwanwu: ["inclusionai"], siliconflow: ["qwen", "baai"], cohere: ["cohere"], perplexity: ["perplexity"], }; const OFFICIAL_DOCS = { openai: ["https://platform.openai.com/docs/models", "https://platform.openai.com/docs/pricing"], anthropic: ["https://docs.anthropic.com/en/docs/about-claude/models/all-models", "https://docs.anthropic.com/en/docs/about-claude/pricing"], deepseek: ["https://api-docs.deepseek.com/quick_start/pricing"], google: ["https://ai.google.dev/gemini-api/docs/models", "https://ai.google.dev/pricing"], moonshot: ["https://platform.moonshot.cn/docs/guide/overview", "https://platform.moonshot.cn/docs/pricing/chat"], zhipu: ["https://docs.bigmodel.cn/cn/guide/models/text/", "https://www.bigmodel.cn/pricing"], "zhipu-embedding": ["https://docs.bigmodel.cn/cn/guide/models/embedding"], dashscope: ["https://help.aliyun.com/zh/model-studio/getting-started/models", "https://help.aliyun.com/zh/model-studio/pricing"], minimax: ["https://platform.minimax.io/docs/api-reference/api-overview", "https://platform.minimax.io/docs/guides/pricing-paygo"], baichuan: ["https://platform.baichuan-ai.com/docs"], baidu: ["https://cloud.baidu.com/doc/qianfan/"], tencent: ["https://cloud.tencent.com/document/product/1729"], volcengine: ["https://www.volcengine.com/docs/82379"], xai: ["https://docs.x.ai/docs/models"], mistral: ["https://docs.mistral.ai/getting-started/models", "https://mistral.ai/pricing"], cohere: ["https://docs.cohere.com/docs/models", "https://cohere.com/pricing"], siliconflow: ["https://www.siliconflow.com/models", "https://siliconflow.cn/pricing"], perplexity: ["https://docs.perplexity.ai"], openrouter: ["https://openrouter.ai/models"], xunfei: ["https://www.xfyun.cn/doc/"], }; function normalize(value) { return String(value || "") .toLowerCase() .replace(/[._/]/g, "-") .replace(/[^a-z0-9-]/g, "") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function stripTail(value) { return value .replace(/-(latest|preview|exp|experimental|stable)$/g, "") .replace(/-\d{8}$/g, "") .replace(/-\d{6}$/g, "") .replace(/-\d{4}-\d{2}-\d{2}$/g, ""); } function parseZenMuxModel(model) { const [vendor, ...rest] = String(model.id || "").split("/"); const name = rest.join("/"); const norm = normalize(name); return { id: model.id, vendor, modelName: name, norm, stripped: stripTail(norm), contextWindow: model.context_length, reasoning: model.capabilities?.reasoning, prompt: model.pricings?.prompt?.[0], completion: model.pricings?.completion?.[0], }; } function jaccard(left, right) { const a = new Set(left.split("-").filter(Boolean)); const b = new Set(right.split("-").filter(Boolean)); let overlap = 0; for (const item of a) if (b.has(item)) overlap += 1; return overlap / (new Set([...a, ...b]).size || 1); } function matchModel(provider, modelName, zenModels) { const owners = PROVIDER_OWNED_BY[provider] || [provider]; const pool = zenModels.filter((model) => owners.includes(model.vendor)); const localNorm = normalize(modelName); const localStripped = stripTail(localNorm); const exact = pool.filter((model) => model.modelName === modelName); if (exact.length === 1) return { tier: "exact", matched: exact[0], candidates: exact }; const normalized = pool.filter((model) => model.norm === localNorm); if (normalized.length === 1) return { tier: "normalized", matched: normalized[0], candidates: normalized }; const stripped = pool.filter((model) => model.stripped === localStripped); if (stripped.length === 1) return { tier: "stripped", matched: stripped[0], candidates: stripped }; const candidates = pool .map((model) => ({ model, score: jaccard(localNorm, model.norm) })) .filter((item) => item.score >= 0.35) .sort((a, b) => b.score - a.score) .slice(0, 5) .map((item) => item.model); if (candidates.length === 1) return { tier: "similar", matched: candidates[0], candidates }; return { tier: candidates.length ? "ambiguous" : "none", matched: null, candidates }; } function formatValue(value) { if (value === undefined) return "(缺省)"; return `\`${JSON.stringify(value)}\``; } function formatList(values) { if (!values?.length) return "(none)"; return values.map((value) => `\`${value}\``).join("、"); } function reportFolderName(relativePath) { return relativePath .replace(/^compute\//, "") .replaceAll("/", "__") .replace(/\.json$/, ""); } async function listJsonFiles() { const files = []; for (const dir of TARGET_DIRS) { for (const fileName of await fs.readdir(dir)) { if (!fileName.endsWith(".json") || fileName.startsWith("_")) continue; files.push(path.join(dir, fileName)); } } return files.sort(); } async function readZenMuxModels() { try { const raw = JSON.parse(await fs.readFile(ZENMUX_FILE, "utf8")); return (raw.data || []).map(parseZenMuxModel); } catch { return []; } } function buildFieldRows(doc, model, match) { const z = match.matched; return [ { field: "modelName", current: model.modelName, suggested: z ? z.modelName : model.modelName, decision: z && ["exact", "normalized"].includes(match.tier) ? "保持" : "待确认", reason: z ? `ZenMux匹配(${match.tier}): ${z.id}` : "ZenMux无稳定匹配,需官方文档确认", }, { field: "displayName", current: model.displayName, suggested: model.displayName, decision: "保持", reason: "展示字段,以本项目产品命名策略为准", }, { field: "serviceType", current: model.serviceType, suggested: model.serviceType, decision: "保持", reason: "服务路由字段,以本项目分类约定为准", }, { field: "contextWindow", current: model.contextWindow, suggested: z?.contextWindow ?? model.contextWindow, decision: typeof z?.contextWindow === "number" ? "待确认" : "待确认", reason: typeof z?.contextWindow === "number" ? `ZenMux给出context_length=${z.contextWindow},最终以官方模型规格页确认` : "ZenMux无context可用,需官方规格页确认", }, { field: "maxOutputTokens", current: model.maxOutputTokens, suggested: model.maxOutputTokens, decision: "待确认", reason: "ZenMux列表未提供统一max output字段,需官方模型详情页确认", }, { field: "inputPrice", current: model.inputPrice, suggested: model.inputPrice, decision: "待确认", reason: `当前文件币种为${doc.priceCurrency || "USD"},价格需官方价格页确认`, }, { field: "outputPrice", current: model.outputPrice, suggested: model.outputPrice, decision: "待确认", reason: `当前文件币种为${doc.priceCurrency || "USD"},价格需官方价格页确认`, }, { field: "capabilities", current: model.capabilities, suggested: model.capabilities, decision: "保持", reason: "能力标签是本项目语义字段,不直接由第三方列表覆盖", }, { field: "defaultTemperature", current: model.defaultTemperature, suggested: model.defaultTemperature, decision: model.defaultTemperature === null ? "建议修改" : "保持", reason: model.defaultTemperature === null ? "schema拒绝null;不支持采样参数时应省略字段" : "当前值与schema兼容;是否采用默认采样值需官方文档确认", }, { field: "defaultTopP", current: model.defaultTopP, suggested: model.defaultTopP, decision: model.defaultTopP === null ? "建议修改" : "保持", reason: model.defaultTopP === null ? "schema拒绝null;不支持采样参数时应省略字段" : "当前值与schema兼容;是否采用默认采样值需官方文档确认", }, { field: "extra", current: model.extra, suggested: model.extra, decision: "待确认", reason: "扩展字段为本项目schema,需按业务含义和官方补充信息复核", }, ]; } async function main() { const generatedAt = new Date().toISOString(); const zenModels = await readZenMuxModels(); const files = await listJsonFiles(); await fs.mkdir(FIELD_TABLE_DIR, { recursive: true }); await fs.mkdir(OUTPUT_DIR, { recursive: true }); const allModels = []; const skipped = []; const pendingByProvider = new Map(); const fieldIndex = []; const nullSampling = []; for (const absolutePath of files) { const relativePath = path.relative(ROOT, absolutePath); const doc = JSON.parse(await fs.readFile(absolutePath, "utf8")); const provider = doc.provider || path.basename(absolutePath, ".json"); const outDir = path.join(FIELD_TABLE_DIR, reportFolderName(relativePath)); await fs.mkdir(outDir, { recursive: true }); const detailed = [ `# 详细字段取值表 - ${relativePath}`, "", `- provider: \`${provider}\``, `- priceCurrency: \`${doc.priceCurrency || "USD"}\``, `- generatedAt: \`${generatedAt}\``, "", "## 来源", "", ...(OFFICIAL_DOCS[provider] || ["(待补充provider官方文档)"]).map((item) => `- ${item}`), "- https://zenmux.ai/models", "- https://zenmux.ai/api/v1/models", "", ]; const unresolved = [ `# 未确认字段报告 - ${relativePath}`, "", `- provider: \`${provider}\``, `- generatedAt: \`${generatedAt}\``, "", ]; const models = Array.isArray(doc.models) ? doc.models : []; for (const model of models) { const match = matchModel(provider, model.modelName, zenModels); const rows = buildFieldRows(doc, model, match); const status = match.matched ? `matched-${match.tier}` : match.tier === "ambiguous" ? "ambiguous" : "no-match"; allModels.push({ file: relativePath, provider, modelName: model.modelName, displayName: model.displayName, serviceType: model.serviceType, capabilities: model.capabilities, contextWindow: model.contextWindow, maxOutputTokens: model.maxOutputTokens, inputPrice: model.inputPrice, outputPrice: model.outputPrice, defaultTemperature: model.defaultTemperature, defaultTopP: model.defaultTopP, matchTier: match.tier, zenmux: match.matched?.id ?? null, }); if (model.defaultTemperature === null) nullSampling.push(`${relativePath}::${model.modelName}::defaultTemperature`); if (model.defaultTopP === null) nullSampling.push(`${relativePath}::${model.modelName}::defaultTopP`); skipped.push({ file: relativePath, provider, modelName: model.modelName, reason: status, zenmux: match.matched?.id ?? null, candidates: match.candidates.map((item) => item.id), }); if (!match.matched || match.tier === "ambiguous") { const bucket = pendingByProvider.get(provider) || []; bucket.push({ file: relativePath, modelName: model.modelName, reason: match.tier === "ambiguous" ? `ambiguous match candidates: ${match.candidates.map((item) => item.id).join(",")}` : "no ZenMux match", }); pendingByProvider.set(provider, bucket); } detailed.push(`## ${model.modelName}`); detailed.push(""); detailed.push(`- ZenMux匹配级别: \`${match.tier}\``); if (match.matched) detailed.push(`- ZenMux命中: \`${match.matched.id}\``); detailed.push(`- ZenMux候选: ${formatList(match.candidates.map((item) => item.id))}`); detailed.push(""); detailed.push("| 字段 | 当前值 | 建议值 | 结论 | 依据/说明 |"); detailed.push("|---|---|---|---|---|"); for (const row of rows) { detailed.push(`| \`${row.field}\` | ${formatValue(row.current)} | ${formatValue(row.suggested)} | ${row.decision} | ${row.reason} |`); } detailed.push(""); const pendingRows = rows.filter((row) => row.decision === "待确认" || row.decision === "建议修改"); if (pendingRows.length) { unresolved.push(`## ${model.modelName}`); unresolved.push(""); for (const row of pendingRows) unresolved.push(`- \`${row.field}\`: ${row.reason}`); unresolved.push(""); } } if (unresolved.length === 4) { unresolved.push("- (none)"); unresolved.push(""); } const detailedFile = path.join(outDir, "详细字段取值表.md"); const unresolvedFile = path.join(outDir, "未确认字段报告.md"); await fs.writeFile(detailedFile, `${detailed.join("\n")}\n`, "utf8"); await fs.writeFile(unresolvedFile, `${unresolved.join("\n")}\n`, "utf8"); fieldIndex.push({ file: relativePath, provider, modelCount: models.length, folder: path.relative(ROOT, outDir), detailed: path.relative(ROOT, detailedFile), unresolved: path.relative(ROOT, unresolvedFile), }); } const matchedModels = allModels.filter((item) => item.zenmux).length; const summary = { actualModels: allModels.length, sourceModels: zenModels.length, matchedModels, unmatchedModels: allModels.filter((item) => item.matchTier === "none").length, ambiguousModels: allModels.filter((item) => item.matchTier === "ambiguous").length, nullSamplingFields: nullSampling.length, updatedModels: 0, updatedFiles: 0, skippedModels: allModels.length, }; await fs.writeFile( path.join(FIELD_TABLE_DIR, "目录索引.json"), `${JSON.stringify(fieldIndex, null, 2)}\n`, "utf8", ); const reportJson = { source: ZENMUX_FILE, generatedAt, summary, updated: [], skipped, models: allModels, nullSampling, }; await fs.writeFile( path.join(OUTPUT_DIR, "zenmux-sync-report.json"), `${JSON.stringify(reportJson, null, 2)}\n`, "utf8", ); const reportLines = [ "# ZenMux Sync Report", "", `- source: \`${ZENMUX_FILE}\``, `- generatedAt: \`${generatedAt}\``, `- actualModels: **${summary.actualModels}**`, `- sourceModels: **${summary.sourceModels}**`, `- matchedModels: **${summary.matchedModels}**`, `- unmatchedModels: **${summary.unmatchedModels}**`, `- ambiguousModels: **${summary.ambiguousModels}**`, `- nullSamplingFields: **${summary.nullSamplingFields}**`, `- updatedModels: **${summary.updatedModels}**`, `- updatedFiles: **${summary.updatedFiles}**`, "", "## Field Coverage", "", "- synced: none; this audit run is read-only and does not mutate JSON config.", "- reviewed from JSON: `modelName`, `displayName`, `serviceType`, `contextWindow`, `maxOutputTokens`, `inputPrice`, `outputPrice`, `capabilities`, `defaultTemperature`, `defaultTopP`, `extra`.", "- guardrail: `defaultTemperature` and `defaultTopP` must be omitted or numbers; `null` is invalid.", "", "## Updated Models", "", "- (none)", "", "## Skipped / Reviewed Models", "", ...skipped.map((item) => { const detail = item.zenmux ? `${item.reason}:${item.zenmux}` : item.candidates.length ? `${item.reason}:${item.candidates.join(",")}` : item.reason; return `- ${item.file} :: ${item.modelName} -> ${detail}`; }), "", ]; await fs.writeFile(path.join(OUTPUT_DIR, "zenmux-sync-report.md"), `${reportLines.join("\n")}\n`, "utf8"); const checklist = [ "# Official Review Checklist", "", `GeneratedAt: ${generatedAt}`, `Source: ${path.join(OUTPUT_DIR, "zenmux-sync-report.json")}`, "", "Purpose: verify unresolved models with official vendor docs before write-back.", "", "## Summary", "", `- actual models: **${summary.actualModels}**`, `- unresolved models: **${summary.unmatchedModels + summary.ambiguousModels}**`, `- providers involved: **${pendingByProvider.size}**`, `- null sampling fields: **${summary.nullSamplingFields}**`, "", ]; for (const [provider, items] of [...pendingByProvider.entries()].sort(([a], [b]) => a.localeCompare(b))) { checklist.push(`## Provider: ${provider}`); checklist.push("- official docs:"); for (const doc of OFFICIAL_DOCS[provider] || ["(add link manually)"]) checklist.push(` - ${doc}`); checklist.push("- pending models:"); for (const item of items) checklist.push(` - \`${item.modelName}\` (${item.file}) -> ${item.reason}`); checklist.push(""); } checklist.push("## Field-by-Field Verification Rule"); checklist.push(""); checklist.push("- `modelName`: must exactly match provider API model ID or official alias rule."); checklist.push("- `contextWindow`: use official model spec limit."); checklist.push("- `maxOutputTokens`: use official output cap; do not infer from context."); checklist.push("- `inputPrice`/`outputPrice`: use official published API pricing and the provider currency."); checklist.push("- `capabilities`: project taxonomy field; keep local semantics unless official docs clearly contradict the capability."); checklist.push("- `defaultTemperature`/`defaultTopP`: only fill with numeric values when provider docs define a safe default; omit unsupported fields instead of using `null`."); await fs.writeFile(path.join(OUTPUT_DIR, "official-review-checklist.md"), `${checklist.join("\n")}\n`, "utf8"); const riskLines = [ "# ZenMux Risk Audit", "", `GeneratedAt: ${generatedAt}`, `Source: ${path.join(OUTPUT_DIR, "zenmux-sync-report.json")}`, "", `- HIGH: ${summary.nullSamplingFields > 0 ? 1 : 0}`, `- MEDIUM: ${summary.ambiguousModels}`, `- LOW: ${summary.unmatchedModels}`, "", "## Notes", "", "- HIGH covers schema-breaking sampling defaults such as `null`.", "- MEDIUM covers ambiguous third-party model matches.", "- LOW covers models without a ZenMux match; these require official-doc review but do not by themselves indicate JSON invalidity.", ]; await fs.writeFile(path.join(OUTPUT_DIR, "zenmux-risk-audit.md"), `${riskLines.join("\n")}\n`, "utf8"); const detailedLines = [ "# ZenMux Sync Detailed Report", "", `GeneratedAt: ${generatedAt}`, "", "## Summary", "", `- actualModels: ${summary.actualModels}`, `- sourceModels: ${summary.sourceModels}`, `- matchedModels: ${summary.matchedModels}`, `- unmatchedModels: ${summary.unmatchedModels}`, `- ambiguousModels: ${summary.ambiguousModels}`, `- nullSamplingFields: ${summary.nullSamplingFields}`, "", "## Current Model Inventory", "", "| File | Provider | Model | Match | ZenMux |", "|---|---|---|---|---|", ...allModels.map((item) => `| ${item.file} | ${item.provider} | \`${item.modelName}\` | ${item.matchTier} | ${item.zenmux ? `\`${item.zenmux}\`` : ""} |`), ]; await fs.writeFile(path.join(OUTPUT_DIR, "zenmux-sync-detailed.md"), `${detailedLines.join("\n")}\n`, "utf8"); console.log(JSON.stringify({ generatedAt, summary, fieldTableFiles: fieldIndex.length }, null, 2)); } await main();