Turn provider records into presets derived from a service, with an additive migration and generated samples
- Dominant language
- JavaScript
- Stars
- 38
- Forks
- 31
- Avg merge
- 22m
- Merged PRs (30d)
- 988
Description
Part of #7561 (epic).
Blocked by #7564
## Problem / Goal
Provider records are the only thing a selector can name, and each one duplicates its service's endpoint, key, env block and model list. Turn records into **presets**: a named (harness, method, service) tuple plus selection defaults and overrides, with connection-owned fields derived from the service on every save and still materialized into `data/providers.json` so older releases keep executing them.
## Context
- Record fields and their ownership are catalogued in `aiToolkit/providers.js:655 buildProviderRecord` and `internal/providerModes.js` (`MODE_GROUPED_KEYS = endpoint, apiKey, envVars, credentialBootstrap`; `PROVIDER_MODE_OVERRIDE_KEYS = args, headlessArgs, tuiPromptDelayMs`).
- The projection machinery exists: `withoutConnectionOwnedFields`/`withConnectionOwnedFields`/`projectConnectionOwnedFields` (`providerConnections.js`), `planGraphReconciliation` + `applyReconciliation` (projected/pending snapshots, external-change refusal).
- `providerConnectionProfile` classifies every existing record; `CONNECTION_ISOLATION_REASONS` name why one cannot be mapped (unknown harness, dynamic env, external config, …).
- 55 shipped samples in `aiToolkit/defaults/providers.sample.json` (mirrored by `data.reference/providers.json`); `providerRouteRecipes.sampleParity.test.js` pins recipe↔sample agreement.
- Migration rules (root AGENTS.md): numbered script in `scripts/migrations/`, gated on the presence of the INPUT (`data/providers.json`), never on output absence; no derived `data.reference` seed; applied-list gives re-run safety.
## Proposed approach
1. **Fields (additive).** `providerSchema` gains optional `harnessId` (enum of `PROVIDER_HARNESS_IDS`), `method` (`cli|tui|api`, must equal `type`), `serviceId` (service slug), `catalogNarrowing: string[]|null` (subset of the service catalog to offer; `null` = whole catalog), `credentialBootstrapId` (slug of a configured bootstrap app, `cli`/`tui` only). A record with all three is a **derived preset**; without them a **legacy preset**.
2. **Save path.** On create/update of a derived preset, the service instance is loaded, `materializeRoute` produces the connection-owned fields, and they are written into the record (endpoint, apiKey, envVars, secretEnvVars, markers, `models` = catalog ∩ narrowing). When `credentialBootstrapId` is set, the inline `credentialBootstrap` object is written from the app row (with the harness name from its map) exactly as today; a preset carrying a hand-written inline object and no id is a legacy bootstrap and stays as is. User-editable per-preset fields stay: name, args/headlessArgs/tuiPromptDelayMs/timeout, defaultModel, tier pins, effort, fallback, generation params, consent flags, unknown custom fields. Editing a connection-owned field directly on a derived preset is refused with a pointer to the service (the editor hides them). Service edits (child 2) re-project every derived preset via the existing pending/projected snapshot flow.
3. **Migration `NNN-provider-presets-structural-fields.js`.** Input-gated on `data/providers.json`. For each record: `harnessForProvider` → `harnessId` (`direct` for `api`), `method = type`, `providerConnectionProfile` → find the service instance whose definition/transports/credentials contain the profile (`routeBelongsOnConnection`) → `serviceId`; when none exists and the profile has no isolation reasons, create the instance (child 2 API, slug from definition id) and assign. Records with isolation reasons stay legacy, untouched. An inline `credentialBootstrap` is matched against configured bootstrap apps by `(command, args, argsSeparator, setupCommand)` equality and, on a match, gets `credentialBootstrapId`; otherwise it is left inline (the migration never creates a bootstrap app). Write only the additive keys; never touch args/env/models/pins/consent/`activeProvider`/fallbacks. Private report of counts; log aggregate counts only.
4. **"Save as preset."** `POST /api/providers/presets` accepts `{compositeId, id?, name?, model?, effort?}`; the composite's `+` suffix becomes `credentialBootstrapId`; mints a record id from the composite (`pi-tui-nvidia-nim-free`, `claude-cli-anthropic-corp-auth`, uniquified) and materializes it enabled. `GET /api/providers` returns presets exactly as today plus the three new fields.
5. **Samples.** Replace the hand-maintained 55-record sample file with a generator (`scripts/generate-provider-samples.js`, content-addressed, `positionInvariance` test) that emits the same ids from `(harnessId, method, serviceDefinition)` tuples plus per-sample overrides, so a new service definition gets its presets for free; keep `data.reference/providers.json` byte-stable for this slice (generator output must equal the current file before any new tuple is added).
6. Downgrade proof: fixture test that the previous release's `providers.js` parser (`PRIOR_*` fixture pattern already used for codex/antigravity catalogs) accepts the projected file with the new keys present.
## Acceptance criteria
- [ ] Migration on the 55-sample fixture assigns `harnessId/method/serviceId` to every mappable record and leaves each isolated record legacy with its reason; a diff of the file before/after shows only added keys.
- [ ] Editing a service's endpoint re-projects every derived preset on it and the projection survives a simulated crash between DB commit and file write (existing `planGraphReconciliation` tests extended for presets).
- [ ] `POST /api/providers/presets` from `pi.tui@nvidia-nim-free` yields a record that runs identically to the composite (same argv/env in the spawn test).
- [ ] A legacy preset (custom command with `$VAR` env) is still selectable, executable and fully editable, and the editor offers "Convert to derived preset" only when `providerConnectionProfile` reports no reasons.
- [ ] Sample generator reproduces `data.reference/providers.json` byte-for-byte and is position-invariant.
- [ ] Prior-release parser fixture accepts the new file; `activeProvider` remains a record id.
## Out of scope
Selector and page UI (child 5/6); deleting or renaming any shipped sample id.
Contributor guide
Research direction
Start with root AGENTS.md for migration rules, then read aiToolkit/providers.js buildProviderRecord, internal/providerModes.js, and providerConnections.js. Run the existing providerRouteRecipes.sampleParity.test.js and planGraphReconciliation tests before tracing the preset save and migration paths. Done means the migration, projection, preset endpoint, sample generator, and prior-release parser fixture satisfy the listed acceptance criteria without changing legacy behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- api, backend, testing
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100