fix(env-sync): 同步 provider_config.json(UI 分组/顺序/模型级配置)
问题:只同步 v2/config.json 时,只在 v2/provider_config.json 里存在的 provider 分组到对面就是缺失的 —— 表现为「provider 数量对,但 UI 分组少几个」。 实测:9 个分组里有 4 个(GINKA API / zxcbug / zxcbug-p / d1)纯规则型, 密钥只存在 provider_config.json 的 config.access.apiKey 里,旧实现完全没收集。 修复: - sync-core:新增 V2_PROVIDER_CONFIG、extract/sanitize/collectProviderConfigSecrets、 providerConfigKeyManifest;密钥命名空间 PROVIDER_CONFIG/<providerId>/<key> - export:导出 v2.provider-config.json(脱敏),密钥并入加密束,manifest 记摘要与哈希 - import:按 providerId 合并规则、回填密钥空位、按 (providerId,modelId) 合并模型级配置、 providerOrder 以快照为准并保留本机特有分组;重复导入幂等 - import:provider_config 路径补「护住本机已有密钥 N 个」提示(原来只在 config.json 侧有) - 测试:单元 6 项 + E2E 2 项(分组完整同步/强制覆盖),全量 72 项通过
This commit is contained in:
@@ -16,6 +16,8 @@ import {
|
||||
isHiddenEntry, copyDirFiltered,
|
||||
collectModelSecrets, sanitizeProviders, summarizeProviders,
|
||||
extractModelSecrets, isModelEntry, parseModelRel,
|
||||
isProviderConfigEntry, parseProviderConfigRel, extractProviderConfigSecrets,
|
||||
collectProviderConfigSecrets, sanitizeProviderConfig, providerConfigKeyManifest,
|
||||
} from "../scripts/sync-core.mjs";
|
||||
|
||||
/* ---------------- 路径占位符 ---------------- */
|
||||
@@ -322,3 +324,82 @@ test("HOME/USER 常量与 os 一致", () => {
|
||||
assert.equal(HOME, os.homedir());
|
||||
assert.equal(USER, path.basename(os.homedir()));
|
||||
});
|
||||
|
||||
/* ---------------- provider_config.json:UI 分组/顺序/模型级配置 ---------------- */
|
||||
|
||||
/** 造一份带 2 个分组的 provider_config(其中一个只在它这里存在) */
|
||||
function sampleProviderConfig() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
config: {
|
||||
providerOrder: ["p-shared", "new-provider"],
|
||||
providerConfigRules: { providerRules: [
|
||||
{ providerId: "p-shared", providerName: "zz",
|
||||
config: { group: "standard-personal", access: { type: "api-key", apiKey: "sk-sharedkey-1234567890" },
|
||||
api: { type: "openai-chat-completions", baseUrl: "http://example.invalid/v1" },
|
||||
personalModelIds: ["m1"], modelOrder: ["m1"] } },
|
||||
{ providerId: "new-provider", providerName: "GINKA API",
|
||||
config: { group: "standard-personal", access: { type: "api-key", apiKey: "sk-onlyhere-0987654321" },
|
||||
api: { type: "openai-chat-completions", baseUrl: "http://example.invalid/v2" },
|
||||
personalModelIds: ["m2"], modelOrder: ["m2"] } },
|
||||
] },
|
||||
modelConfigRules: {
|
||||
providerModelRules: [{ modelId: "m1", config: { properties: { contextWindow: 1000000 } }, providerId: "p-shared" }],
|
||||
manualProviderModelRules: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("provider_config:密钥路径前缀与解析", () => {
|
||||
assert.equal(isProviderConfigEntry("PROVIDER_CONFIG/pid/apiKey"), true);
|
||||
assert.equal(isProviderConfigEntry("MODELS/pid/apiKey"), false);
|
||||
assert.deepEqual(parseProviderConfigRel("PROVIDER_CONFIG/pid/apiKey"), { provider: "pid", key: "apiKey" });
|
||||
assert.throws(() => parseProviderConfigRel("PROVIDER_CONFIG/pid"), /非法/);
|
||||
assert.throws(() => parseProviderConfigRel("PROVIDER_CONFIG//apiKey"), /非法/);
|
||||
});
|
||||
|
||||
test("provider_config:抽取密钥(含只在它这里存在的分组)", () => {
|
||||
const keys = extractProviderConfigSecrets(sampleProviderConfig());
|
||||
assert.equal(keys.length, 2, "两个分组的密钥都要抽到");
|
||||
assert.deepEqual(keys.map((k) => k.provider).sort(), ["new-provider", "p-shared"]);
|
||||
assert.ok(keys.some((k) => k.value === "sk-onlyhere-0987654321"), "只在 provider_config 的分组密钥不能漏");
|
||||
});
|
||||
|
||||
test("provider_config:脱敏后明文区无真密钥,占位符就位", () => {
|
||||
const { config, withSecrets } = sanitizeProviderConfig(sampleProviderConfig());
|
||||
const text = JSON.stringify(config);
|
||||
assert.ok(!text.includes("sk-sharedkey-1234567890"), "不得残留真密钥");
|
||||
assert.ok(!text.includes("sk-onlyhere-0987654321"), "不得残留真密钥");
|
||||
assert.equal(text.match(/\$\{MODEL_SECRET_REF\}/g)?.length, 2, "两处都要换成占位符");
|
||||
assert.deepEqual(withSecrets.sort(), ["new-provider", "p-shared"]);
|
||||
// 非密钥字段必须原样保留(分组/顺序/模型级配置是这次修复的重点)
|
||||
assert.deepEqual(config.config.providerOrder, ["p-shared", "new-provider"], "分组顺序要保留");
|
||||
assert.equal(config.config.providerConfigRules.providerRules[0].config.group, "standard-personal", "group 要保留");
|
||||
assert.deepEqual(config.config.providerConfigRules.providerRules[0].config.personalModelIds, ["m1"], "模型清单要保留");
|
||||
assert.equal(config.config.modelConfigRules.providerModelRules[0].config.properties.contextWindow, 1000000, "模型级配置要保留");
|
||||
});
|
||||
|
||||
test("provider_config:脱敏不破坏原对象(纯函数)", () => {
|
||||
const src = sampleProviderConfig();
|
||||
sanitizeProviderConfig(src);
|
||||
assert.equal(src.config.providerConfigRules.providerRules[0].config.access.apiKey, "sk-sharedkey-1234567890", "原对象不应被改写");
|
||||
});
|
||||
|
||||
test("provider_config:清单只记哈希不记值", () => {
|
||||
const man = providerConfigKeyManifest(extractProviderConfigSecrets(sampleProviderConfig()));
|
||||
const text = JSON.stringify(man);
|
||||
assert.ok(!text.includes("sk-sharedkey"), "清单里不得有明文");
|
||||
assert.ok(!text.includes("sk-onlyhere"), "清单里不得有明文");
|
||||
assert.equal(man.length, 2);
|
||||
for (const m of man) assert.match(m.rel, /^PROVIDER_CONFIG\/.+\/apiKey$/);
|
||||
});
|
||||
|
||||
test("provider_config:空值与缺字段不炸", () => {
|
||||
assert.deepEqual(extractProviderConfigSecrets(undefined), []);
|
||||
assert.deepEqual(extractProviderConfigSecrets({}), []);
|
||||
assert.deepEqual(extractProviderConfigSecrets({ config: { providerConfigRules: { providerRules: [{ providerId: "x" }] } } }), []);
|
||||
assert.deepEqual(collectProviderConfigSecrets(null), []);
|
||||
const { config } = sanitizeProviderConfig({ config: { providerOrder: ["a"] } });
|
||||
assert.deepEqual(config.config.providerOrder, ["a"], "无 rules 时也要原样返回");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user