Cherry Studio 数据迁移实战:ProviderModelMigrator 如何把 Redux 模型状态搬进 SQLite v2
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
导读
Cherry Studio 的 v2 架构将原来存放在 Redux 中的 Provider/Model 状态迁入 SQLite 的user_provider与user_model表,ProviderModelMigrator正是完成这一动作的专职 migrator。本文围绕该 migrator 的职责、投影(projection)算法、字段映射、数据质量处理与三阶段执行流程展开,结合仓库源码与测试用例,帮助你理解"增量(delta)优先"的迁移设计如何在不确定的旧数据与不断演进的 registry 之间安全地搬运用户配置,并掌握其可复用的实现模式。
一、定位:v2 迁移管线中的第 6 号 migrator
ProviderModelMigrator是 Cherry Studio v2 数据迁移管线中负责"Provider/Model 域"的专用 migrator。在 migratorRegistry.ts 中,它排在BootConfigMigrator、PreferencesMigrator、NoteMigrator、MiniAppMigrator、McpServerMigrator之后、AssistantMigrator之前执行,元数据如下(见 ProviderModelMigrator.ts):
id:provider_modelname:Provider Modelorder:1.75(数值越小越先执行)
它继承自 BaseMigrator.ts,遵循统一的prepare → execute → validate三阶段生命周期:
| 阶段 | 职责 | 失败返回 |
|---|---|---|
prepare | 读取 Redux/Dexie 源数据,过滤脏数据、去重、统计条目 | provider_model_prepare_failed |
execute | 在一个withWriteTx同步事务内插入 provider/model/pin 行 | provider_model_execute_failed |
validate | 核对源/目标行数、抽查 API Key 迁移完整性 | provider_model_validate_failed |
三个阶段的错误 ID 常量定义在 ProviderModelMigrator.ts,任何一阶段失败都会在返回的error字段中以errorId: message的格式暴露给迁移引擎。
1.1 四类数据源
迁移的数据来源横跨 Redux 与 Dexie 两个旧存储,官方文档给出的来源如下:
| 数据 | 来源 |
|---|---|
| Providers 与 Models | Reduxstate.llm.providers[] |
| Provider/Model 设置 | Reduxstate.llm.settings |
| 置顶模型(Pinned models) | Dexiepinned:models |
| Provider Logo | Dexieimage://provider-<providerId> |
在源码中,prepare()通过ctx.sources.reduxState.getCategory<LlmState>('llm')读取providers与settings,通过ctx.sources.dexieSettings.get('pinned:models')读取置顶模型、通过ctx.sources.dexieSettings.get(\image://provider-${provider.id}`)` 读取 Logo(ProviderModelMigrator.ts)。
一个需要特别注意的设计:受管理的 CherryAI 行不会被从 Redux 复制。prepare()会跳过isManagedCherryProviderId(provider.id)命中的行(即cherryai/cherry-cloud),因为 v2 seeder(ensureCherryAiDefaultProviderAndModelTx)已经负责写入规范化的 CherryAI provider 与默认模型,旧有的 CherryAI 置顶模型会被改写为指向该 seeded 模型(CHERRYAI_DEFAULT_UNIQUE_MODEL_ID)。测试 ProviderModelMigrator.test.ts 验证了"旧 CherryAI qwen 置顶 → 重写到 seeded 默认模型"的行为。
附带约束:由于本 migrator 独家负责
pinned:models的一次性迁移(写入pin表、entityType='model'),该键不能再被 classification.json 标记为普通偏好项,否则同一份数据会被写入两次。这个约定写在 ProviderModelMigrator.ts 的文件头注释里。
二、核心难点:Preset 归属投影(Ownership Projection)
v2 的设计中,preset 支撑的行是增量(delta),不是快照(snapshot)。也就是说:一行user_provider/user_model只存储用户相对出厂默认值的差异,其余字段由运行时从当前 registry 解析。这意味着迁移器必须回答一个棘手问题:
如何区分"v1 出厂默认值"与"用户手动改过的值"?
答案是不能查当前 v2 registry——因为 registry 本身可能已经变了。文档明确给出了方案:
mappings/v1-provider-model-baseline.json是钉死的 final-v1 基线,提取自 v1 修订版d316ec5345680f1de511fd6df3a7fbdb3edad151(v1.9.12),只包含 provider/model 投影所需的字段。sourceRevision字段记录了该出处。其中的 provider API 标志表示的是post-migration-127/129 的 Redux 形态:三个兼容性标志在apiOptions被清空后仍保留在 system-provider 顶层行上。不要用当前的providers.json、models.json或provider-models.json替代此基线做比较。
基线文件 v1-provider-model-baseline.json 以sourceRevision+providers[providerId]组织,每个 provider 包含type、apiHost、anthropicApiHost、isNotSupportDeveloperRole、isNotSupportStreamOptions以及按 model id 索引的models快照。源码中对应的 TypeScript 接口V1ProviderModelBaseline/V1ProviderBaseline/V1ModelBaseline定义在 ProviderModelMigrator.ts。
2.1 投影规则详解
文档归纳了 6 条投影规则,逐条拆解如下(对照 ProviderModelMigrator.ts 的projectProviderDeltaRow/projectModelDeltaRow):
- 自定义 provider 整体保留:
presetProviderId = null的 provider 是用户自定义的,其映射出的 provider 配置全部保留为行自有值。其模型仍可能独立命中全局models.json元数据,但由于没有有效 preset provider,provider-model override 不可用。 - preset provider 解析顺序:先按
providerId解析,再按presetProviderId解析。第二个查找是必须的——像 Azure OpenAI 这类自定义 ID 但挂靠已知 provider 类型的实例(例如 UUID 形式的0196f996-34fc-7e3f-96d0-10b7f55fd6c8,type='azure-openai'),需要回退到 preset id 才能找到 registry 中的预设。对应resolveEffectivePresetProvider()(ProviderModelMigrator.ts)与映射层resolvePresetProviderId()(ProviderModelMappings.ts)。 - 逐字段比较、逐键比较 API 特性:provider 字段与 final-v1 基线相等则置 null/缺席;API 特性(如
streamOptions、developerRole)按 key 逐个比较,一个特性被改动不会冻结其余与基线相等的兄弟标志;不同的值才作为行自有 delta 保留。 - preset 关联的自定义 provider ID 使用 final-v1 自定义 provider API 特性基线:非系统 provider 由 v1 迁移 127/129/132 物化出默认方言
{ streamOptions: true, developerRole: false }(源码常量V1_CUSTOM_PROVIDER_DIALECT_BASELINE,ProviderModelMigrator.ts)。这样既保留了 v1 迁移后显式的特性选择,又丢弃了未动过的自定义默认值。 - 模型元数据匹配顺序:模型先使用有效 provider 的 provider-model override,再匹配全局
models.json元数据;全局匹配同样适用于完全自定义的 provider。若有效 provider 的 override 在全局元数据中无对应项,则 migrator合成一个与运行时一致的 provider-exclusive preset(synthesizePresetFromOverride)。 - preset 模型字段的稀疏规则:与 final-v1 模型相等的字段置 null;每个非 null 稀疏列在读取时都是权威的,不存在单独的 ownership 标记。
此外还有两个边界情形:
- 若当前存在 preset,但模型不在 final-v1 基线中,则普通遗留字段没有可证明的用户出处,保持 null;但显式的
capabilities[].isUserSelected选择会保留。endpointTypes在 registry 无法重新推导旧路由元数据时也会保留——包括内置 NewAPI provider 的动态模型、以及type='new-api'的自定义 provider;CherryIN 模型在没有旧端点元数据时,会显式恢复其前缀路由(anthropic/→ Anthropic Messages、google/→ Google Generate Content、否则 OpenAI-compatible 回退),对应inferCherryInEndpointTypes()(ProviderModelMigrator.ts)。 - v1 编辑器合成的
0/0定价值等价于"缺席 final-v1 价格",会在定价归一化时丢弃(matchesModelPricingBaseline只比较真实的非 0/0 价格)。
2.2 为什么必须钉死基线而不是用当前 registry
测试 ProviderModelMigrator.test.ts 给出了一个极佳的例子:当前 registry 中 OpenAI 的 chat 端点恰好也使用/v1后缀,但归属判断必须对照钉死的 final-v1 快照(其记录为https://api.openai.com)。因此遗留代理地址https://my-proxy.com/v1被判定为用户所有并写入行;而defaultChatEndpoint与基线相等则置 null,由运行时读取补齐目录事实。这验证了文档的警告:拿今天的 registry 与历史数据比较,无法区分"历史默认值"和"用户编辑"。
文档还给出了更新基线的操作规范:使用已发布的 final-v1 源修订版而非 v2 registry 快照;只保留V1ModelBaseline/V1ProviderBaseline声明的遗留字段;更新sourceRevision;并运行覆盖"基线相等值、真实 delta、自定义 preset ID、provider-exclusive 模型"的 migrator 测试。
三、目标表与字段映射
3.1user_provider表映射
文档给出的映射表如下,标注了每列的转换规则:
| v1 源 | v2 目标 | 转换 |
|---|---|---|
id | providerId | 直接映射;先过滤非法与重复 ID |
id/type | presetProviderId | 已知系统 ID 使用目录 ID;受支持的自定义 provider 类型链接到其 preset |
name | name | 直接保留用户自有值 |
apiHost、anthropicApiHost | endpointConfigs | 转换为按端点键的 baseURL,然后对 preset 行投影 final-v1 默认值 |
type | defaultChatEndpoint | 通过遗留端点映射表转换,然后只存储 final-v1 delta |
apiKey、认证设置 | apiKeys、authConfig | 归一化密钥与 provider 特定凭据 |
| 遗留 API option 标志 | apiFeatures | 转换受支持的标志,然后只存储 final-v1 delta |
| provider 设置 | providerSettings | 归一化 provider 特定用户设置 |
enabled | isEnabled | 默认 true |
在映射层 ProviderModelMappings.ts 中有若干值得展开的实现细节:
- 端点映射表
ENDPOINT_MAP(L62-L74)把遗留字符串端点/类型键映射为EndpointType枚举,例如openai→OPENAI_CHAT_COMPLETIONS、openai-response→OPENAI_RESPONSES、anthropic→ANTHROPIC_MESSAGES、gemini/vertexai→GOOGLE_GENERATE_CONTENT、ollama→OLLAMA_CHAT等;无法识别且非aws-bedrock的类型会记警告后丢弃。 - API Key 归一化:
buildApiKeys会把逗号分隔的 v1 密钥串拆成多个{ id: uuidv4(), key, isEnabled: true }条目;AWS Bedrock 在authType === 'apiKey'时从settings.awsBedrock.apiKey取密钥。 - 认证配置
buildAuthConfig:Vertex(iam-gcp,含 project/location/serviceAccount)、AWS Bedrock(api-key-aws/iam-aws)、Azure OpenAI(iam-azure+ apiVersion)、CherryIN OAuth、以及普通api-key。遗留 Anthropic Web OAuth 被端到端移除——token 原本存放在独立的凭据文件中、已不再读取,因此此类 provider 会被重置回api-key认证路径,避免 v2 行落入不可恢复状态。 - provider 设置:
keepAliveTime(ollama/lmstudio/gpustack)、rateLimit、extraHeaders(来自extra_headers)、notes、cacheControl(来自anthropicCacheControl)会被归一化进providerSettings。
3.2user_model表映射
| v1 源 | v2 目标 | 转换 |
|---|---|---|
provider ID + modelid | id、providerId、modelId | 构建确定性providerId::modelId身份 |
| 有效 registry 匹配 | presetModelId | 全局 preset(与 provider 出处无关)或合成的 provider-exclusive preset;未匹配的自定义模型为 null |
name、description、group | 同名可空列 | 自定义行完整值;preset 行为 final-v1 delta |
capabilities | capabilities | 归一化能力名;保留显式isUserSelected选择 |
endpoint_type、supported_endpoint_types | endpointTypes | 归一化遗留端点别名 |
supported_text_delta | supportsStreaming | 自定义行默认 true;preset 行为 final-v1 delta |
pricing | pricing | 归一化为运行时定价;丢弃合成的空0/0回显 |
| 源顺序 | orderKey | 在每个 provider 内分配分数键 |
实现要点:
- 模型唯一 ID 通过
createUniqueModelId(providerId, modelId)生成,非法 ID 返回 null 并在 prepare 阶段被剔除(ProviderModelMigrator.ts)。 - capability 映射:
CAPABILITY_MAP把 v1 类型映射到 v2 能力(vision→IMAGE_RECOGNITION、reasoning→REASONING、function_calling→FUNCTION_CALL、embedding→EMBEDDING、rerank→RERANK),text/web_search除外;用户显式关闭的能力(isUserSelected === false)优先于重复的启用条目,且不会被端点隐含能力重新加回(ProviderModelMappings.ts)。 - pricing 映射:只迁移 v2 定价契约能无损表达的货币——
$→ USD、¥/¥→ CNY,其余货币记警告后丢弃;输出{ input: { perMillionTokens, currency }, output: { perMillionTokens, currency } }(ProviderModelMappings.ts)。 - orderKey:provider 顺序键在 seeded CherryAI 行之后生成(
generateOrderKeySequenceBetween),模型顺序键按 provider 作用域分配(assignOrderKeysByScope),保证 UI 排序稳定。
四、有意丢弃或重新推导的数据
文档列出了一批"不落库"的数据,这背后是"读时解析(read-time resolution)"的设计哲学:
- preset 模型无 delta 的字段不存:由当前 registry 在读取时解析;不存储
userOverrides所有权数组。 - registry 独有的 provider 端点字段不复制进 preset 行:如
modelsApiUrls、adapterFamily。但迁移后的自定义 relay 可能保留 main-only 的adapterFamily提示——因为没有目录能重新推导它。测试 ProviderModelMigrator.test.ts 覆盖了小米 MIMO 这种自定义 relay 的回填场景:无目录匹配时运行时推断 anthropic 协议族,避免 resolver 回退到 openai-compatible 导致 404。 - preset 的
inputModalities、outputModalities、token 限制、reasoning、参数支持不在迁移期从当前 registry 推断;null 委托给读时解析。 - 遗留
isNotSupportEnableThinking没有 v2ApiFeatures目标,直接丢弃。 - 遗留 Anthropic Web OAuth token 在源外,不可恢复,对应 provider 回到 API-key 认证路径。
- 损坏标识符、重复行(保留首次出现)、retired providers、无效 pin 引用、缺失的可选 Logo 均按下一节规则处理。
五、端点路由边界(Endpoint Routing Boundary)
文档用一小节明确了路由数据的职责划分:
Renderer/API 端点写入只包含用户可编辑的
baseUrl值。遗留adapterFamily路由出处可能存在于主进程存储形态的自定义 provider 中,但它不是共享写入 DTO 的一部分。Preset provider 的路由族一律取自当前 registry。
也就是说:v2 的端点配置契约刻意把"用户能改的"(baseUrl)与"系统推导的"(adapterFamily 等路由元数据)分离。迁移时对 catalog 匹配的系统 provider,projectProviderDeltaRow会把 endpointConfigs 削减到只剩 baseUrl 级别的 delta;而对无目录匹配的自定义 provider,才保留LEGACY_TYPE_TO_ADAPTER_FAMILY推导出的路由提示(openai-compatible/openai/anthropic/google/newapi/gateway/ollama,见 ProviderModelMappings.ts),并且 Anthropic Messages 端点刻意跳过该提示——v1 自定义 anthropic relay 即使端点说 anthropic 协议、type也常为openai,端点协议必须优先。
六、数据质量处理清单
prepare()阶段集中完成脏数据过滤,文档给出如下处理矩阵:
| 问题 | 处理 |
|---|---|
| provider ID 缺失/为空 | 跳过并警告 |
| provider ID 重复 | 保留第一个并警告 |
| model ID 缺失、为空或不安全(route-unsafe) | 跳过并警告 |
| 候选 model ID 全部非法 | 保留 provider、剔除非法模型、汇总警告 |
| provider 内 model ID 重复 | 保留第一个并警告 |
| Retired provider | 跳过并警告 |
| 缺失 final-v1 preset 基线 | 保守保留映射后的 provider 值并警告 |
| 无效的置顶模型引用 | 丢弃 |
| 缺失可选 Logo | 保留 provider 但不带 Logo |
源码中的实现细节值得注意:
- 提前过滤的意义:缺失/空
providerId若不过滤,会以空字符串主键落进user_provider(SQLite 文本主键允许''),从而遮蔽整个 v2 数据层的查找(ProviderModelMigrator.ts 的注释)。 - route-unsafe model id:
createUniqueModelId会拒绝含@、#等路由不安全字符的 ID(测试用例jackrong-qwopus3.5-27b-v3@?、legacy-model#fragment均被剔除,且不影响同一 provider 的其余模型迁移,见 ProviderModelMigrator.test.ts)。 - 置顶模型归一化:
normalizePinnedModelId支持对象形态{id, provider}、JSON 字符串、provider/model、provider::model、以及裸provider::model唯一 ID 五种格式,并会与迁移后的有效模型集合做交叉校验去重(ProviderModelMigrator.ts)。测试验证了旧顺序被保留、非法引用被丢弃(ProviderModelMigrator.test.ts)。
6.1 事务与 Logo 处理的联动
provider 行与 model 行在一个同步withWriteTx事务内插入(ctx.db.transaction(...)),orderKey保持 prepare 阶段准备好的顺序(seeded CherryAI 行之后)。事务内部还有一处精心安排的依赖顺序(ProviderModelMigrator.ts):
- 先
ensureCherryAiDefaultProviderAndModelTx(tx)写入 seeded CherryAI 行; - 插入 Logo 的
file_entry(其file_entry_id外键依赖文件实体先存在); - 插入 provider 行、按 BATCH_SIZE=100 分批插入 model 行;
- 插入 Logo ref 行(其
source_id外键依赖 provider 行已存在); - 最后写入
pin表的置顶模型行(onConflictDoNothing)。
事务结束后,migrator 通过assertOwnedForeignKeys对自己拥有的provider_logoref 表执行PRAGMA foreign_key_check——因为迁移全程foreign_keys = OFF(见 BaseMigrator.ts),插入期不会暴露外键错误,必须主动自检。若事务失败,已写盘的 WebP Logo 文件会被unlinkPreparedImages清理,避免重试时产生孤儿文件。
6.2 Logo 的两种迁移路径
v1 的自定义 provider Logo 存在 Dexie 的image://provider-<id>键下,取值有两种形态(源码注释 ProviderModelMigrator.ts):
- data URL(base64 上传图或小体积内联图)→ 提升为磁盘上的 WebP
file_entry,由 ref 行引用(logoKey置 null),并打上delete_when_unreferenced清理策略——与运行时bindLogoImage路径一致,provider 被删或换 Logo 时可回收。测试用 1×1 PNG 验证了 WebP 落盘与 ref 行唯一性(ProviderModelMigrator.test.ts)。 - 非 data: 值(v1
PROVIDER_LOGO_MAP[id]的 hashed 构建资源路径)→ 在 v2 中已失效,直接写logoKey会渲染破图。因此从资源名反查品牌并重新表达为 v2 的icon:<catalogKey>引用(recoverV1ProviderLogoIconKey);无法识别的值回退为 null(内置 provider 按 id 取内置图标、自定义 provider 显示首字母头像)。测试覆盖了openai-a1b2c3d4.png → icon:openai、microsoft.png → icon:azureai、字面量poe → icon:poe、未知值丢弃等场景(ProviderModelMigrator.test.ts)。
七、validate 阶段的完整性校验
validate()(ProviderModelMigrator.ts)执行三类核对:
- 行数核对:
user_provider非 CherryAI/非 CherryCloud 行数 === prepare 统计的 provider 数;user_model同理;pin表entityType='model'行数 === 归一化后的置顶数。任一不匹配都会产出provider_count_mismatch/model_count_mismatch/pin_count_mismatch错误。 - API Key 抽查:对前 5 个 provider 抽样,若源有
apiKey而目标apiKeys为空,且buildProviderApiKeys确实能产出可迁移条目,则报missing_api_key_<providerId>。 - 统计输出:返回
sourceCount/targetCount/skippedCount供引擎汇总。
八、实现文件速查
| 文件 | 职责 |
|---|---|
| ProviderModelMigrator.ts | prepare/投影/事务/pin/校验 主逻辑 |
| mappings/ProviderModelMappings.ts | 遗留 → v2 字段转换(端点、API Key、认证、能力、定价) |
| mappings/v1-provider-model-baseline.json | 钉死的 final-v1 归属基线(含sourceRevision) |
| tests/ProviderModelMigrator.test.ts | 迁移与归属投影回归测试 |
| mappings/tests/ProviderModelMappings.test.ts | 字段转换单测 |
九、小结:可复用的迁移设计模式
ProviderModelMigrator提供了一套值得借鉴的迁移范式:
- 钉死基线而非对照活 registry:用带
sourceRevision出处信息的静态基线快照判断"历史默认 vs 用户改动",避免 registry 演化污染归属判定; - delta 优先的稀疏落库:只存非默认差异,其余字段读时解析,让行数据随时间跟随 registry 演进;
- prepare 期集中清洗:非法/重复/退役数据在事务前剔除并聚合警告,保证 execute 阶段幂等、可控;
- 事务内依赖排序 + 外键自检:file_entry → owner 行 → ref 行,配合
PRAGMA foreign_key_check主动兜底关闭外键约束的迁移期; - 源侧一次性所有权:
pinned:models由本 migrator 独家迁移,防止 codegen 重复写入。
理解这套迁移逻辑,不仅能解释 v1 → v2 升级后"为什么我的自定义 provider 配置还在、而默认值悄悄让位给了目录",也能为你在 Cherry Studio 中排查升级后的 Provider/Model 异常、或为其他数据域设计迁移器提供直接参考。
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考