Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions docs/architecture/model-capability-identity/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,25 @@ capability identity using this precedence:
6. recognized model family;
7. transport-provider match;
8. unique global exact or canonical match;
9. transport fallback with an unknown catalog model.

An ambiguous global match is not selected by provider iteration order. All capabilities for a
resolved model use the same provider and catalog model identity.
9. dominant global capability match;
10. transport fallback with an unknown catalog model.

An ambiguous global match is not selected by provider iteration order. The dominant global match
accepts a model ID only when one capability signature covers at least two thirds of its
provider-db entries, and then selects a representative entry deterministically: the most common
context/output pair wins, ties prefer larger limits, and within that group a canonical vendor
entry wins before alphabetical provider order. Below that coverage the ID stays unknown. Lookup
keys are normalized before matching, so routing prefixes such as `auto/deepseek-v4.1-flash`
resolve against the unqualified catalog ID while `requestModelId` keeps the prefix. All
capabilities for a resolved model use the same provider and catalog model identity.

Qualified namespaces, owner metadata, and canonical model-family rules may identify a known model
origin even when an aggregator returns an unqualified ID. The maintained origin rules cover
families with an authoritative provider-db owner, including GLM, MiniMax, StepFun, Mistral, Cohere,
and OpenAI GPT-OSS. Families without an authoritative owner record, or whose provider-db mirrors
disagree about reasoning semantics, remain unknown rather than selecting an arbitrary mirror.
OpenAI (`gpt-*`, `chatgpt-*`, `o*` reasoning models, Codex, Sora, DALL-E, embeddings, Whisper,
TTS, moderation), Gemma, and GPT-OSS. Families without an authoritative owner record, or whose
provider-db mirrors disagree about reasoning semantics, remain unknown rather than selecting an
arbitrary mirror.

Resolved identity distinguishes the ID sent to the service from the optional ID selected in the
catalog:
Expand Down
96 changes: 96 additions & 0 deletions docs/architecture/provider-catalog/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Provider Catalog Implementation Plan

Specification: [spec.md](./spec.md)

## Phase 1: Main-process catalog core

- [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`.
- [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.
- [x] Add raw provider/custom model and catalog-state accessors to `ProviderSettings`; keep
effective resolution in the existing resolver.
- [x] Implement the pure catalog projection: provider-db identities + raw provider facts +
custom definitions -> one `resolveEffectiveModels` call -> `RENDERER_MODEL_META[]` with status.
- [x] Implement `ProviderCatalogService`: snapshot read, list, ensure-fresh, refresh dedupe,
diffing, state transitions, sanitized errors, last-known-good behavior, revision events.
- [x] Add pure `fetchCatalog` and `reloadModelsFromSettings` to the base provider; keep current
persistence and legacy routes untouched.
- [x] Wire the service in `ProviderRuntime`, register routes, register the typed event, and keep
legacy `models.changed` emissions.
- [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;
no renderer behavior changes yet.

## Phase 2: Settings refresh uses the catalog service

- [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.
- [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
renderer still uses the legacy model store for the rest of the page.

## Phase 3: Renderer projection

- [x] Replace `refreshStandardModels`/`refreshCustomModels` merging and the empty-catalog
discovery gate with catalog snapshot reads.
- [x] Derive `allProviderModels`, `enabledModels`, and `customModels` from snapshots while keeping
the store's public shape for existing consumers.
- [x] Initialize from one catalog list call and request stale revalidation for enabled providers.
- [x] Listen to `providers.catalog.changed` for catalog updates; keep status/config listeners.
- [x] Remove renderer `getDbProviderModels`, per-model effective-config lookups, and the
discovery/rerun state machine from the standard path.
- [x] Keep the ACP special path until Phase 4; include Ollama installed models through the
catalog source.
- [x] 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

- [x] Make provider `fetchCatalog` the only fetch adapter; remove persistence from `fetchModels`
and from draft validation.
- [x] Delegate `providers.refreshModels` and the provider-db background refresh to the catalog
service.
- [x] Route ACP catalog writes through `replaceFromSource`; route synced provider/model changes
through `invalidateFromSync`.
- [x] Invalidate provider config-driven catalogs on base URL, API key, or protocol changes.
- [x] Remove obsolete discovery helpers, unused routes, and legacy refresh state; keep
`models.changed` only for non-catalog consumers.
- [x] Add an architecture guard proving `setProviderModels` is only called by the catalog service
and the settings persistence layer.
- [x] 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

- [x] Review the complete diff for source-of-truth violations, duplicated merges, secret leakage
in errors/logs, and behavior changes to CLI/agent paths.
- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`.
- [x] Run targeted main and renderer suites plus the provider catalog smoke spec.
- [x] Remove temporary probes; keep only durable regression coverage.
- [x] Do not push. Leave the branch and validation evidence for the user.

Validation evidence (2026-10-31):

- `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`: pass.
- `pnpm run test:main`: 678 files, 9902 tests passed (4 skipped).
- `pnpm run test:renderer`: 288 files, 2750 tests passed.
- `test/e2e/specs/42-provider-connection-save.smoke.spec.ts`: all 4 smoke tests pass,
including the new "refresh discovers models added to the upstream catalog" regression.
- The model-check dialog previously hid the whole provider catalog when only a custom model was
enabled: its enabled-only filter was gated on any enabled model. `5e23e9ff9` gates the filter
on enabled catalog models only and adds a regression test in
`test/renderer/components/ModelCheckDialog.test.ts`; the renderer suite then reports 2750
passing tests.
197 changes: 197 additions & 0 deletions docs/architecture/provider-catalog/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# 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; IDs that no longer
exist are skipped so one stale caller cannot fail the batch;
- `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. It advances on every persisted state change, including metadata-only updates and
failures, so equal revisions mean identical state. `changed: false` status events update freshness
without refetching the model list.
A configuration change invalidates any refresh still running on the previous provider instance:
its result or error is discarded, and the `config` refresh for the rebuilt instance takes over.
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 replaces source rows and records its catalog state in one transaction, 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.
Loading