From 4405b8845363c7f901bbbb8c0d03b83d49d870b5 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 31 Oct 2026 18:15:36 +0800 Subject: [PATCH 01/16] docs(provider): add catalog architecture spec --- docs/architecture/provider-catalog/plan.md | 83 +++++++++ docs/architecture/provider-catalog/spec.md | 192 +++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 docs/architecture/provider-catalog/plan.md create mode 100644 docs/architecture/provider-catalog/spec.md diff --git a/docs/architecture/provider-catalog/plan.md b/docs/architecture/provider-catalog/plan.md new file mode 100644 index 000000000..97d95db14 --- /dev/null +++ b/docs/architecture/provider-catalog/plan.md @@ -0,0 +1,83 @@ +# Provider Catalog Implementation Plan + +Specification: [spec.md](./spec.md) + +## Phase 1: Main-process catalog core + +- [ ] Add shared catalog types and zod contracts: snapshot, status, source, refresh reason, + refresh result, error code, `providers.catalog.get/list/refresh/ensureFresh`, and + `providers.catalog.changed`. +- [ ] Add `provider_catalog_state` to the settings schema (migration version 26) with revision, + fetched time, source, last reason, sanitized error code/time, and no sync triggers. +- [ ] Add raw provider/custom model and catalog-state accessors to `ProviderSettings`; keep + effective resolution in the existing resolver. +- [ ] Implement the pure catalog projection: provider-db identities + raw provider facts + + custom definitions -> one `resolveEffectiveModels` call -> `RENDERER_MODEL_META[]` with status. +- [ ] Implement `ProviderCatalogService`: snapshot read, list, ensure-fresh, refresh dedupe, + diffing, state transitions, sanitized errors, last-known-good behavior, revision events. +- [ ] Add pure `fetchCatalog` and `reloadModelsFromSettings` to the base provider; keep current + persistence and legacy routes untouched. +- [ ] Wire the service in `ProviderRuntime`, register routes, register the typed event, and keep + legacy `models.changed` emissions. +- [ ] Add main tests: projection precedence, refresh success/diff, failure preservation, + dedupe, status derivation, revision monotonicity. + +Completion condition: routes return correct snapshots and a mocked upstream refresh updates rows; +no renderer behavior changes yet. + +## Phase 2: Settings refresh uses the catalog service + +- [ ] Add a renderer provider-catalog client for the new routes and event. +- [ ] Change the provider settings refresh button to `catalog.refresh(user)`, then refresh the + existing model-store projection; show the existing success/failure notifications. +- [ ] Show last successful sync time from the snapshot; on error, keep the existing failure copy. +- [ ] Extend the provider smoke spec with a two-catalog mock server: first refresh returns catalog + A, second refresh returns catalog B, and the new model becomes visible. + +Completion condition: the reported stale-catalog defect is fixed through the new service; the +renderer still uses the legacy model store for the rest of the page. + +## Phase 3: Renderer projection + +- [ ] Replace `refreshStandardModels`/`refreshCustomModels` merging and the empty-catalog + discovery gate with catalog snapshot reads. +- [ ] Derive `allProviderModels`, `enabledModels`, and `customModels` from snapshots while keeping + the store's public shape for existing consumers. +- [ ] Initialize from one catalog list call and request stale revalidation for enabled providers. +- [ ] Listen to `providers.catalog.changed` for catalog updates; keep status/config listeners. +- [ ] Remove renderer `getDbProviderModels`, per-model effective-config lookups, and the + discovery/rerun state machine from the standard path. +- [ ] Keep the ACP special path until Phase 4; include Ollama installed models through the + catalog source. +- [ ] Update renderer store tests and run chat/model-chooser component tests. + +Completion condition: standard providers and Ollama render from main snapshots; no renderer code +decides whether to contact the upstream catalog. + +## Phase 4: Single writer and cleanup + +- [ ] Make provider `fetchCatalog` the only fetch adapter; remove persistence from `fetchModels` + and from draft validation. +- [ ] Delegate `providers.refreshModels` and the provider-db background refresh to the catalog + service. +- [ ] Route ACP catalog writes through `replaceFromSource`; route synced provider/model changes + through `invalidateFromSync`. +- [ ] Invalidate provider config-driven catalogs on base URL, API key, or protocol changes. +- [ ] Remove obsolete discovery helpers, unused routes, and legacy refresh state; keep + `models.changed` only for non-catalog consumers. +- [ ] Add an architecture guard proving `setProviderModels` is only called by the catalog service + and the settings persistence layer. +- [ ] Update `ARCHITECTURE.md` or the retained feature documentation if the catalog ownership is + referenced there. + +Completion condition: one writer, one merge, one explicit refresh contract; no hidden catalog +fetch remains in provider initialization. + +## Whole-change review and validation + +- [ ] Review the complete diff for source-of-truth violations, duplicated merges, secret leakage + in errors/logs, and behavior changes to CLI/agent paths. +- [ ] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`. +- [ ] Run targeted main and renderer suites plus the provider catalog smoke spec. +- [ ] Remove temporary probes; keep only durable regression coverage. +- [ ] Do not push. Leave the branch and validation evidence for the user. diff --git a/docs/architecture/provider-catalog/spec.md b/docs/architecture/provider-catalog/spec.md new file mode 100644 index 000000000..9ea792d86 --- /dev/null +++ b/docs/architecture/provider-catalog/spec.md @@ -0,0 +1,192 @@ +# Provider Catalog Architecture Specification + +## Context + +DeepChat currently resolves a provider's visible model catalog in the renderer. `modelStore` +combines provider-db projections, persisted `provider_models`, custom models, model status, and +per-model configuration, and separately decides whether an explicit refresh may contact the +provider. The main process also owns refresh paths: provider instance initialization, +`models.listRuntime`, `providers.refreshModels`, and provider-db background refresh. + +This split produced a user-visible defect: once a provider catalog was persisted, the settings +refresh button stopped contacting the upstream `/v1/models` endpoint. A New API gateway catalog +stayed at a September snapshot (36 models) while the upstream served 29 models including +`gpt-6.1-sol`. The refresh only re-read local rows, reported success, and never surfaced staleness. + +The root cause is ownership, not a missing branch. The catalog is a derived read model with +multiple inputs; it must have one owner, one write path, one merge pipeline, and an explicit +freshness contract. + +## Goals + +1. Make the main process the single owner of provider catalog orchestration and persistence. +2. Keep source ownership unchanged: provider facts remain distinct from provider-db defaults, + user model configuration, and user selection. +3. Make an explicit user refresh always contact the provider's current catalog source. +4. Make catalog freshness, failure, and last successful sync observable to the renderer. +5. Keep the renderer a projection: it issues commands and renders snapshots, and never decides + whether to discover models. +6. Remove the duplicate renderer merge/discovery path without changing CLI, agent, or provider + request contracts. + +## Non-Goals + +- No new process, database engine, dependency, or generic event bus. +- No new model or capability cache. The snapshot is computed on read from existing sources. +- No change to the provider-db format, and no New API entry added to provider-db. +- No change to model enablement defaults or automatic enablement of newly discovered models. +- No change to the `ModelConfig`, CLI, or agent runtime contracts. +- No event-sourcing system; catalog revisions only order notifications. + +## Source Ownership + +The existing `docs/architecture/model-config-source-of-truth/spec.md` remains normative: + +```text +safe fallback + < provider catalog defaults + < raw provider MODEL_META facts + < explicit user ModelConfig + < provider-specific normalization policy +``` + +Persistence owners remain: + +- `provider_models` (source `provider`): sparse facts observed from one provider service; +- `provider_models` (source `custom`): explicit user model definitions; +- `model_configs`: explicit user model configuration only; +- `model_status`: explicit user enablement; +- provider-db: catalog defaults and capability metadata, selected through the existing + capability-identity resolver; +- `provider_catalog_state`: operational metadata only (last successful sync, revision, last + sanitized error). It stores no model or capability content and is not synced. + +Derived effective values are never written back into `provider_models`, `model_configs`, or +provider-db. The projection is recomputable from the sources above. + +## Proposed Design + +One main-process `ProviderCatalogService` owns catalog reads and refreshes. + +### Read path + +`getSnapshot(providerId)` is pure and side-effect free. It: + +1. reads the provider row and catalog state; +2. builds the union of model identities from provider-db identity records, raw + `provider_models` source `provider`, and raw `provider_models` source `custom`; +3. resolves effective configuration through the existing `ProviderSettings` resolver + (`resolveEffectiveModels` / `getModelConfig`), preserving the precedence above; +4. reads model status through the existing batch status path; +5. returns a `ProviderCatalogSnapshot` containing the resolved models, `revision`, derived + freshness `status`, `fetchedAt`, `source`, and sanitized `lastErrorCode`. + +The snapshot is computed on demand. No provider catalog content is cached in a new store. + +### Refresh path + +`refresh(providerId, reason)` is the only path that writes the provider catalog: + +1. deduplicates in-flight refreshes per provider; +2. for provider-db-backed providers and an explicit user refresh, synchronizes provider-db first; +3. obtains models from the provider's pure catalog fetch adapter; +4. compares the normalized model set with the persisted source `provider` rows; +5. replaces source `provider` rows and updates catalog state in one logical operation; +6. reloads the provider instance's in-memory read-through cache; +7. emits one catalog change event and, when content changed, one legacy `models.changed` event; +8. keeps last-known-good rows and records a sanitized error code on failure. + +Reasons are explicit: `user`, `stale`, `config`, `provider-db`, `sync`, and `external-write`. +Only `user` (and an explicit config change) forces an upstream request regardless of freshness. +`stale` is a background stale-while-revalidate request. `provider-db` and `sync` never contact the +upstream catalog. + +### Freshness + +Freshness is derived from the persisted `fetched_at` and the source-specific TTL: + +- remote catalog sources: six hours; +- runtime sources (`openai-codex`, Ollama, ACP): sixty seconds; +- provider-db and config sources: effectively infinite until a provider-db/config event. + +`ensureFresh(providerId)` schedules a background `stale` refresh only when the provider is +enabled, the snapshot is stale or unknown, no refresh is in flight, and an error backoff has +elapsed. Reads remain pure; callers explicitly request revalidation. + +### Events and routes + +Typed routes: + +- `providers.catalog.get` -> one snapshot; +- `providers.catalog.list` -> snapshots for the requested or all providers; +- `providers.catalog.refresh` -> refresh result and fresh snapshot; +- `providers.catalog.ensureFresh` -> whether a background refresh was scheduled. + +Typed event `providers.catalog.changed`: + +```text +providerId, revision, status, reason, changed, added?, removed?, updated?, errorCode?, version +``` + +`revision` is monotonic per provider and guards against out-of-order IPC responses across +windows. `changed: false` status events update freshness without refetching the model list. +The legacy `models.changed` event remains during migration for capability, chat-mode, and Ollama +consumers. + +## Invariants + +1. Only `ProviderCatalogService` writes provider catalog rows. Provider instances, draft + validation, and ACP adapters use the service or a pure fetch adapter. +2. A user refresh always attempts the current catalog source; a non-empty local catalog never + suppresses it. +3. A failed refresh never deletes last-known-good models. +4. Derived provider-db, fallback, or user values are never persisted as provider facts. +5. One refresh performs one source-row replacement and emits one catalog change event, not one + event per model. +6. Custom model definitions remain intact across upstream refreshes and take precedence over + upstream facts for the same provider and model ID, without duplicate rows. +7. The renderer never merges catalog sources or chooses freshness policy. +8. `provider_catalog_state` is local operational metadata; it is not synced and contains no model + content, `ModelConfig`, or capabilities. +9. Provider-model reads remain side-effect free; only explicit refresh, config, sync, or ACP + external-write paths mutate catalog state. + +## Compatibility and Migration + +- Additive schema migration (version 26) creates `provider_catalog_state`; existing rows, + sync triggers, and provider-db format are untouched. +- Existing providers without state read as `unknown`, so the first catalog read schedules a + background refresh. This self-heals stale snapshots after upgrade. +- New routes and events are additive. Existing routes and `models.changed` keep working during + migration; legacy paths are removed only after the renderer projection switches. +- Rollback leaves the additive table in place; older code ignores it. No user model data is + rewritten. + +## Acceptance Criteria + +1. A local mock provider returning catalog A on its first `/v1/models` response and catalog B on + the second: two explicit refreshes produce two requests, and the model added in B is visible. +2. A failed refresh keeps the previous models, exposes a sanitized error state, and a retry + recovers. +3. Offline startup renders the persisted catalog immediately; a later background revalidation + updates it without a blank list. +4. Provider-db metadata changes re-project capabilities without an upstream request. +5. A model status or model config change updates the renderer without a full catalog refetch. +6. Two windows converge through catalog revision; an older snapshot response cannot overwrite a + newer one. +7. Synced provider/model changes invalidate the local snapshot and re-project. +8. CLI and agent reads keep using the refreshed `provider_models` rows with unchanged contracts. +9. No `setProviderModels` call exists outside the catalog service and the settings persistence + implementation. +10. Format, i18n, lint, typecheck, targeted unit/component tests, and the catalog smoke test pass. + +## Rejected Alternatives + +- **Button-only fix**: call `providers.refreshModels` from the settings button. Fixes one path, + keeps two orchestration layers, and leaves the next provider type with the same defect. +- **Renderer-owned stale-while-revalidate**: the renderer decides stale and fetch policy. Multiple + windows duplicate state, CLI/agent do not share it, and the renderer keeps the merge. +- **Fetch-only catalogs**: no persistence. Breaks offline startup, provider-db enrichment, and + request routing metadata. +- **Write-time merge**: persist one resolved model row. Loses provenance, makes resets and catalog + updates incorrect, and violates the existing source-of-truth specification. From 873d81a3bcae475031441cfdba5765fc0df34261 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 31 Oct 2026 18:30:06 +0800 Subject: [PATCH 02/16] feat(provider): add main-process catalog service --- docs/architecture/provider-catalog/plan.md | 16 +- src/main/provider/baseProvider.ts | 14 + .../catalog/providerCatalogProjection.ts | 98 ++++ .../catalog/providerCatalogService.ts | 531 ++++++++++++++++++ src/main/provider/data/settingsTable.ts | 126 ++++- src/main/provider/index.ts | 60 +- src/main/provider/routes.ts | 52 ++ src/main/provider/settings.ts | 35 ++ src/shared/contracts/domainSchemas.ts | 57 ++ src/shared/contracts/events.ts | 2 + .../contracts/events/providers.events.ts | 28 + src/shared/contracts/routes.ts | 8 + .../contracts/routes/providers.routes.ts | 46 ++ src/shared/types/providerCatalog.ts | 69 +++ .../main/provider/backgroundModelSync.test.ts | 123 ++-- .../catalog/providerCatalogService.test.ts | 361 ++++++++++++ test/main/provider/data/settingsTable.test.ts | 49 ++ 17 files changed, 1601 insertions(+), 74 deletions(-) create mode 100644 src/main/provider/catalog/providerCatalogProjection.ts create mode 100644 src/main/provider/catalog/providerCatalogService.ts create mode 100644 src/shared/types/providerCatalog.ts create mode 100644 test/main/provider/catalog/providerCatalogService.test.ts diff --git a/docs/architecture/provider-catalog/plan.md b/docs/architecture/provider-catalog/plan.md index 97d95db14..5307b6eb0 100644 --- a/docs/architecture/provider-catalog/plan.md +++ b/docs/architecture/provider-catalog/plan.md @@ -4,22 +4,22 @@ Specification: [spec.md](./spec.md) ## Phase 1: Main-process catalog core -- [ ] Add shared catalog types and zod contracts: snapshot, status, source, refresh reason, +- [x] Add shared catalog types and zod contracts: snapshot, status, source, refresh reason, refresh result, error code, `providers.catalog.get/list/refresh/ensureFresh`, and `providers.catalog.changed`. -- [ ] Add `provider_catalog_state` to the settings schema (migration version 26) with revision, +- [x] Add `provider_catalog_state` to the settings schema (migration version 26) with revision, fetched time, source, last reason, sanitized error code/time, and no sync triggers. -- [ ] Add raw provider/custom model and catalog-state accessors to `ProviderSettings`; keep +- [x] Add raw provider/custom model and catalog-state accessors to `ProviderSettings`; keep effective resolution in the existing resolver. -- [ ] Implement the pure catalog projection: provider-db identities + raw provider facts + +- [x] Implement the pure catalog projection: provider-db identities + raw provider facts + custom definitions -> one `resolveEffectiveModels` call -> `RENDERER_MODEL_META[]` with status. -- [ ] Implement `ProviderCatalogService`: snapshot read, list, ensure-fresh, refresh dedupe, +- [x] Implement `ProviderCatalogService`: snapshot read, list, ensure-fresh, refresh dedupe, diffing, state transitions, sanitized errors, last-known-good behavior, revision events. -- [ ] Add pure `fetchCatalog` and `reloadModelsFromSettings` to the base provider; keep current +- [x] Add pure `fetchCatalog` and `reloadModelsFromSettings` to the base provider; keep current persistence and legacy routes untouched. -- [ ] Wire the service in `ProviderRuntime`, register routes, register the typed event, and keep +- [x] Wire the service in `ProviderRuntime`, register routes, register the typed event, and keep legacy `models.changed` emissions. -- [ ] Add main tests: projection precedence, refresh success/diff, failure preservation, +- [x] Add main tests: projection precedence, refresh success/diff, failure preservation, dedupe, status derivation, revision monotonicity. Completion condition: routes return correct snapshots and a mocked upstream refresh updates rows; diff --git a/src/main/provider/baseProvider.ts b/src/main/provider/baseProvider.ts index 3e6c0be14..4a8f0abbe 100644 --- a/src/main/provider/baseProvider.ts +++ b/src/main/provider/baseProvider.ts @@ -315,6 +315,20 @@ export abstract class BaseLLMProvider { return structuredClone(validatedModels) } + /** + * Fetch the provider's current catalog without persisting it. Catalog persistence is owned by + * the main-process catalog service. + */ + public async fetchCatalog(): Promise { + return await this.fetchProviderModels() + } + + /** Reload the in-memory read-through cache after the catalog service persists new rows. */ + public reloadModelsFromSettings(): void { + this.loadCachedModels() + this.modelFetchPromise = null + } + /** * 强制刷新模型数据 * 忽略缓存,重新从网络获取最新的模型列表 diff --git a/src/main/provider/catalog/providerCatalogProjection.ts b/src/main/provider/catalog/providerCatalogProjection.ts new file mode 100644 index 000000000..0c2dede6b --- /dev/null +++ b/src/main/provider/catalog/providerCatalogProjection.ts @@ -0,0 +1,98 @@ +import { ModelType } from '@shared/model' +import { + resolveDerivedModelMaxTokens, + resolveModelContextLength, + resolveModelFunctionCall, + resolveModelVision +} from '@shared/modelConfigDefaults' +import type { MODEL_META, RENDERER_MODEL_META } from '@shared/types/provider' + +type ProviderCatalogProjectionInput = { + providerId: string + catalogModels: RENDERER_MODEL_META[] + providerModels: MODEL_META[] + customModels: MODEL_META[] + resolveEffectiveModels: (models: MODEL_META[], providerId: string) => MODEL_META[] + modelStatus: (modelIds: string[]) => Record +} + +const toCatalogIdentity = (model: RENDERER_MODEL_META): MODEL_META => ({ + id: model.id, + name: model.name || model.id, + group: model.group || 'default', + providerId: model.providerId, + isCustom: false, + type: model.type +}) + +/** + * Build the effective provider catalog from the three persisted sources. Catalog contents are + * sparse provider facts, never a materialized effective projection. + */ +export function buildProviderCatalogModels( + input: ProviderCatalogProjectionInput +): RENDERER_MODEL_META[] { + const modelsById = new Map() + const explicitFunctionCallById = new Map() + + for (const catalogModel of input.catalogModels) { + modelsById.set(catalogModel.id, toCatalogIdentity(catalogModel)) + } + + for (const providerModel of input.providerModels) { + const normalized: MODEL_META = { + ...providerModel, + providerId: input.providerId, + isCustom: providerModel.isCustom ?? false + } + const catalogModel = modelsById.get(normalized.id) + modelsById.set(normalized.id, catalogModel ? { ...catalogModel, ...normalized } : normalized) + if (typeof providerModel.functionCall === 'boolean') { + explicitFunctionCallById.set(providerModel.id, providerModel.functionCall) + } + } + + for (const customModel of input.customModels) { + const normalized: MODEL_META = { + ...customModel, + providerId: input.providerId, + isCustom: true + } + const existing = modelsById.get(normalized.id) + modelsById.set( + normalized.id, + existing ? { ...existing, ...normalized, isCustom: true } : normalized + ) + if (typeof customModel.functionCall === 'boolean') { + explicitFunctionCallById.set(customModel.id, customModel.functionCall) + } + } + + const resolvedModels = input.resolveEffectiveModels( + Array.from(modelsById.values()), + input.providerId + ) + const modelStatus = input.modelStatus(resolvedModels.map((model) => model.id)) + + return resolvedModels.map((model) => ({ + id: model.id, + name: model.name || model.id, + group: model.group || 'default', + providerId: input.providerId, + enabled: modelStatus[model.id] ?? false, + isCustom: model.isCustom ?? false, + vision: resolveModelVision(model.vision), + functionCall: resolveModelFunctionCall(model.functionCall), + explicitFunctionCall: explicitFunctionCallById.get(model.id), + reasoning: model.reasoning ?? false, + enableSearch: model.enableSearch ?? false, + type: (model.type ?? ModelType.Chat) as ModelType, + contextLength: resolveModelContextLength(model.contextLength), + maxTokens: resolveDerivedModelMaxTokens(model.maxTokens), + description: model.description, + supportedEndpointTypes: model.supportedEndpointTypes, + selectableEndpointTypes: model.selectableEndpointTypes, + endpointType: model.endpointType, + ownedBy: model.ownedBy + })) +} diff --git a/src/main/provider/catalog/providerCatalogService.ts b/src/main/provider/catalog/providerCatalogService.ts new file mode 100644 index 000000000..bc79ddf36 --- /dev/null +++ b/src/main/provider/catalog/providerCatalogService.ts @@ -0,0 +1,531 @@ +import logger from '@shared/logger' +import type { DeepchatEventPayload, DeepchatEventPublisher } from '@shared/contracts/events' +import type { LLM_PROVIDER, MODEL_META } from '@shared/types/provider' +import type { + ProviderCatalogErrorCode, + ProviderCatalogRefreshReason, + ProviderCatalogRefreshResult, + ProviderCatalogSnapshot, + ProviderCatalogSource, + ProviderCatalogState, + ProviderCatalogStatus +} from '@shared/types/providerCatalog' +import { stripDerivedProviderModelFields } from '../providerModelFacts' +import { isProviderDbBackedProvider, resolveAiSdkProviderDefinition } from '../providerRegistry' +import type { ProviderSettingsPort } from '../settings' +import { buildProviderCatalogModels } from './providerCatalogProjection' + +const REMOTE_CATALOG_TTL_MS = 6 * 60 * 60 * 1000 +const RUNTIME_CATALOG_TTL_MS = 60 * 1000 +const ERROR_RETRY_MS = 5 * 60 * 1000 + +export type ProviderCatalogSettingsPort = Pick< + ProviderSettingsPort, + | 'getProviders' + | 'getProviderById' + | 'getRawProviderModels' + | 'getRawCustomModels' + | 'getDbProviderModels' + | 'resolveEffectiveModels' + | 'getBatchModelStatus' + | 'setProviderModels' + | 'getProviderCatalogState' + | 'setProviderCatalogState' + | 'deleteProviderCatalogState' + | 'notifyModelsChanged' + | 'refreshProviderDb' +> + +export interface ProviderCatalogSourcePort { + fetchCatalog(providerId: string): Promise + reloadModels(providerId: string): void +} + +export interface ProviderCatalogServiceDeps { + settings: ProviderCatalogSettingsPort + source: ProviderCatalogSourcePort + publishEvent: DeepchatEventPublisher + now?: () => number +} + +type CatalogDiff = { + added: string[] + removed: string[] + updated: string[] +} + +const stableStringify = (value: unknown): string => { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]` + } + if (value && typeof value === 'object') { + const record = value as Record + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +const catalogSourceForProvider = (provider: LLM_PROVIDER): ProviderCatalogSource => { + const providerId = provider.id.trim().toLowerCase() + if (providerId === 'acp') return 'acp' + if (providerId === 'ollama' || provider.apiType === 'ollama') return 'ollama' + if (provider.custom) return 'remote' + + const definition = resolveAiSdkProviderDefinition(provider) + switch (definition?.modelSource) { + case 'provider-db': + case 'kimi-for-coding': + return 'provider-db' + case 'config-db': + return 'config' + case 'openai-codex': + return 'runtime' + default: + return 'remote' + } +} + +const catalogTtlForSource = (source: ProviderCatalogSource): number => { + if (source === 'provider-db' || source === 'config') return Number.POSITIVE_INFINITY + if (source === 'remote') return REMOTE_CATALOG_TTL_MS + return RUNTIME_CATALOG_TTL_MS +} + +const includesCatalogModels = (source: ProviderCatalogSource): boolean => + source === 'remote' || source === 'provider-db' || source === 'config' + +const normalizeFetchedModels = (models: MODEL_META[], providerId: string): MODEL_META[] => { + const byId = new Map() + for (const model of models) { + if (!model || typeof model.id !== 'string' || model.id.trim().length === 0) continue + const id = model.id.trim() + const normalized: MODEL_META = { ...model, id, providerId, isCustom: model.isCustom ?? false } + byId.set(id, stripDerivedProviderModelFields(normalized, providerId)) + } + return Array.from(byId.values()) +} + +const diffCatalogModels = ( + previous: MODEL_META[], + next: MODEL_META[], + providerId: string +): CatalogDiff => { + const fingerprint = (model: MODEL_META): string => + stableStringify(stripDerivedProviderModelFields({ ...model, providerId }, providerId)) + const previousById = new Map(previous.map((model) => [model.id, fingerprint(model)])) + const nextById = new Map(next.map((model) => [model.id, fingerprint(model)])) + + return { + added: next + .filter((model) => !previousById.has(model.id)) + .map((model) => model.id) + .sort(), + removed: previous + .filter((model) => !nextById.has(model.id)) + .map((model) => model.id) + .sort(), + updated: next + .filter( + (model) => + previousById.has(model.id) && previousById.get(model.id) !== nextById.get(model.id) + ) + .map((model) => model.id) + .sort() + } +} + +const classifyCatalogError = (error: unknown): ProviderCatalogErrorCode => { + const status = (error as { status?: unknown } | null)?.status + if (typeof status === 'number') { + if (status === 401) return 'auth' + if (status === 403) return 'forbidden' + if (status === 404) return 'not-found' + if (status === 408) return 'timeout' + return 'http' + } + + const message = + error instanceof Error + ? `${error.name} ${error.message}`.toLowerCase() + : String(error).toLowerCase() + if (message.includes('abort') || message.includes('timeout')) return 'timeout' + if ( + message.includes('fetch failed') || + message.includes('network') || + message.includes('econn') || + message.includes('enotfound') || + message.includes('socket') + ) { + return 'network' + } + if (message.includes('json') || message.includes('parse')) return 'invalid-response' + return 'unknown' +} + +export class ProviderCatalogService { + private readonly inFlight = new Map>() + private readonly now: () => number + + constructor(private readonly deps: ProviderCatalogServiceDeps) { + this.now = deps.now ?? Date.now + } + + getSnapshot(providerId: string): ProviderCatalogSnapshot { + const provider = this.requireProvider(providerId) + const source = catalogSourceForProvider(provider) + const state = this.deps.settings.getProviderCatalogState(providerId) + const status = this.deriveStatus(state, source, this.inFlight.has(providerId)) + const catalogModels = includesCatalogModels(source) + ? this.deps.settings.getDbProviderModels(providerId) + : [] + + const models = buildProviderCatalogModels({ + providerId, + catalogModels, + providerModels: this.deps.settings.getRawProviderModels(providerId), + customModels: this.deps.settings.getRawCustomModels(providerId), + resolveEffectiveModels: (input, id) => this.deps.settings.resolveEffectiveModels(input, id), + modelStatus: (modelIds) => this.deps.settings.getBatchModelStatus(providerId, modelIds) + }) + + return { + providerId, + revision: state?.revision ?? 0, + status, + fetchedAt: state?.fetchedAt ?? null, + source, + lastErrorCode: state?.lastErrorCode ?? null, + models + } + } + + listSnapshots(providerIds?: string[]): ProviderCatalogSnapshot[] { + const ids = providerIds ?? this.deps.settings.getProviders().map((provider) => provider.id) + return ids.map((providerId) => this.getSnapshot(providerId)) + } + + ensureFresh(providerId: string): boolean { + const provider = this.deps.settings.getProviderById(providerId) + if (!provider?.enable) return false + + const source = catalogSourceForProvider(provider) + if (source === 'acp') return false + if (this.inFlight.has(providerId)) return false + + const state = this.deps.settings.getProviderCatalogState(providerId) + const status = this.deriveStatus(state, source, false) + if (status === 'fresh') return false + if ( + status === 'error' && + state?.lastErrorAt != null && + this.now() - state.lastErrorAt < ERROR_RETRY_MS + ) { + return false + } + + void this.refresh(providerId, 'stale').catch((error) => { + logger.warn( + `[ProviderCatalog] Background refresh failed for ${providerId}: ` + + (error instanceof Error ? error.message : String(error)) + ) + }) + return true + } + + refresh( + providerId: string, + reason: ProviderCatalogRefreshReason = 'user' + ): Promise { + const existing = this.inFlight.get(providerId) + if (existing) return existing + this.requireProvider(providerId) + + const refreshPromise = this.performRefresh(providerId, reason).finally(() => { + if (this.inFlight.get(providerId) === refreshPromise) { + this.inFlight.delete(providerId) + } + }) + this.inFlight.set(providerId, refreshPromise) + return refreshPromise + } + + replaceFromSource( + providerId: string, + models: MODEL_META[], + reason: ProviderCatalogRefreshReason = 'external-write' + ): ProviderCatalogRefreshResult { + const provider = this.requireProvider(providerId) + const source = catalogSourceForProvider(provider) + const normalized = normalizeFetchedModels(models, providerId) + const diff = diffCatalogModels( + this.deps.settings.getRawProviderModels(providerId), + normalized, + providerId + ) + const changed = diff.added.length + diff.removed.length + diff.updated.length > 0 + + if (changed) { + this.deps.settings.setProviderModels(providerId, normalized) + } + const state = this.deps.settings.setProviderCatalogState( + providerId, + { + fetchedAt: this.now(), + source, + lastRefreshReason: reason, + lastErrorCode: null, + lastErrorAt: null + }, + { bumpRevision: changed } + ) + this.deps.source.reloadModels(providerId) + if (changed) this.deps.settings.notifyModelsChanged(providerId) + this.publishChanged(providerId, state, source, reason, changed, diff, null) + + return { + providerId, + status: changed ? 'ok' : 'not-modified', + revision: state.revision, + fetchedAt: state.fetchedAt, + ...diff, + errorCode: null + } + } + + onExternalCatalogWrite( + providerId: string, + reason: ProviderCatalogRefreshReason = 'external-write' + ): void { + const provider = this.deps.settings.getProviderById(providerId) + if (!provider) return + const source = catalogSourceForProvider(provider) + const state = this.deps.settings.setProviderCatalogState( + providerId, + { source, lastRefreshReason: reason }, + { bumpRevision: true } + ) + this.deps.source.reloadModels(providerId) + this.deps.settings.notifyModelsChanged(providerId) + this.publishChanged(providerId, state, source, reason, true, undefined, null) + } + + onProviderDbUpdated(): void { + for (const provider of this.deps.settings.getProviders()) { + if (!provider.enable) continue + const source = catalogSourceForProvider(provider) + if (source === 'remote' || source === 'ollama' || source === 'acp' || source === 'runtime') { + const state = this.deps.settings.setProviderCatalogState( + provider.id, + { source }, + { bumpRevision: true } + ) + this.publishChanged(provider.id, state, source, 'provider-db', true, undefined, null) + continue + } + + void this.refresh(provider.id, 'provider-db').catch((error) => { + logger.warn( + `[ProviderCatalog] Provider DB refresh failed for ${provider.id}: ` + + (error instanceof Error ? error.message : String(error)) + ) + }) + } + } + + onProviderConfigChanged(providerId: string): void { + const provider = this.deps.settings.getProviderById(providerId) + if (!provider) return + const source = catalogSourceForProvider(provider) + + if (provider.enable) { + void this.refresh(providerId, 'config').catch((error) => { + logger.warn( + `[ProviderCatalog] Config refresh failed for ${providerId}: ` + + (error instanceof Error ? error.message : String(error)) + ) + }) + return + } + + const state = this.deps.settings.setProviderCatalogState(providerId, { + fetchedAt: null, + source, + lastRefreshReason: 'config', + lastErrorCode: null, + lastErrorAt: null + }) + this.publishChanged(providerId, state, source, 'config', false, undefined, null) + } + + private async performRefresh( + providerId: string, + reason: ProviderCatalogRefreshReason + ): Promise { + const provider = this.requireProvider(providerId) + const source = catalogSourceForProvider(provider) + const currentState = this.deps.settings.getProviderCatalogState(providerId) + this.publishChanged( + providerId, + currentState, + source, + reason, + false, + undefined, + null, + 'refreshing' + ) + + try { + if ((reason === 'user' || reason === 'config') && isProviderDbBackedProvider(providerId)) { + try { + const dbRefresh = await this.deps.settings.refreshProviderDb(true) + if (dbRefresh.status === 'error') { + logger.warn( + `[ProviderCatalog] Provider DB download failed for ${providerId}; using the cached catalog` + ) + } + } catch (error) { + // The cached provider DB still answers the fetch; a failed download must not empty it. + logger.warn( + `[ProviderCatalog] Provider DB download failed for ${providerId}: ` + + (error instanceof Error ? error.message : String(error)) + ) + } + } + + // Snapshot before fetching: some sources (ACP) persist while fetching. + const previousModels = this.deps.settings.getRawProviderModels(providerId) + const fetched = await this.deps.source.fetchCatalog(providerId) + if (!this.deps.settings.getProviderById(providerId)) { + throw new Error(`Provider ${providerId} was removed during refresh`) + } + + const normalized = normalizeFetchedModels(fetched, providerId) + const diff = diffCatalogModels(previousModels, normalized, providerId) + const changed = diff.added.length + diff.removed.length + diff.updated.length > 0 + if (changed) { + this.deps.settings.setProviderModels(providerId, normalized) + } + + const state = this.deps.settings.setProviderCatalogState( + providerId, + { + fetchedAt: this.now(), + source, + lastRefreshReason: reason, + lastErrorCode: null, + lastErrorAt: null + }, + { bumpRevision: changed } + ) + this.deps.source.reloadModels(providerId) + if (changed) this.deps.settings.notifyModelsChanged(providerId) + this.publishChanged( + providerId, + state, + source, + reason, + changed, + diff, + null, + this.deriveStatus(state, source, false) + ) + + return { + providerId, + status: changed ? 'ok' : 'not-modified', + revision: state.revision, + fetchedAt: state.fetchedAt, + ...diff, + errorCode: null + } + } catch (error) { + const errorCode = classifyCatalogError(error) + if (!this.deps.settings.getProviderById(providerId)) { + // The provider (and its state row) was deleted mid-refresh; do not resurrect either. + return { + providerId, + status: 'failed', + revision: 0, + fetchedAt: null, + added: [], + removed: [], + updated: [], + errorCode + } + } + + const state = this.deps.settings.setProviderCatalogState( + providerId, + { + source, + lastRefreshReason: reason, + lastErrorCode: errorCode, + lastErrorAt: this.now() + }, + { bumpRevision: false } + ) + this.publishChanged(providerId, state, source, reason, false, undefined, errorCode, 'error') + logger.warn( + `[ProviderCatalog] Refresh failed for ${providerId} (${reason}): ` + + (error instanceof Error ? error.message : String(error)) + ) + + return { + providerId, + status: 'failed', + revision: state.revision, + fetchedAt: state.fetchedAt, + added: [], + removed: [], + updated: [], + errorCode + } + } + } + + private deriveStatus( + state: ProviderCatalogState | undefined, + source: ProviderCatalogSource, + refreshing: boolean + ): ProviderCatalogStatus { + if (refreshing) return 'refreshing' + if (state?.lastErrorCode) return 'error' + if (!state?.fetchedAt) return 'unknown' + const ttl = catalogTtlForSource(source) + if (ttl !== Number.POSITIVE_INFINITY && this.now() - state.fetchedAt > ttl) return 'stale' + return 'fresh' + } + + private requireProvider(providerId: string): LLM_PROVIDER { + const provider = this.deps.settings.getProviderById(providerId) + if (!provider) throw new Error(`Provider ${providerId} not found`) + return provider + } + + private publishChanged( + providerId: string, + state: ProviderCatalogState | undefined, + source: ProviderCatalogSource, + reason: ProviderCatalogRefreshReason, + changed: boolean, + diff?: CatalogDiff, + errorCode: ProviderCatalogErrorCode | null = null, + statusOverride?: ProviderCatalogStatus + ): void { + const payload: DeepchatEventPayload<'providers.catalog.changed'> = { + providerId, + revision: state?.revision ?? 0, + status: statusOverride ?? this.deriveStatus(state, source, this.inFlight.has(providerId)), + reason, + changed, + errorCode, + version: this.now(), + ...(diff ? { added: diff.added, removed: diff.removed, updated: diff.updated } : {}) + } + this.deps.publishEvent('providers.catalog.changed', payload) + } +} diff --git a/src/main/provider/data/settingsTable.ts b/src/main/provider/data/settingsTable.ts index d486c24ba..4c813c5c5 100644 --- a/src/main/provider/data/settingsTable.ts +++ b/src/main/provider/data/settingsTable.ts @@ -1,6 +1,13 @@ import Database from 'better-sqlite3-multiple-ciphers' import { BaseTable } from '@/data/baseTable' import type { IModelConfig, LLM_PROVIDER, MODEL_META } from '@shared/types/provider' +import type { + ProviderCatalogErrorCode, + ProviderCatalogRefreshReason, + ProviderCatalogSource, + ProviderCatalogState, + ProviderCatalogStatePatch +} from '@shared/types/providerCatalog' import { getValidProviderCustomHeaders } from '@shared/providerCustomHeaders' import { LEGACY_MODEL_CONFIG_META_KEY, normalizeUserModelConfigEntry } from '../userModelConfig' import { @@ -47,6 +54,17 @@ type ModelConfigRow = { updated_at: number } +type ProviderCatalogStateRow = { + provider_id: string + revision: number + fetched_at: number | null + source: string | null + last_refresh_reason: string | null + last_error_code: string | null + last_error_at: number | null + updated_at: number +} + export type SaveCustomModelInput = { providerId: string originalModelId?: string @@ -130,15 +148,41 @@ export class ProviderSettingsTable extends BaseTable { updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_model_configs_provider ON model_configs(provider_id); + + CREATE TABLE IF NOT EXISTS provider_catalog_state ( + provider_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + fetched_at INTEGER, + source TEXT, + last_refresh_reason TEXT, + last_error_code TEXT, + last_error_at INTEGER, + updated_at INTEGER NOT NULL + ); ` } getMigrationSQL(version: number): string | null { - return version === 25 ? this.getCreateTableSQL() : null + if (version === 25) return this.getCreateTableSQL() + if (version === 26) { + return ` + CREATE TABLE IF NOT EXISTS provider_catalog_state ( + provider_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0, + fetched_at INTEGER, + source TEXT, + last_refresh_reason TEXT, + last_error_code TEXT, + last_error_at INTEGER, + updated_at INTEGER NOT NULL + ); + ` + } + return null } getLatestVersion(): number { - return 25 + return 26 } listProviders(): LLM_PROVIDER[] { @@ -293,6 +337,84 @@ export class ProviderSettingsTable extends BaseTable { })() } + getProviderCatalogState(providerId: string): ProviderCatalogState | undefined { + const row = this.db + .prepare('SELECT * FROM provider_catalog_state WHERE provider_id = ?') + .get(providerId) as ProviderCatalogStateRow | undefined + return row ? this.toProviderCatalogState(row) : undefined + } + + setProviderCatalogState( + providerId: string, + patch: ProviderCatalogStatePatch, + options: { bumpRevision?: boolean } = {} + ): ProviderCatalogState { + const timestamp = now() + return this.db.transaction(() => { + const existing = this.getProviderCatalogState(providerId) + const next: ProviderCatalogState = { + providerId, + revision: (existing?.revision ?? 0) + (options.bumpRevision ? 1 : 0), + fetchedAt: patch.fetchedAt !== undefined ? patch.fetchedAt : (existing?.fetchedAt ?? null), + source: patch.source !== undefined ? patch.source : (existing?.source ?? null), + lastRefreshReason: + patch.lastRefreshReason !== undefined + ? patch.lastRefreshReason + : (existing?.lastRefreshReason ?? null), + lastErrorCode: + patch.lastErrorCode !== undefined + ? patch.lastErrorCode + : (existing?.lastErrorCode ?? null), + lastErrorAt: + patch.lastErrorAt !== undefined ? patch.lastErrorAt : (existing?.lastErrorAt ?? null) + } + + this.db + .prepare( + `INSERT INTO provider_catalog_state ( + provider_id, revision, fetched_at, source, last_refresh_reason, + last_error_code, last_error_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id) DO UPDATE SET + revision = excluded.revision, + fetched_at = excluded.fetched_at, + source = excluded.source, + last_refresh_reason = excluded.last_refresh_reason, + last_error_code = excluded.last_error_code, + last_error_at = excluded.last_error_at, + updated_at = excluded.updated_at` + ) + .run( + providerId, + next.revision, + next.fetchedAt, + next.source, + next.lastRefreshReason, + next.lastErrorCode, + next.lastErrorAt, + timestamp + ) + + return next + })() + } + + deleteProviderCatalogState(providerId: string): void { + this.db.prepare('DELETE FROM provider_catalog_state WHERE provider_id = ?').run(providerId) + } + + private toProviderCatalogState(row: ProviderCatalogStateRow): ProviderCatalogState { + return { + providerId: row.provider_id, + revision: row.revision, + fetchedAt: row.fetched_at, + source: (row.source as ProviderCatalogSource | null) ?? null, + lastRefreshReason: (row.last_refresh_reason as ProviderCatalogRefreshReason | null) ?? null, + lastErrorCode: (row.last_error_code as ProviderCatalogErrorCode | null) ?? null, + lastErrorAt: row.last_error_at + } + } + saveCustomModel(input: SaveCustomModelInput): void { const { providerId, originalModelId, model, configKey, config, enabled } = input const targetId = model.id diff --git a/src/main/provider/index.ts b/src/main/provider/index.ts index 07143b9dd..2f2df98f5 100644 --- a/src/main/provider/index.ts +++ b/src/main/provider/index.ts @@ -61,6 +61,12 @@ import { AcpSessionPersistence } from '@/agent/acp/runtime' import { AcpProvider } from './providers/acpProvider' import type { AgentSettingsPort } from '@/agent/settings' import type { DeepchatEventPublisher } from '@shared/contracts/events' +import type { + ProviderCatalogRefreshReason, + ProviderCatalogRefreshResult, + ProviderCatalogSnapshot +} from '@shared/types/providerCatalog' +import { ProviderCatalogService } from './catalog/providerCatalogService' const createAbortError = (): Error => { if (typeof DOMException !== 'undefined') { @@ -141,6 +147,7 @@ export class ProviderRuntime } private readonly rateLimitManager: RateLimitManager private readonly providerInstanceManager: ProviderInstanceManager + private readonly providerCatalogService: ProviderCatalogService private readonly modelManager: ModelManager private readonly ollamaManager: OllamaManager private readonly embeddingManager: EmbeddingManager @@ -179,6 +186,15 @@ export class ProviderRuntime acpRuntimeOwner: this.acpRuntimeOwner, publishEvent: this.publishEvent }) + this.providerCatalogService = new ProviderCatalogService({ + settings: providerSettings, + source: { + fetchCatalog: (providerId) => this.getProviderInstance(providerId).fetchCatalog(), + reloadModels: (providerId) => + this.getExistingProviderInstance(providerId)?.reloadModelsFromSettings() + }, + publishEvent: this.publishEvent + }) this.modelManager = new ModelManager({ providerSettings, getProviderInstance: this.getProviderInstance.bind(this) @@ -267,7 +283,26 @@ export class ProviderRuntime } handleProviderDbUpdated(): void { - this.refreshEnabledProviderDbBackedModelsInBackground('provider-db-updated') + this.providerCatalogService.onProviderDbUpdated() + } + + getProviderCatalog(providerId: string): ProviderCatalogSnapshot { + return this.providerCatalogService.getSnapshot(providerId) + } + + listProviderCatalogs(providerIds?: string[]): ProviderCatalogSnapshot[] { + return this.providerCatalogService.listSnapshots(providerIds) + } + + refreshProviderCatalog( + providerId: string, + reason: ProviderCatalogRefreshReason = 'user' + ): Promise { + return this.providerCatalogService.refresh(providerId, reason) + } + + ensureProviderCatalogFresh(providerId: string): boolean { + return this.providerCatalogService.ensureFresh(providerId) } getProviders(): LLM_PROVIDER[] { @@ -1003,18 +1038,6 @@ export class ProviderRuntime } } - private getEnabledProviderIdsUsingProviderDb(): string[] { - return this.providerInstanceManager - .getProviders() - .filter( - (provider) => - provider.enable && - isProviderDbBackedProvider(provider.id) && - provider.id !== 'openai-codex' - ) - .map((provider) => provider.id) - } - private async syncProviderDbBeforeRefresh(providerId: string): Promise { if (!isProviderDbBackedProvider(providerId)) { return @@ -1045,17 +1068,6 @@ export class ProviderRuntime return refreshPromise } - private refreshEnabledProviderDbBackedModelsInBackground(reason: string): void { - for (const providerId of this.getEnabledProviderIdsUsingProviderDb()) { - void this.enqueueProviderModelRefresh(providerId).catch((error) => { - console.warn( - `[ProviderRuntime] Failed to refresh models for provider ${providerId} during ${reason}:`, - error - ) - }) - } - } - async refreshModels(providerId: string): Promise { try { await this.syncProviderDbBeforeRefresh(providerId) diff --git a/src/main/provider/routes.ts b/src/main/provider/routes.ts index ec6234db0..3ea812044 100644 --- a/src/main/provider/routes.ts +++ b/src/main/provider/routes.ts @@ -41,6 +41,10 @@ import { oauthXaiGrokLogoutRoute, oauthXaiGrokStartDeviceLoginRoute, providersAddRoute, + providersCatalogEnsureFreshRoute, + providersCatalogGetRoute, + providersCatalogListRoute, + providersCatalogRefreshRoute, providersValidateDraftRoute, providersGetAcpProcessConfigOptionsRoute, providersGetEmbeddingDimensionsRoute, @@ -441,6 +445,54 @@ export function createProviderRoutes(deps: { return result } ], + [ + providersCatalogGetRoute.name, + async (rawInput) => { + const input = providersCatalogGetRoute.input.parse(rawInput) + return providersCatalogGetRoute.output.parse({ + catalog: providerRuntime.getProviderCatalog(input.providerId) + }) + } + ], + [ + providersCatalogListRoute.name, + async (rawInput) => { + const input = providersCatalogListRoute.input.parse(rawInput) + return providersCatalogListRoute.output.parse({ + catalogs: providerRuntime.listProviderCatalogs(input.providerIds) + }) + } + ], + [ + providersCatalogRefreshRoute.name, + async (rawInput) => { + const input = providersCatalogRefreshRoute.input.parse(rawInput) + const result = await providerRuntime.refreshProviderCatalog(input.providerId, input.reason) + const catalog = providerRuntime.getProviderCatalog(input.providerId) + const provider = providerSettings.getProviderById(input.providerId) + recordActivity({ + category: 'provider', + action: 'refreshed', + targetType: 'provider', + targetId: input.providerId, + targetLabel: provider?.name ?? input.providerId, + routeName: 'settings-provider', + routeParams: { providerId: input.providerId }, + summaryKey: 'settings.controlCenter.activity.providerModelsRefreshed', + summaryParams: { name: provider?.name ?? input.providerId } + }) + return providersCatalogRefreshRoute.output.parse({ result, catalog }) + } + ], + [ + providersCatalogEnsureFreshRoute.name, + async (rawInput) => { + const input = providersCatalogEnsureFreshRoute.input.parse(rawInput) + return providersCatalogEnsureFreshRoute.output.parse({ + scheduled: providerRuntime.ensureProviderCatalogFresh(input.providerId) + }) + } + ], [ providersListOllamaModelsRoute.name, async (rawInput) => { diff --git a/src/main/provider/settings.ts b/src/main/provider/settings.ts index eb512d927..4947eac4e 100644 --- a/src/main/provider/settings.ts +++ b/src/main/provider/settings.ts @@ -7,6 +7,7 @@ import type { RENDERER_MODEL_META, IModelConfig } from '@shared/types/provider' +import type { ProviderCatalogState, ProviderCatalogStatePatch } from '@shared/types/providerCatalog' import { ProviderBatchUpdate } from '@shared/provider-operations' import { ModelType, @@ -255,7 +256,16 @@ export interface ProviderSettingsPort { getProviderById(id: string): LLM_PROVIDER | undefined setProviderById(id: string, provider: LLM_PROVIDER): void getProviderModels(providerId: string): MODEL_META[] + getRawProviderModels(providerId: string): MODEL_META[] + getRawCustomModels(providerId: string): MODEL_META[] resolveEffectiveModels(models: MODEL_META[], providerId: string): MODEL_META[] + getProviderCatalogState(providerId: string): ProviderCatalogState | undefined + setProviderCatalogState( + providerId: string, + patch: ProviderCatalogStatePatch, + options?: { bumpRevision?: boolean } + ): ProviderCatalogState + deleteProviderCatalogState(providerId: string): void getProviderModelRouteMetadata( providerId: string, modelId: string, @@ -1064,6 +1074,7 @@ export class ProviderSettings implements ProviderSettingsPort { */ removeProviderAtomic(providerId: string): void { this.providerHelper.removeProviderAtomic(providerId) + this.database.settingsTable.deleteProviderCatalogState(providerId) } /** @@ -1127,6 +1138,14 @@ export class ProviderSettings implements ProviderSettingsPort { ) } + getRawProviderModels(providerId: string): MODEL_META[] { + return this.providerModelHelper.getProviderModels(providerId) + } + + getRawCustomModels(providerId: string): MODEL_META[] { + return this.providerModelHelper.getCustomModels(providerId) + } + resolveEffectiveModels(models: MODEL_META[], providerId: string): MODEL_META[] { const provider = this.providerHelper?.getProviderById?.(providerId) return models.map((model) => @@ -1220,6 +1239,22 @@ export class ProviderSettings implements ProviderSettingsPort { this.providerModelHelper.setProviderModels(providerId, models) } + getProviderCatalogState(providerId: string): ProviderCatalogState | undefined { + return this.database.settingsTable.getProviderCatalogState(providerId) + } + + setProviderCatalogState( + providerId: string, + patch: ProviderCatalogStatePatch, + options?: { bumpRevision?: boolean } + ): ProviderCatalogState { + return this.database.settingsTable.setProviderCatalogState(providerId, patch, options) + } + + deleteProviderCatalogState(providerId: string): void { + this.database.settingsTable.deleteProviderCatalogState(providerId) + } + getEnabledProviders(): LLM_PROVIDER[] { return this.providerHelper.getEnabledProviders() } diff --git a/src/shared/contracts/domainSchemas.ts b/src/shared/contracts/domainSchemas.ts index 454d1fda5..9c2c69d03 100644 --- a/src/shared/contracts/domainSchemas.ts +++ b/src/shared/contracts/domainSchemas.ts @@ -3,9 +3,12 @@ import { BrowserPageStatus } from '../types/browser' import { ApiEndpointType, ModelType, NEW_API_ENDPOINT_TYPES } from '../model' import { AttachmentRepresentationPreferenceSchema, + EntityIdSchema, FileMetadataValueSchema, ImageGenerationOptionsSchema, PdfEmbeddedTextCoverageSchema, + RevisionSchema, + TimestampMsSchema, VideoGenerationOptionsSchema, TtsSettingsSchema, JsonValueSchema, @@ -591,6 +594,60 @@ export const ProviderModelCatalogSchema = z.object({ modelStatusMap: ModelStatusMapSchema }) +export const ProviderCatalogStatusSchema = z.enum([ + 'unknown', + 'fresh', + 'stale', + 'refreshing', + 'error' +]) + +export const ProviderCatalogSourceSchema = z.enum([ + 'remote', + 'provider-db', + 'config', + 'runtime', + 'ollama', + 'acp' +]) + +export const ProviderCatalogErrorCodeSchema = z.enum([ + 'auth', + 'forbidden', + 'not-found', + 'timeout', + 'network', + 'http', + 'invalid-response', + 'unknown' +]) + +export const ProviderCatalogModelSummarySchema = ProviderModelSummarySchema.extend({ + // Only catalog snapshots carry this: the UI needs to tell a raw provider fact from a derived value. + explicitFunctionCall: z.boolean().optional() +}) + +export const ProviderCatalogSnapshotSchema = z.object({ + providerId: EntityIdSchema, + revision: RevisionSchema, + status: ProviderCatalogStatusSchema, + fetchedAt: TimestampMsSchema.nullable(), + source: ProviderCatalogSourceSchema, + lastErrorCode: ProviderCatalogErrorCodeSchema.nullable(), + models: z.array(ProviderCatalogModelSummarySchema) +}) + +export const ProviderCatalogRefreshResultSchema = z.object({ + providerId: EntityIdSchema, + status: z.enum(['ok', 'not-modified', 'failed']), + revision: RevisionSchema, + fetchedAt: TimestampMsSchema.nullable(), + added: z.array(z.string()), + removed: z.array(z.string()), + updated: z.array(z.string()), + errorCode: ProviderCatalogErrorCodeSchema.nullable() +}) + export const AcpConfigOptionValueSchema = z.looseObject({ value: z.string(), label: z.string(), diff --git a/src/shared/contracts/events.ts b/src/shared/contracts/events.ts index f68ce87ce..3ce8caa75 100644 --- a/src/shared/contracts/events.ts +++ b/src/shared/contracts/events.ts @@ -86,6 +86,7 @@ import { providersOllamaPullProgressEvent } from './events/misc.providers.events import { projectEnvironmentsChangedEvent } from './events/project.events' import { providersAcpDebugEvent, + providersCatalogChangedEvent, providersChangedEvent, providersRateLimitConfigUpdatedEvent, providersRateLimitRequestExecutedEvent, @@ -244,6 +245,7 @@ export const DEEPCHAT_EVENT_CATALOG = { [configSystemPromptsChangedEvent.name]: configSystemPromptsChangedEvent, [configCustomPromptsChangedEvent.name]: configCustomPromptsChangedEvent, [providersChangedEvent.name]: providersChangedEvent, + [providersCatalogChangedEvent.name]: providersCatalogChangedEvent, [oauthOpenAICodexStatusChangedEvent.name]: oauthOpenAICodexStatusChangedEvent, [oauthXaiGrokStatusChangedEvent.name]: oauthXaiGrokStatusChangedEvent, [projectEnvironmentsChangedEvent.name]: projectEnvironmentsChangedEvent, diff --git a/src/shared/contracts/events/providers.events.ts b/src/shared/contracts/events/providers.events.ts index be7e6ba9a..707524554 100644 --- a/src/shared/contracts/events/providers.events.ts +++ b/src/shared/contracts/events/providers.events.ts @@ -17,6 +17,34 @@ export const providersChangedEvent = defineEventContract({ }) }) +export const providersCatalogChangedEvent = defineEventContract({ + name: 'providers.catalog.changed', + payload: z.object({ + providerId: z.string().min(1), + revision: z.number().int().nonnegative(), + status: z.enum(['unknown', 'fresh', 'stale', 'refreshing', 'error']), + reason: z.enum(['user', 'stale', 'config', 'provider-db', 'sync', 'external-write', 'acp']), + changed: z.boolean(), + added: z.array(z.string()).optional(), + removed: z.array(z.string()).optional(), + updated: z.array(z.string()).optional(), + errorCode: z + .enum([ + 'auth', + 'forbidden', + 'not-found', + 'timeout', + 'network', + 'http', + 'invalid-response', + 'unknown' + ]) + .nullable() + .optional(), + version: TimestampMsSchema + }) +}) + const ProviderRateLimitConfigSchema = z.object({ enabled: z.boolean(), qpsLimit: z.number() diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 9c4a35f2f..0ed8fbc14 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -405,6 +405,10 @@ import { import { providersAddPublicRoute, providersAddRoute, + providersCatalogEnsureFreshRoute, + providersCatalogGetRoute, + providersCatalogListRoute, + providersCatalogRefreshRoute, providersGetAcpProcessConfigOptionsRoute, providersGetEmbeddingDimensionsRoute, providersGetKeyStatusRoute, @@ -1097,6 +1101,10 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [providersSyncModelScopeMcpServersRoute.name]: providersSyncModelScopeMcpServersRoute, [providersRunAcpDebugActionRoute.name]: providersRunAcpDebugActionRoute, [providersRefreshModelsRoute.name]: providersRefreshModelsRoute, + [providersCatalogGetRoute.name]: providersCatalogGetRoute, + [providersCatalogListRoute.name]: providersCatalogListRoute, + [providersCatalogRefreshRoute.name]: providersCatalogRefreshRoute, + [providersCatalogEnsureFreshRoute.name]: providersCatalogEnsureFreshRoute, [providersListOllamaModelsRoute.name]: providersListOllamaModelsRoute, [providersListOllamaRunningModelsRoute.name]: providersListOllamaRunningModelsRoute, [providersPullOllamaModelRoute.name]: providersPullOllamaModelRoute, diff --git a/src/shared/contracts/routes/providers.routes.ts b/src/shared/contracts/routes/providers.routes.ts index 3cf937766..e4a23935c 100644 --- a/src/shared/contracts/routes/providers.routes.ts +++ b/src/shared/contracts/routes/providers.routes.ts @@ -10,6 +10,8 @@ import { LlmProviderSummarySchema, ModelScopeMcpSyncResultSchema, OllamaModelSchema, + ProviderCatalogRefreshResultSchema, + ProviderCatalogSnapshotSchema, ProviderRateLimitStatusSchema } from '../domainSchemas' import { PROVIDER_IMPORT_CUSTOM_API_TYPES, PROVIDER_IMPORT_SOURCE_IDS } from '../../providerImport' @@ -397,6 +399,50 @@ export const providersRefreshModelsRoute = defineRouteContract({ }) }) +export const providersCatalogGetRoute = defineRouteContract({ + name: 'providers.catalog.get', + input: z.object({ + providerId: EntityIdSchema + }), + output: z.object({ + catalog: ProviderCatalogSnapshotSchema + }) +}) + +export const providersCatalogListRoute = defineRouteContract({ + name: 'providers.catalog.list', + input: z + .object({ + providerIds: z.array(EntityIdSchema).optional() + }) + .default({}), + output: z.object({ + catalogs: z.array(ProviderCatalogSnapshotSchema) + }) +}) + +export const providersCatalogRefreshRoute = defineRouteContract({ + name: 'providers.catalog.refresh', + input: z.object({ + providerId: EntityIdSchema, + reason: z.enum(['user', 'config']).optional() + }), + output: z.object({ + result: ProviderCatalogRefreshResultSchema, + catalog: ProviderCatalogSnapshotSchema + }) +}) + +export const providersCatalogEnsureFreshRoute = defineRouteContract({ + name: 'providers.catalog.ensureFresh', + input: z.object({ + providerId: EntityIdSchema + }), + output: z.object({ + scheduled: z.boolean() + }) +}) + export const providersListOllamaModelsRoute = defineRouteContract({ name: 'providers.listOllamaModels', input: z.object({ diff --git a/src/shared/types/providerCatalog.ts b/src/shared/types/providerCatalog.ts new file mode 100644 index 000000000..de4ac25f8 --- /dev/null +++ b/src/shared/types/providerCatalog.ts @@ -0,0 +1,69 @@ +import type { RENDERER_MODEL_META } from './provider' + +export type ProviderCatalogStatus = 'unknown' | 'fresh' | 'stale' | 'refreshing' | 'error' + +export type ProviderCatalogSource = + | 'remote' + | 'provider-db' + | 'config' + | 'runtime' + | 'ollama' + | 'acp' + +export type ProviderCatalogRefreshReason = + | 'user' + | 'stale' + | 'config' + | 'provider-db' + | 'sync' + | 'external-write' + | 'acp' + +export type ProviderCatalogErrorCode = + | 'auth' + | 'forbidden' + | 'not-found' + | 'timeout' + | 'network' + | 'http' + | 'invalid-response' + | 'unknown' + +export type ProviderCatalogState = { + providerId: string + revision: number + fetchedAt: number | null + source: ProviderCatalogSource | null + lastRefreshReason: ProviderCatalogRefreshReason | null + lastErrorCode: ProviderCatalogErrorCode | null + lastErrorAt: number | null +} + +export type ProviderCatalogStatePatch = { + fetchedAt?: number | null + source?: ProviderCatalogSource | null + lastRefreshReason?: ProviderCatalogRefreshReason | null + lastErrorCode?: ProviderCatalogErrorCode | null + lastErrorAt?: number | null +} + +export type ProviderCatalogSnapshot = { + providerId: string + revision: number + status: ProviderCatalogStatus + fetchedAt: number | null + source: ProviderCatalogSource + lastErrorCode: ProviderCatalogErrorCode | null + models: RENDERER_MODEL_META[] +} + +export type ProviderCatalogRefreshResult = { + providerId: string + status: 'ok' | 'not-modified' | 'failed' + revision: number + fetchedAt: number | null + added: string[] + removed: string[] + updated: string[] + errorCode: ProviderCatalogErrorCode | null +} diff --git a/test/main/provider/backgroundModelSync.test.ts b/test/main/provider/backgroundModelSync.test.ts index 849a46432..47be6149d 100644 --- a/test/main/provider/backgroundModelSync.test.ts +++ b/test/main/provider/backgroundModelSync.test.ts @@ -1,9 +1,11 @@ import type { ProviderSettingsPort } from '@/provider/settings' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LLM_PROVIDER } from '@shared/types/provider' +import type { LLM_PROVIDER, MODEL_META } from '@shared/types/provider' +import type { ProviderCatalogState, ProviderCatalogStatePatch } from '@shared/types/providerCatalog' import type { MainDatabase } from '../../../src/main/data/mainDatabase' import { ProviderRuntime } from '../../../src/main/provider' import { AiSdkProvider } from '../../../src/main/provider/providers/aiSdkProvider' +import { BaseLLMProvider } from '../../../src/main/provider/baseProvider' import { AcpRuntimeOwner } from '@/agent/acp/client' import { AcpSessionPersistence } from '@/agent/acp/runtime' @@ -71,12 +73,54 @@ const createProvider = (overrides?: Partial): LLM_PROVIDER => ({ ...overrides }) -const createProviderSettings = (provider = createProvider()) => - ({ +const createProviderSettings = (provider = createProvider()) => { + const providerModels: Record = {} + const customModels: Record = {} + const catalogStates = new Map() + + return { getProviders: vi.fn().mockReturnValue([provider]), getProviderById: vi.fn().mockReturnValue(provider), getProviderModels: vi.fn().mockReturnValue([]), getCustomModels: vi.fn().mockReturnValue([]), + getRawProviderModels: vi.fn((providerId: string) => providerModels[providerId] ?? []), + getRawCustomModels: vi.fn((providerId: string) => customModels[providerId] ?? []), + getDbProviderModels: vi.fn().mockReturnValue([]), + resolveEffectiveModels: vi.fn((models: MODEL_META[]) => models), + getBatchModelStatus: vi.fn((_providerId: string, modelIds: string[]) => + Object.fromEntries(modelIds.map((modelId) => [modelId, false])) + ), + getProviderCatalogState: vi.fn((providerId: string) => catalogStates.get(providerId)), + setProviderCatalogState: vi.fn( + ( + providerId: string, + patch: ProviderCatalogStatePatch, + options: { bumpRevision?: boolean } = {} + ) => { + const existing = catalogStates.get(providerId) + const next: ProviderCatalogState = { + providerId, + revision: (existing?.revision ?? 0) + (options.bumpRevision ? 1 : 0), + fetchedAt: + patch.fetchedAt !== undefined ? patch.fetchedAt : (existing?.fetchedAt ?? null), + source: patch.source !== undefined ? patch.source : (existing?.source ?? null), + lastRefreshReason: + patch.lastRefreshReason !== undefined + ? patch.lastRefreshReason + : (existing?.lastRefreshReason ?? null), + lastErrorCode: + patch.lastErrorCode !== undefined + ? patch.lastErrorCode + : (existing?.lastErrorCode ?? null), + lastErrorAt: + patch.lastErrorAt !== undefined ? patch.lastErrorAt : (existing?.lastErrorAt ?? null) + } + catalogStates.set(providerId, next) + return next + } + ), + deleteProviderCatalogState: vi.fn((providerId: string) => catalogStates.delete(providerId)), + notifyModelsChanged: vi.fn(), getModelConfig: vi.fn().mockReturnValue({ maxTokens: 4096, contextLength: 8192, @@ -92,12 +136,15 @@ const createProviderSettings = (provider = createProvider()) => lastUpdated: Date.now(), providersCount: 1 }), - setProviderModels: vi.fn(), + setProviderModels: vi.fn((providerId: string, models: MODEL_META[]) => { + providerModels[providerId] = models + }), getModelStatus: vi.fn().mockReturnValue(true), updateCustomModel: vi.fn(), addCustomModel: vi.fn(), removeCustomModel: vi.fn() - }) as unknown as ProviderSettingsPort + } as unknown as ProviderSettingsPort +} const mockSqlitePresenter = { getAcpSession: vi.fn().mockResolvedValue(null), @@ -167,9 +214,9 @@ describe('ProviderRuntime background model sync', () => { 'xiaomi-token-plan-sgp', 'xiaomi-token-plan-ams' ])('re-syncs enabled %s models when provider-db updates', async (id) => { - const refreshSpy = vi - .spyOn(AiSdkProvider.prototype, 'refreshModels') - .mockResolvedValue(undefined) + const fetchCatalogSpy = vi + .spyOn(BaseLLMProvider.prototype, 'fetchCatalog') + .mockResolvedValue([]) const presenter = createProviderRuntime( createProviderSettings( @@ -183,17 +230,17 @@ describe('ProviderRuntime background model sync', () => { ) await Promise.resolve() await Promise.resolve() - refreshSpy.mockClear() + fetchCatalogSpy.mockClear() await notifyProviderDbUpdated(presenter) - expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1) }) it('skips OpenAI Codex when provider-db updates in the background', async () => { - const refreshSpy = vi - .spyOn(AiSdkProvider.prototype, 'refreshModels') - .mockResolvedValue(undefined) + const fetchCatalogSpy = vi + .spyOn(BaseLLMProvider.prototype, 'fetchCatalog') + .mockResolvedValue([]) const presenter = createProviderRuntime( createProviderSettings( @@ -207,17 +254,18 @@ describe('ProviderRuntime background model sync', () => { ) await Promise.resolve() await Promise.resolve() - refreshSpy.mockClear() + fetchCatalogSpy.mockClear() await notifyProviderDbUpdated(presenter) - expect(refreshSpy).not.toHaveBeenCalled() + expect(fetchCatalogSpy).not.toHaveBeenCalled() + expect(presenter.getProviderCatalog('openai-codex').revision).toBe(1) }) it('ignores provider-db updates for providers that do not use the provider DB catalog', async () => { - const refreshSpy = vi - .spyOn(AiSdkProvider.prototype, 'refreshModels') - .mockResolvedValue(undefined) + const fetchCatalogSpy = vi + .spyOn(BaseLLMProvider.prototype, 'fetchCatalog') + .mockResolvedValue([]) const presenter = createProviderRuntime(createProviderSettings()) await Promise.resolve() @@ -225,14 +273,15 @@ describe('ProviderRuntime background model sync', () => { await notifyProviderDbUpdated(presenter) - expect(refreshSpy).not.toHaveBeenCalled() + expect(fetchCatalogSpy).not.toHaveBeenCalled() + expect(presenter.getProviderCatalog('novita').revision).toBe(1) }) it('coalesces duplicate background refreshes for the same provider', async () => { - let resolveRefresh: (() => void) | null = null - const refreshSpy = vi.spyOn(AiSdkProvider.prototype, 'refreshModels').mockReturnValue( - new Promise((resolve) => { - resolveRefresh = resolve + let resolveFetch: ((models: MODEL_META[]) => void) | null = null + const fetchCatalogSpy = vi.spyOn(BaseLLMProvider.prototype, 'fetchCatalog').mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve }) ) @@ -249,20 +298,19 @@ describe('ProviderRuntime background model sync', () => { await Promise.resolve() await Promise.resolve() - expect(refreshSpy).not.toHaveBeenCalled() + expect(fetchCatalogSpy).not.toHaveBeenCalled() await notifyProviderDbUpdated(presenter) await notifyProviderDbUpdated(presenter) - expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1) - resolveRefresh?.() - await Promise.resolve() - await Promise.resolve() + resolveFetch?.([]) + await vi.waitFor(() => expect(presenter.getProviderCatalog('doubao').status).toBe('fresh')) await notifyProviderDbUpdated(presenter) - expect(refreshSpy).toHaveBeenCalledTimes(2) + expect(fetchCatalogSpy).toHaveBeenCalledTimes(2) }) it.each([ @@ -333,11 +381,10 @@ describe('ProviderRuntime background model sync', () => { expect(refreshSpy).toHaveBeenCalledTimes(1) }) - it('logs provider-db refresh failures without blocking presenter initialization', async () => { - const refreshSpy = vi - .spyOn(AiSdkProvider.prototype, 'refreshModels') + it('records a failed background refresh without blocking presenter initialization', async () => { + const fetchCatalogSpy = vi + .spyOn(BaseLLMProvider.prototype, 'fetchCatalog') .mockRejectedValue(new Error('refresh failed')) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const presenter = createProviderRuntime( createProviderSettings( @@ -353,14 +400,10 @@ describe('ProviderRuntime background model sync', () => { await Promise.resolve() await notifyProviderDbUpdated(presenter) - await Promise.resolve() - await Promise.resolve() + await vi.waitFor(() => expect(presenter.getProviderCatalog('doubao').status).toBe('error')) expect(presenter.getProviders()).toHaveLength(1) - expect(refreshSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith( - '[ProviderRuntime] Failed to refresh models for provider doubao during provider-db-updated:', - expect.any(Error) - ) + expect(presenter.getProviderCatalog('doubao').lastErrorCode).toBe('unknown') + expect(fetchCatalogSpy).toHaveBeenCalledTimes(1) }) }) diff --git a/test/main/provider/catalog/providerCatalogService.test.ts b/test/main/provider/catalog/providerCatalogService.test.ts new file mode 100644 index 000000000..b2b04d19d --- /dev/null +++ b/test/main/provider/catalog/providerCatalogService.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it, vi } from 'vitest' +import type { LLM_PROVIDER, MODEL_META, RENDERER_MODEL_META } from '@shared/types/provider' +import type { ProviderCatalogState, ProviderCatalogStatePatch } from '@shared/types/providerCatalog' +import { + ProviderCatalogService, + type ProviderCatalogSettingsPort, + type ProviderCatalogSourcePort +} from '@/provider/catalog/providerCatalogService' + +type CatalogStateOptions = { bumpRevision?: boolean } + +const createHarness = (options: { + providers: LLM_PROVIDER[] + providerModels?: Record + customModels?: Record + dbModels?: Record + modelStatus?: Record + now?: () => number +}) => { + const providerModels = structuredClone(options.providerModels ?? {}) + const customModels = structuredClone(options.customModels ?? {}) + const dbModels = structuredClone(options.dbModels ?? {}) + const catalogStates = new Map() + const now = options.now ?? (() => 1_000) + + const setProviderCatalogState = vi.fn( + ( + providerId: string, + patch: ProviderCatalogStatePatch, + stateOptions: CatalogStateOptions = {} + ) => { + const existing = catalogStates.get(providerId) + const next: ProviderCatalogState = { + providerId, + revision: (existing?.revision ?? 0) + (stateOptions.bumpRevision ? 1 : 0), + fetchedAt: patch.fetchedAt !== undefined ? patch.fetchedAt : (existing?.fetchedAt ?? null), + source: patch.source !== undefined ? patch.source : (existing?.source ?? null), + lastRefreshReason: + patch.lastRefreshReason !== undefined + ? patch.lastRefreshReason + : (existing?.lastRefreshReason ?? null), + lastErrorCode: + patch.lastErrorCode !== undefined + ? patch.lastErrorCode + : (existing?.lastErrorCode ?? null), + lastErrorAt: + patch.lastErrorAt !== undefined ? patch.lastErrorAt : (existing?.lastErrorAt ?? null) + } + catalogStates.set(providerId, next) + return next + } + ) + + const settings = { + getProviders: vi.fn(() => options.providers), + getProviderById: vi.fn( + (providerId: string) => + options.providers.find((provider) => provider.id === providerId) ?? undefined + ), + getRawProviderModels: vi.fn((providerId: string) => providerModels[providerId] ?? []), + getRawCustomModels: vi.fn((providerId: string) => customModels[providerId] ?? []), + getDbProviderModels: vi.fn((providerId: string) => dbModels[providerId] ?? []), + resolveEffectiveModels: vi.fn((models: MODEL_META[]) => models), + getBatchModelStatus: vi.fn( + (_providerId: string, modelIds: string[]) => + Object.fromEntries( + modelIds.map((modelId) => [modelId, options.modelStatus?.[modelId] ?? false]) + ) as Record + ), + setProviderModels: vi.fn((providerId: string, models: MODEL_META[]) => { + providerModels[providerId] = structuredClone(models) + }), + getProviderCatalogState: vi.fn((providerId: string) => catalogStates.get(providerId)), + setProviderCatalogState, + deleteProviderCatalogState: vi.fn((providerId: string) => { + catalogStates.delete(providerId) + }), + notifyModelsChanged: vi.fn(), + refreshProviderDb: vi.fn().mockResolvedValue({ status: 'updated', providersCount: 1 }) + } as unknown as ProviderCatalogSettingsPort + + const source: ProviderCatalogSourcePort = { + fetchCatalog: vi.fn(), + reloadModels: vi.fn() + } + const publishEvent = vi.fn() + const service = new ProviderCatalogService({ + settings, + source, + publishEvent: publishEvent as never, + now + }) + + return { + service, + settings, + source, + publishEvent, + providerModels, + customModels, + catalogStates, + setProviderCatalogState, + refreshProviderDb: vi.mocked(settings.refreshProviderDb) + } +} + +const provider = (id: string, overrides: Partial = {}): LLM_PROVIDER => + ({ + id, + name: id, + apiType: id, + apiKey: `${id}-key`, + baseUrl: `https://${id}.example.com`, + enable: true, + ...overrides + }) as LLM_PROVIDER + +const model = (id: string, overrides: Partial = {}): MODEL_META => ({ + id, + name: id, + group: 'default', + providerId: 'new-api', + isCustom: false, + ...overrides +}) + +describe('ProviderCatalogService', () => { + it('projects merged catalog sources with status and capability defaults', () => { + const mistralDbModel = { + id: 'mistral-large', + name: 'Mistral Large', + group: 'default', + providerId: 'mistral', + enabled: false, + isCustom: false + } as RENDERER_MODEL_META + const harness = createHarness({ + providers: [provider('mistral')], + dbModels: { mistral: [mistralDbModel] }, + providerModels: { + mistral: [model('mistral-large', { providerId: 'mistral', vision: true })] + }, + customModels: { mistral: [model('my-model', { providerId: 'mistral', isCustom: true })] }, + modelStatus: { 'mistral-large': true, 'my-model': true } + }) + + const snapshot = harness.service.getSnapshot('mistral') + + expect(snapshot).toMatchObject({ + providerId: 'mistral', + revision: 0, + status: 'unknown', + source: 'provider-db', + fetchedAt: null, + lastErrorCode: null + }) + expect(snapshot.models.map((item) => item.id).sort()).toEqual(['mistral-large', 'my-model']) + expect(snapshot.models.find((item) => item.id === 'mistral-large')).toMatchObject({ + enabled: true, + isCustom: false, + vision: true, + functionCall: true + }) + expect(snapshot.models.find((item) => item.id === 'my-model')).toMatchObject({ + enabled: true, + isCustom: true + }) + }) + + it('persists upstream discoveries and reports a diff on user refresh', async () => { + const harness = createHarness({ + providers: [provider('new-api')], + providerModels: { 'new-api': [model('old-model'), model('kept-model')] } + }) + vi.mocked(harness.source.fetchCatalog).mockResolvedValue([ + model('kept-model'), + model('gpt-6.1-sol', { group: 'openai' }) + ]) + + const result = await harness.service.refresh('new-api', 'user') + + expect(result).toMatchObject({ + providerId: 'new-api', + status: 'ok', + revision: 1, + added: ['gpt-6.1-sol'], + removed: ['old-model'], + updated: [], + errorCode: null + }) + expect(harness.providerModels['new-api'].map((item) => item.id)).toEqual([ + 'kept-model', + 'gpt-6.1-sol' + ]) + expect(harness.settings.notifyModelsChanged).toHaveBeenCalledWith('new-api') + expect(harness.source.reloadModels).toHaveBeenCalledWith('new-api') + expect(harness.publishEvent).toHaveBeenCalledWith( + 'providers.catalog.changed', + expect.objectContaining({ + providerId: 'new-api', + revision: 1, + status: 'fresh', + reason: 'user', + changed: true, + added: ['gpt-6.1-sol'], + removed: ['old-model'] + }) + ) + expect(harness.service.getSnapshot('new-api').status).toBe('fresh') + }) + + it('does not rewrite or notify when the upstream catalog is unchanged', async () => { + const existing = model('gpt-6.1-sol', { providerId: 'new-api' }) + const harness = createHarness({ + providers: [provider('new-api')], + providerModels: { 'new-api': [existing] } + }) + vi.mocked(harness.source.fetchCatalog).mockResolvedValue([existing]) + + const result = await harness.service.refresh('new-api', 'user') + + expect(result).toMatchObject({ status: 'not-modified', revision: 0, added: [] }) + expect(harness.settings.setProviderModels).not.toHaveBeenCalled() + expect(harness.settings.notifyModelsChanged).not.toHaveBeenCalled() + expect(harness.publishEvent).toHaveBeenCalledWith( + 'providers.catalog.changed', + expect.objectContaining({ changed: false, status: 'fresh', errorCode: null }) + ) + }) + + it('keeps the last known catalog and records a sanitized error when refresh fails', async () => { + const harness = createHarness({ + providers: [provider('new-api')], + providerModels: { 'new-api': [model('last-known')] } + }) + vi.mocked(harness.source.fetchCatalog).mockRejectedValue( + Object.assign(new Error('upstream rejected the request'), { status: 401 }) + ) + + const result = await harness.service.refresh('new-api', 'user') + + expect(result).toMatchObject({ status: 'failed', errorCode: 'auth', added: [], removed: [] }) + expect(harness.providerModels['new-api'].map((item) => item.id)).toEqual(['last-known']) + expect(harness.settings.setProviderModels).not.toHaveBeenCalled() + expect(harness.publishEvent).toHaveBeenCalledWith( + 'providers.catalog.changed', + expect.objectContaining({ status: 'error', changed: false, errorCode: 'auth' }) + ) + + const snapshot = harness.service.getSnapshot('new-api') + expect(snapshot).toMatchObject({ status: 'error', lastErrorCode: 'auth' }) + expect(snapshot.models.map((item) => item.id)).toEqual(['last-known']) + }) + + it('syncs the provider DB before a user refresh and survives a failed download', async () => { + const harness = createHarness({ providers: [provider('mistral')] }) + harness.refreshProviderDb.mockResolvedValue({ + status: 'error', + lastUpdated: null, + providersCount: 1, + message: 'offline' + }) + vi.mocked(harness.source.fetchCatalog).mockResolvedValue([ + model('mistral-large', { providerId: 'mistral' }) + ]) + + const result = await harness.service.refresh('mistral', 'user') + + expect(harness.refreshProviderDb).toHaveBeenCalledWith(true) + expect(harness.refreshProviderDb.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(harness.source.fetchCatalog).mock.invocationCallOrder[0] + ) + expect(result).toMatchObject({ status: 'ok', added: ['mistral-large'], errorCode: null }) + }) + + it('deduplicates concurrent refreshes for the same provider', async () => { + const harness = createHarness({ providers: [provider('new-api')] }) + let resolveFetch: (models: MODEL_META[]) => void = () => undefined + vi.mocked(harness.source.fetchCatalog).mockImplementation( + () => new Promise((resolve) => (resolveFetch = resolve)) + ) + + const first = harness.service.refresh('new-api', 'user') + const second = harness.service.refresh('new-api', 'user') + resolveFetch([model('gpt-6.1-sol')]) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(harness.source.fetchCatalog).toHaveBeenCalledTimes(1) + expect(harness.service.getSnapshot('new-api').models.map((item) => item.id)).toEqual([ + 'gpt-6.1-sol' + ]) + }) + + it('refreshes stale remote catalogs at most once and skips fresh or disabled providers', async () => { + let now = 10_000 + const harness = createHarness({ providers: [provider('new-api')], now: () => now }) + vi.mocked(harness.source.fetchCatalog).mockResolvedValue([model('gpt-6.1-sol')]) + + expect(harness.service.ensureFresh('new-api')).toBe(true) + await vi.waitFor(() => expect(harness.service.getSnapshot('new-api').status).toBe('fresh')) + expect(harness.service.ensureFresh('new-api')).toBe(false) + expect(harness.source.fetchCatalog).toHaveBeenCalledTimes(1) + + now += 6 * 60 * 60 * 1000 + 1 + expect(harness.service.ensureFresh('new-api')).toBe(true) + await vi.waitFor(() => expect(harness.source.fetchCatalog).toHaveBeenCalledTimes(2)) + + const disabled = createHarness({ + providers: [provider('new-api', { enable: false })], + now: () => now + }) + expect(disabled.service.ensureFresh('new-api')).toBe(false) + expect(disabled.source.fetchCatalog).not.toHaveBeenCalled() + }) + + it('never auto-refreshes ACP catalogs', () => { + const harness = createHarness({ providers: [provider('acp')] }) + + expect(harness.service.ensureFresh('acp')).toBe(false) + expect(harness.source.fetchCatalog).not.toHaveBeenCalled() + }) + + it('bumps the revision without fetching for remote providers on a provider DB update', async () => { + const harness = createHarness({ + providers: [provider('new-api'), provider('mistral')] + }) + vi.mocked(harness.source.fetchCatalog).mockResolvedValue([]) + + harness.service.onProviderDbUpdated() + await vi.waitFor(() => expect(harness.source.fetchCatalog).toHaveBeenCalledTimes(1)) + + expect(harness.source.fetchCatalog).toHaveBeenCalledWith('mistral') + expect(harness.service.getSnapshot('new-api').revision).toBe(1) + expect(harness.publishEvent).toHaveBeenCalledWith( + 'providers.catalog.changed', + expect.objectContaining({ providerId: 'new-api', revision: 1, reason: 'provider-db' }) + ) + }) + + it('replaces an externally written catalog (ACP agents) and reports the diff', () => { + const harness = createHarness({ + providers: [provider('acp')], + providerModels: { acp: [model('removed-agent', { providerId: 'acp', isCustom: true })] } + }) + + const result = harness.service.replaceFromSource( + 'acp', + [model('claude-code', { providerId: 'acp', isCustom: true })], + 'acp' + ) + + expect(result).toMatchObject({ + status: 'ok', + revision: 1, + added: ['claude-code'], + removed: ['removed-agent'] + }) + expect(harness.providerModels.acp).toMatchObject([{ id: 'claude-code', isCustom: true }]) + expect(harness.settings.notifyModelsChanged).toHaveBeenCalledWith('acp') + }) +}) diff --git a/test/main/provider/data/settingsTable.test.ts b/test/main/provider/data/settingsTable.test.ts index a2e31c388..d5f21967e 100644 --- a/test/main/provider/data/settingsTable.test.ts +++ b/test/main/provider/data/settingsTable.test.ts @@ -309,6 +309,55 @@ describeIfSqlite('ProviderSettingsTable', () => { db.close() }) + it('stores catalog operational state with monotonic revisions', () => { + const { db, table } = createTable() + + expect(table.getProviderCatalogState('new-api')).toBeUndefined() + + const initial = table.setProviderCatalogState('new-api', { + fetchedAt: 1000, + source: 'remote', + lastRefreshReason: 'user' + }) + expect(initial).toEqual({ + providerId: 'new-api', + revision: 0, + fetchedAt: 1000, + source: 'remote', + lastRefreshReason: 'user', + lastErrorCode: null, + lastErrorAt: null + }) + + const refreshed = table.setProviderCatalogState( + 'new-api', + { fetchedAt: 2000, lastErrorCode: null, lastErrorAt: null }, + { bumpRevision: true } + ) + expect(refreshed.revision).toBe(1) + expect(refreshed.fetchedAt).toBe(2000) + + const failed = table.setProviderCatalogState('new-api', { + lastErrorCode: 'timeout', + lastErrorAt: 3000 + }) + expect(failed).toMatchObject({ + revision: 1, + fetchedAt: 2000, + lastErrorCode: 'timeout', + lastErrorAt: 3000 + }) + + const cleared = table.setProviderCatalogState('new-api', { fetchedAt: null }) + expect(cleared.fetchedAt).toBeNull() + expect(cleared.lastErrorCode).toBe('timeout') + + table.deleteProviderCatalogState('new-api') + expect(table.getProviderCatalogState('new-api')).toBeUndefined() + + db.close() + }) + it('migrates legacy rows using explicit user intent without value heuristics', () => { const { db, table } = createTable() const insert = db.prepare( From e01820f0d34bc2d507d1345b007f91768558aadc Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Sat, 31 Oct 2026 18:33:57 +0800 Subject: [PATCH 03/16] fix(provider): refresh models through catalog service --- docs/architecture/provider-catalog/plan.md | 8 +- src/renderer/api/ProviderCatalogClient.ts | 51 ++++++++ src/renderer/api/index.ts | 1 + .../ModelProviderSettingsDetail.vue | 36 +++++- .../42-provider-connection-save.smoke.spec.ts | 56 +++++++++ test/renderer/api/clients.test.ts | 56 +++++++++ .../ModelProviderSettingsDetail.test.ts | 116 ++++++++++++++---- 7 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 src/renderer/api/ProviderCatalogClient.ts diff --git a/docs/architecture/provider-catalog/plan.md b/docs/architecture/provider-catalog/plan.md index 5307b6eb0..5a25b72e9 100644 --- a/docs/architecture/provider-catalog/plan.md +++ b/docs/architecture/provider-catalog/plan.md @@ -27,11 +27,11 @@ no renderer behavior changes yet. ## Phase 2: Settings refresh uses the catalog service -- [ ] Add a renderer provider-catalog client for the new routes and event. -- [ ] Change the provider settings refresh button to `catalog.refresh(user)`, then refresh the +- [x] Add a renderer provider-catalog client for the new routes and event. +- [x] Change the provider settings refresh button to `catalog.refresh(user)`, then refresh the existing model-store projection; show the existing success/failure notifications. -- [ ] Show last successful sync time from the snapshot; on error, keep the existing failure copy. -- [ ] Extend the provider smoke spec with a two-catalog mock server: first refresh returns catalog +- [x] Show last successful sync time from the snapshot; on error, keep the existing failure copy. +- [x] Extend the provider smoke spec with a two-catalog mock server: first refresh returns catalog A, second refresh returns catalog B, and the new model becomes visible. Completion condition: the reported stale-catalog defect is fixed through the new service; the diff --git a/src/renderer/api/ProviderCatalogClient.ts b/src/renderer/api/ProviderCatalogClient.ts new file mode 100644 index 000000000..d085b3ebc --- /dev/null +++ b/src/renderer/api/ProviderCatalogClient.ts @@ -0,0 +1,51 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { providersCatalogChangedEvent } from '@shared/contracts/events' +import type { DeepchatEventPayload } from '@shared/contracts/events' +import { + providersCatalogEnsureFreshRoute, + providersCatalogGetRoute, + providersCatalogListRoute, + providersCatalogRefreshRoute +} from '@shared/contracts/routes' +import type { ProviderCatalogSnapshot } from '@shared/types/providerCatalog' +import { getDeepchatBridge } from './core' + +export function createProviderCatalogClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function getCatalog(providerId: string): Promise { + const result = await bridge.invoke(providersCatalogGetRoute.name, { providerId }) + return result.catalog + } + + async function listCatalogs(providerIds?: string[]): Promise { + const result = await bridge.invoke( + providersCatalogListRoute.name, + providerIds ? { providerIds } : {} + ) + return result.catalogs + } + + async function refreshCatalog(providerId: string, reason: 'user' | 'config' = 'user') { + return await bridge.invoke(providersCatalogRefreshRoute.name, { providerId, reason }) + } + + async function ensureFresh(providerId: string): Promise { + const result = await bridge.invoke(providersCatalogEnsureFreshRoute.name, { providerId }) + return result.scheduled + } + + function onCatalogChanged( + listener: (payload: DeepchatEventPayload<'providers.catalog.changed'>) => void + ) { + return bridge.on(providersCatalogChangedEvent.name, listener) + } + + return { + getCatalog, + listCatalogs, + refreshCatalog, + ensureFresh, + onCatalogChanged + } +} + +export type ProviderCatalogClient = ReturnType diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 86565196e..2dbc0c589 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -17,6 +17,7 @@ export * from './NowledgeMemClient' export * from './OAuthClient' export * from './OnboardingClient' export * from './PluginClient' +export * from './ProviderCatalogClient' export * from './ProviderClient' export * from './ProjectClient' export * from './RemoteControlClient' diff --git a/src/renderer/settings/components/ModelProviderSettingsDetail.vue b/src/renderer/settings/components/ModelProviderSettingsDetail.vue index b6af642e7..5ba773fb7 100644 --- a/src/renderer/settings/components/ModelProviderSettingsDetail.vue +++ b/src/renderer/settings/components/ModelProviderSettingsDetail.vue @@ -51,6 +51,13 @@ > {{ t('settings.provider.refreshModelsWithMetadataHint') }}

+

+ {{ t('settings.data.lastSyncTime') }}: {{ catalogLastSyncedAt }} +