packages/shared was never a real pnpm workspace package (no package.json); it was referenced only through the @shared TypeScript path alias. Relocate it under src/ via git mv (143 files, detected as pure renames).
Repoint the @shared alias and include globs to src/shared across electron.vite.config.ts, tsconfig.{json,node,web}.json and vitest.config.ts; update scripts/check-custom-exts.ts, scripts/update-languages.ts, the eslint.config.mjs generated-file globs, the data-classify generator output targets, .github/CODEOWNERS path rules, and CLAUDE.md/docs/source-comment references.
The @shared alias name is unchanged, so all 1403 @shared/* import sites resolve without modification. Verified with typecheck:node, typecheck:web and the full test suite (700 files, 9739 tests passing).
10 KiB
Provider & Model Registry System
This document describes how Cherry Studio loads, parses, and merges provider/model preset data with user data.
Architecture Overview
@cherrystudio/provider-registry (package)
├── data/
│ ├── models.json 2525 preset models (capabilities, pricing, modalities...)
│ ├── providers.json 63 preset providers (endpoints, apiFeatures, metadata)
│ └── provider-models.json Provider-specific model overrides (per-provider tweaks)
├── src/
│ ├── registry-loader.ts RegistryLoader: load, validate, cache, index, idle TTL
│ ├── registry-utils.ts Pure functions: lookupRegistryModel, buildRuntimeEndpointConfigs
│ ├── utils/normalize.ts normalizeModelId and helpers (aggregator prefix, variant suffix...)
│ └── schemas/ Zod schemas for validation
│
src/main/data/
├── db/seeding/
│ └── presetProviderSeeding.ts ISeed: insert-only preset providers on first boot
├── services/
│ ├── ProviderRegistryService.ts Merge-dependent queries (resolve, lookup)
│ ├── ModelService.ts Model CRUD, accepts registry lookup from handler
│ └── ProviderService.ts Provider CRUD, preset deletion protection
└── api/handlers/
├── models.ts POST /models: accepts model arrays, does registry lookup, passes to service
└── providers.ts Registry model resolution endpoint
Data Flow
1. Startup: Preset Provider Seeding
DbService.onInit()
→ migrateSeed('presetProvider')
→ PresetProviderSeed.migrate(db)
→ RegistryLoader.loadProviders() // reads providers.json
→ SELECT existing provider IDs from user_provider
→ INSERT only new providers (not already in DB)
→ Never overwrites user customizations
Key behavior: Insert-only. If provider already exists in DB, skip it. Canonical preset providers (where providerId === presetProviderId) cannot be deleted by users. User-created providers that inherit from a preset can be deleted.
2. On-Demand: Model Creation
POST /models [{ providerId: 'openai', modelId: 'gpt-4o' }]
→ handler: for each item, providerRegistryService.lookupModel(providerId, modelId)
→ RegistryLoader.findModel('gpt-4o') // O(1) indexed, normalize fallback
→ RegistryLoader.findOverride('openai', 'gpt-4o') // O(1) indexed
→ getEffectiveReasoningConfig(providerId) // DB query for user provider overrides
→ returns { presetModel, registryOverride, reasoningFormatTypes, defaultChatEndpoint }
→ handler: modelService.create(items)
→ mergeModelWithUser(userRow, override, preset, providerId, ...)
→ INSERT into user_model with presetModelId = preset.id
3. Resolve SDK Model List
GET /providers/:providerId/models:resolve?ids=gpt-4o&ids=o3
→ providerRegistryService.resolveModels(providerId, modelIds)
→ For each modelId:
→ RegistryLoader.findModel(modelId) // O(1), normalize fallback
→ RegistryLoader.findOverride(providerId, modelId) // O(1)
→ mergePresetModel(preset, override, ...) or createCustomModel(...)
→ Return merged Model[]
SDK only provides model IDs. All other data (capabilities, pricing, etc.)
comes from the registry — SDK data does not overwrite curated registry data.
Merge Functions
Three separate functions for three distinct use cases:
| Function | Use Case | Layers |
|---|---|---|
mergePresetModel |
Registry queries, resolveModels | preset → override |
mergeModelWithUser |
ModelService.create with registry match | preset → override → user |
createCustomModel |
No registry match | modelId only |
Shared logic extracted to applyPresetAndOverride (preset + override merge) and resolveReasoning (reasoning config resolution).
Priority
user_model (DB) > provider-models.json (override) > models.json (preset)
highest middle lowest
Null user fields fall through to preset/override values — they do not clobber.
User Override Protection
ModelService.batchUpsert() respects a userOverrides field on each user_model row. When a user manually edits a field (e.g., changes name), that field name is recorded in userOverrides. During enrichment, fields in userOverrides are skipped — the user's customization is preserved even when registry data updates.
RegistryLoader
Cached, indexed access to registry JSON with idle auto-expiry.
Lifecycle
- Lazy load: Data loaded on first access (not at startup)
- Pre-computed indexes: 5 Maps built on first load for O(1) lookups
- Idle TTL: Auto-invalidates after 30s of no access, releasing ~6MB memory
- Touch on access: Every
findModel/findOverride/loadModelsresets the timer - Singleton: One instance per
ProviderRegistryService, shared across queries
Indexes
| Index | Key | Use |
|---|---|---|
modelById |
model.id |
Exact model lookup |
modelByNormId |
normalizeModelId(id) |
Normalized fallback |
overrideByKey |
providerId::modelId |
Exact override lookup |
overrideByNormKey |
providerId::normalizeModelId(id) |
Normalized fallback |
overridesByProvider |
providerId |
All overrides for a provider |
Query API
loader.findModel(modelId) // O(1): exact → normalized fallback
loader.findOverride(providerId, modelId) // O(1): exact → normalized fallback
loader.getOverridesForProvider(providerId) // O(1): grouped by provider
loader.invalidate() // Release all data, reload on next access
Model ID Normalization
User-facing model IDs from different providers often differ from registry canonical IDs:
| User sees | Registry has | Normalization |
|---|---|---|
aihubmix-gpt-4o |
gpt-4o |
Strip aggregator prefix |
gpt-4o:free |
gpt-4o |
Strip variant suffix |
claude-3.5-sonnet |
claude-3-5-sonnet |
Normalize version separator |
aihubmix-gpt-4o:free |
gpt-4o |
Combined |
Implemented in normalizeModelId() (packages/provider-registry/src/utils/normalize.ts):
1. Strip provider prefix (e.g., "anthropic/claude-3" → "claude-3")
2. Lowercase
3. Strip aggregator prefixes (aihubmix-, zai-, siliconflow-, ...)
4. Expand known abbreviations (mm- → minimax-)
5. Strip variant suffixes (:free, -thinking, (beta), ...)
6. Strip parameter size (-72b, -7b, ...)
7. Normalize version separators (3.5 → 3-5, 3p5 → 3-5)
Lookup strategy: Exact match first, normalized fallback second. This ensures that if both gpt-4o and aihubmix-gpt-4o exist as separate entries, exact match wins.
Key Database Tables
user_provider
| Column | Purpose |
|---|---|
providerId |
PK, user-defined unique ID |
presetProviderId |
Links to a providers.json entry (null = custom provider). Dual-purpose: identifies the source preset and the sidebar grouping key — for a few registry rows (e.g. zai→zhipu, minimax-global→minimax) it points at a different preset so they fold under that group. |
name |
Display name |
endpointConfigs |
JSON: per-endpoint baseUrl, reasoningFormatType |
defaultChatEndpoint |
Default endpoint type for chat |
apiKeys |
JSON array of API key entries |
apiFeatures |
JSON: arrayContent, streamOptions, etc. (null = use defaults) |
user_model
| Column | Purpose |
|---|---|
providerId + modelId |
Composite PK |
presetModelId |
Links to models.json entry (null = custom model) |
capabilities |
JSON array: function-call, reasoning, image-recognition, ... |
inputModalities / outputModalities |
JSON array: text, image, audio, video |
contextWindow / maxOutputTokens |
Numeric limits |
reasoning |
JSON: type, supportedEfforts, thinkingTokenLimits |
pricing |
JSON: input/output/cacheRead/cacheWrite per million tokens |
parameters |
JSON: parameter support config (temperature, topP, etc.) |
userOverrides |
JSON array of field names user has manually edited |
sortOrder |
Sort order in provider's model list |
notes |
User notes about this model |
Provider Configuration Merge
Provider configs also follow a layered merge (mergeProviderConfig()):
user_provider (DB) > providers.json (preset) > DEFAULT_API_FEATURES
const apiFeatures = {
...DEFAULT_API_FEATURES, // { arrayContent: true, streamOptions: true, ... }
...presetProvider?.apiFeatures, // from providers.json (null = use defaults)
...userProvider?.apiFeatures // user customization wins
}
Reasoning Configuration
Reasoning config combines model-level and provider-level data:
- Model level (models.json):
supportedEfforts,thinkingTokenLimits— what the model supports - Provider level (providers.json → endpointConfigs → reasoningFormat):
reasoningFormatType— how the provider's API expects reasoning params
At merge time:
const reasoningFormatType = resolveReasoningFormatType(
endpointTypes, // from override or user
defaultChatEndpoint, // from provider config
reasoningFormatTypes // from provider's endpointConfigs
)
reasoning = extractRuntimeReasoning(presetModel.reasoning, reasoningFormatType)
// → { type: 'openai-chat', supportedEfforts: ['low','medium','high'], thinkingTokenLimits: {...} }
File Locations
| What | Where |
|---|---|
| Registry JSON data | packages/provider-registry/data/ |
| Zod schemas | packages/provider-registry/src/schemas/ |
| RegistryLoader (load, index, TTL) | packages/provider-registry/src/registry-loader.ts |
| Pure lookup/transform | packages/provider-registry/src/registry-utils.ts |
| Normalize utilities | packages/provider-registry/src/utils/normalize.ts |
| Preset provider seeding | src/main/data/db/seeding/presetProviderSeeding.ts |
| Service (merge queries) | src/main/data/services/ProviderRegistryService.ts |
| Model service | src/main/data/services/ModelService.ts |
| Provider service | src/main/data/services/ProviderService.ts |
| Merge utilities | src/shared/data/utils/modelMerger.ts |
| DB schemas | src/main/data/db/schemas/userModel.ts, userProvider.ts |