mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(generation): coverage rule, eligibility gate, and the mod load page
Turns the paid-model-loading decisions taken this week into working code, and fixes two things the docs had wrong. GenerationCoverageNext (migration, applied by hand) is the coverage rule the feature was designed against: CoveredCheckpoint no longer gates checkpoints, and Diffusers becomes loadable. EcosystemCheckpoints stays — 62 of the 63 checkpoint defaults are covered through it and none through CoveredCheckpoint, so dropping it would strip the default model from half the supported ecosystems. Measured on prod: covered checkpoints 638 -> 33,796, and nothing loses coverage. /api/v1/model-versions/mini/[id] now reads that view. This is what unblocks the feature at all: the orchestrator asks that endpoint whether a resource can generate, and prepareResource refuses anything it believes cannot — so on the live view paid loading could not load the very checkpoints it exists for (verified on version 3040959). No site code calls the endpoint, and the site's own generator still gates on GenerationCoverage, so this widens what the orchestrator accepts without changing what a user sees. isGenerationEligible composes the two halves of "can this generate" in one place. Coverage alone over-reports by 736 versions across 33 (baseModel, type) pairs, because the view's type branch is one flat list while the constants know support per ecosystem per type. Three call sites were pairing them by hand; the fourth was about to be written here, and would have offered a paid load for a resource search already hides. no-divergent-can-generate-derivation keeps isBaseModelGenerationSupported out of src/. Progress signals go to the buyer's own channel, not a model-version group. The orchestrator posts its WorkflowStepEvent straight to signals — we are not in the path and cannot rewrite it — and workflowId is <userId>-<timestamp>, so a group broadcast would tell everyone watching a model who paid for the load. Bystanders are told when a load is ready instead, by a self-draining localStorage queue with a 48h ceiling; real notifications are a Phase 2 goal. That closes C4. Access: members and mods only (assertCanRequestLoad), a flat 3/hour burst cap on top of the daily ladder, and all four procedures flag-gated — getState takes 100 ids and makes one uncached grain call each, so ungated it is an unauthenticated amplifier. Two figures in the docs were wrong and are corrected here: 514 is the row count of CoveredCheckpoint, not the number of covered checkpoints (638), which is why the headline numbers did not reconcile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,9 +178,9 @@ Worked examples of both fixes: the two retry tests in
|
||||
|
||||
### Convention guards
|
||||
|
||||
30 live in `src/server/services/__tests__/no-*.test.ts`:
|
||||
31 live in `src/server/services/__tests__/no-*.test.ts`:
|
||||
`no-agent-ground-truth-write`, `no-coerce-boolean-in-api`, `no-direct-shared-module-mock`,
|
||||
`no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
`no-divergent-can-generate-derivation`, `no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
ratchet — hand-typed `REDIS_KEYS` in an allowlisted mock had drifted 15 times), `no-io-in-transaction`,
|
||||
`no-job-kind-on-remix-mint`, `no-lint-rules-script-drift`,
|
||||
`no-menu-target-tooltip-nesting` (a `Tooltip` INSIDE `Menu.Target` steals the ref the menu needs and
|
||||
@@ -203,7 +203,7 @@ fail only in a full-suite run. Five were missing when this was last audited, on
|
||||
wired in then. If the diff adds a guard, check it was wired into the script, and don't treat a green
|
||||
`test:lint-rules` as "all guards passed".
|
||||
|
||||
`test:lint-rules` names 35 files today.
|
||||
`test:lint-rules` names 36 files today.
|
||||
|
||||
Both numbers and the list are checked by `no-lint-rules-script-drift`, which reads the two phrasings
|
||||
above literally — edit the numbers, not the shapes.
|
||||
|
||||
@@ -211,9 +211,10 @@ Use a top-level `import type * as PromClient` — an inline `typeof import('...'
|
||||
**Before widening a mock, check whether the import edge is needed at all.** A failing suite may be telling you the code pulled in a dependency it doesn't want, not that the mock is too narrow, and widening it would hide that. (Bit us twice in one day, Aug 2026, on two branches; one of those three suites was fixed by extracting the helpers into their own module instead.)
|
||||
|
||||
#### Convention guards run as tests
|
||||
Several repo conventions are enforced by tests, not by eslint. 30 live in
|
||||
Several repo conventions are enforced by tests, not by eslint. 31 live in
|
||||
`src/server/services/__tests__/no-*.test.ts` — `no-agent-ground-truth-write`, `no-coerce-boolean-in-api`,
|
||||
`no-direct-shared-module-mock` (the shared-mock ratchet, see `docs/testing/shared-module-mocks.md`),
|
||||
`no-divergent-can-generate-derivation` (coverage alone is not canGenerate — the ecosystem must also support the model TYPE, and the pair is composed only in `isGenerationEligible`),
|
||||
`no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
ratchet — hand-typed `REDIS_KEYS` in an allowlisted mock had drifted 15 times), `no-io-in-transaction`,
|
||||
`no-job-kind-on-remix-mint` (the remix provenance mint must sign `kind: 'mint'` — a `job`
|
||||
@@ -247,7 +248,7 @@ was last audited, on 2026-08-24, and were wired in then. **Add a new guard to th
|
||||
you write it**, and don't read a green `test:lint-rules` as "all guards passed" without checking the directory
|
||||
against the script.
|
||||
|
||||
`test:lint-rules` names 35 files today.
|
||||
`test:lint-rules` names 36 files today.
|
||||
|
||||
The count above, the count in the list, and the list itself are what went stale three times, so
|
||||
`no-lint-rules-script-drift` fails when they disagree with the directory or the script. It reads two exact
|
||||
|
||||
@@ -6,13 +6,14 @@ build them in.
|
||||
Companion to:
|
||||
|
||||
- [paid-model-loading.md](paid-model-loading.md) — the contract and the decisions
|
||||
- [paid-model-loading-coverage.md](paid-model-loading-coverage.md) — the coverage model and its audit
|
||||
- [paid-model-loading-checklist.md](paid-model-loading-checklist.md) — the state of the work and the
|
||||
verified orchestrator state
|
||||
- [paid-model-loading-decisions.md](paid-model-loading-decisions.md) — every open decision, with an
|
||||
owner and a closing condition
|
||||
|
||||
Names below were proposals when this was written. §1, §2, §6 and §3's callback builders are now
|
||||
built and the names here are the ones in the code; §3's webhook, §4 and §5 are still proposals. The
|
||||
Names below were proposals when this was written. §1, §2, §4, §6 and §3's callback builders are now
|
||||
built and the names here are the ones in the code; §3's webhook and §5 are still proposals. The
|
||||
[checklist](paid-model-loading-checklist.md) holds the per-item state.
|
||||
|
||||
---
|
||||
@@ -99,20 +100,31 @@ user still sees a price rather than a dead button.
|
||||
**The rate limit** (C10), on `submit`:
|
||||
|
||||
```ts
|
||||
rateLimit(
|
||||
[
|
||||
// The catch-all MUST be first and unconditional — see below.
|
||||
{ limit: 0, period: CacheTTL.day, errorMessage: 'Loading models is a member benefit.' },
|
||||
{ limit: 3, period: CacheTTL.day, userReq: (u) => u.tier === 'bronze' },
|
||||
{ limit: 6, period: CacheTTL.day, userReq: (u) => u.tier === 'silver' },
|
||||
{ limit: 10, period: CacheTTL.day, userReq: (u) => u.tier === 'gold' },
|
||||
{ limit: 10, period: CacheTTL.day, userReq: (u) => u.tier === 'founder' },
|
||||
],
|
||||
undefined,
|
||||
{ onlyCountSuccess: true, sharedKey: 'resource-load:submit' }
|
||||
)
|
||||
// resourceLoadRateLimits, exported from resource-load.router.ts
|
||||
[
|
||||
// The daily catch-all MUST be unconditional — see below.
|
||||
{ limit: 0, period: CacheTTL.day, errorMessage: 'Loading models is a member benefit.' },
|
||||
{ limit: 3, period: CacheTTL.day, userReq: (u) => u.tier === 'bronze' },
|
||||
{ limit: 6, period: CacheTTL.day, userReq: (u) => u.tier === 'silver' },
|
||||
{ limit: 10, period: CacheTTL.day, userReq: (u) => u.tier === 'gold' },
|
||||
{ limit: 10, period: CacheTTL.day, userReq: (u) => u.tier === 'founder' },
|
||||
// Burst protection, and deliberately NOT tier-scaled.
|
||||
{ limit: RESOURCE_LOAD_HOURLY_LIMIT, period: CacheTTL.hour,
|
||||
errorMessage: 'You can only queue a few model loads an hour. Try again shortly.' },
|
||||
]
|
||||
// ...passed with { onlyCountSuccess: true, sharedKey: 'resource-load:submit' }
|
||||
```
|
||||
|
||||
**Two windows, two different things.** Daily rows are **entitlement** — what a plan includes. The
|
||||
hourly row is the **cluster's**, so it is flat: no plan buys its way out of burst protection. The
|
||||
middleware keeps one list of attempt timestamps per key and filters it per rule, so both windows
|
||||
compose on the same `sharedKey`.
|
||||
|
||||
🔴 **The member gate is not the `limit: 0` row.** `assertCanRequestLoad(ctx.user)` runs inside both
|
||||
`estimate` and `submit`, because `rateLimit()` short-circuits entirely for moderators AND in
|
||||
dev/test/preview — so on a preview build that row would already have returned before it refused
|
||||
anyone. The gate is the entitlement; the limiter is the quota.
|
||||
|
||||
🔴 **`userTiers` is `['free', 'founder', 'bronze', 'silver', 'gold']` — the call's table omits
|
||||
`founder`, and an unmatched tier is not "the strictest limit", it is *no limit at all*.** When no row
|
||||
matches, `validLimits` is empty, the check loop never executes, `canProceed` stays true, and the user
|
||||
@@ -136,30 +148,35 @@ decorative.
|
||||
|
||||
## 3. Server — receiving progress
|
||||
|
||||
### `src/pages/api/webhooks/resource-load.ts` — new
|
||||
### `src/pages/api/webhooks/resource-load.ts` — **not built, closed 2026-09-08**
|
||||
|
||||
`WebhookEndpoint`-guarded. Receives the orchestrator's `step:*` callbacks for a load workflow and
|
||||
fans out to the signal topic.
|
||||
Both reasons for a server-side hop went away — see
|
||||
[2.4](paid-model-loading-decisions.md#24--the-c4-webhook--not-now). C9 is Phase 2, and bystanders
|
||||
need to be *told when it is ready* rather than watch it progress, which the localStorage drain does
|
||||
by asking `getState` on their next page load.
|
||||
|
||||
Verified about the payload: a preparing step publishes `WorkflowStepEvent` with `status: preparing`
|
||||
and `preparation { resource, queuePosition, progress, etaSeconds }`, refreshed every 10s and
|
||||
deduplicated at 1% progress.
|
||||
🔴 **And a group broadcast turned out to be unsafe anyway.** The orchestrator posts its
|
||||
`WorkflowStepEvent` straight through, and `workflowId` is `<userId>-<timestamp>` — broadcasting it
|
||||
to a model-version topic would disclose who paid. Progress goes to `/users/{userId}/signals/`
|
||||
instead. Stripping identity for a topic is the one thing this endpoint would still be good for, and
|
||||
it is what Phase 2 reopens it to do.
|
||||
|
||||
⚠️ **This endpoint may not be needed at all.** `WorkflowCallback.url` is an arbitrary string, and
|
||||
generation points it straight at the signals service. If we point ours at a signals *group* URL
|
||||
(`/groups/model-version:{id}/signals/{message}`), the fan-out happens without us.
|
||||
|
||||
That is the route the load submit took, so this endpoint is **not built**. Build it if and only if
|
||||
C9 (the completion notification) survives scoping — that, plus resolving AIR → version id once
|
||||
instead of per client, is all a server-side hop would add. Tracked as
|
||||
[2.4](paid-model-loading-decisions.md#24-whether-to-build-the-c4-webhook-at-all).
|
||||
Payload, for whoever builds it then: a preparing step publishes `WorkflowStepEvent` with
|
||||
`status: preparing` and `preparation { resource, queuePosition, progress, etaSeconds }`, refreshed
|
||||
every 10s and deduplicated at 1% progress.
|
||||
|
||||
### `src/server/orchestrator/orchestrator.utils.ts` — edit
|
||||
|
||||
`getResourceLoadCallbacks(modelVersionId)` builds a `step:*` callback pointed at the signals group
|
||||
URL for `model-version:<id>`, so progress fans out with no hop through us. `sendSignalToTopic(topic,
|
||||
message, data)` is the topic-broadcast helper the site lacked — that one is wrapped in
|
||||
`withSignals()`; it is unused until C9 needs a server-side send.
|
||||
`getResourceLoadCallbacks(userId)` builds a `step:*` callback pointed at
|
||||
`${SIGNALS_ENDPOINT}/users/{userId}/signals/{ResourceLoadUpdate}` — the **buyer's own channel**.
|
||||
🔴 Never a `model-version:<id>` group: the orchestrator posts the `WorkflowStepEvent` straight
|
||||
through and its `workflowId` is `<userId>-<timestamp>`, so a broadcast names the payer
|
||||
([1.4](paid-model-loading-decisions.md#14--progress-signals-go-to-the-buyer-not-to-a-topic)). Pinned
|
||||
by a test asserting the URL contains `/users/` and not `/groups/`.
|
||||
|
||||
`sendSignalToTopic(topic, message, data)` is the topic-broadcast helper the site lacked, wrapped in
|
||||
`withSignals()`. It has **no caller and no planned one** — load progress is per-user and C4 is
|
||||
closed. It is here for whatever Phase 2 needs; delete it if Phase 2 does not want it.
|
||||
|
||||
### `src/server/common/enums.ts` — edit
|
||||
|
||||
@@ -175,23 +192,38 @@ Reuse the existing `SignalTopic.ModelVersion`; no new topic constant. ⚠️ Do
|
||||
|
||||
## 4. Client — shared state
|
||||
|
||||
### `src/store/resource-load.store.ts` — new
|
||||
### `src/store/resource-load.store.ts` — **built**
|
||||
|
||||
Zustand, `localStorage`-persisted. Holds what this user is waiting on: `{ versionId, name, startedAt }[]`.
|
||||
Zustand, `localStorage`-persisted: `{ modelVersionId, modelId, name, modelName, requestedAt, kind }`,
|
||||
where `kind` is `requested` (paid) or `watching` (bystander).
|
||||
|
||||
On mount: poll `resourceLoad.getState` for everything in the store, drop what is `available`,
|
||||
resubscribe to the rest. This is the reconnect story Justin walked through, and it is the whole
|
||||
reason the navbar survives a refresh.
|
||||
A queue that drains, not a history. `resourceLoadDrainVerdict` — pure, so the rules are testable
|
||||
without a browser or a two-day wait — decides per item on each page load: `available` completes
|
||||
(toast, then remove), a missing version is dropped, `loading` or queued-with-a-position is kept, and
|
||||
anything else is dropped because nothing is in flight for it.
|
||||
|
||||
### `src/components/ResourceLoad/resource-load.utils.ts` — new
|
||||
🔴 **Every item must be able to leave.** A failed load and one that finished and was evicted both
|
||||
read back as `unavailable`, indistinguishable from "queued", so without a deadline they are
|
||||
re-subscribed forever. Items expire at 48h, matching the residency policy — but a load that
|
||||
completes *after* the ceiling still reports, because expiry must not swallow good news.
|
||||
|
||||
- `useResourceLoadState(versionId)` — query + topic subscription, the hook every surface uses
|
||||
- `useResourceLoadPurchase()` — mutation + confirmation modal + error mapping
|
||||
- `useTrackedResourceLoads()` — the store, for the navbar
|
||||
This is not the record of a purchase. That is the orchestrator's — see
|
||||
[1.6](paid-model-loading-decisions.md#16--there-is-a-durable-record-of-a-purchased-load-and-it-is-not-ours).
|
||||
|
||||
The topic subscription is one line, because
|
||||
### `src/components/ResourceLoad/resource-load.utils.ts` — **built**
|
||||
|
||||
- `toResourceLoadProgress(raw)` — narrows an untrusted signal payload
|
||||
- `useResourceLoadProgress()` — subscribes to `ResourceLoadUpdate` on the user's own channel
|
||||
- `useDrainTrackedResourceLoads()` — runs `resourceLoadDrainVerdict` over the store on each page load
|
||||
- `ResourceLoadDrain` — the mount point for that drain, rendered in `AppHeader`
|
||||
|
||||
⚠️ Progress is **not** a topic subscription.
|
||||
[model-version.utils.ts:152](../../src/components/Model/ModelVersions/model-version.utils.ts#L152)
|
||||
already subscribes model pages to `model-version:<id>`.
|
||||
subscribes model pages to `model-version:<id>`, which stays the convention for model-version signals
|
||||
generally — it is just not how load progress arrives
|
||||
([1.4](paid-model-loading-decisions.md#14--progress-signals-go-to-the-buyer-not-to-a-topic)).
|
||||
|
||||
Still to build for C5–C7: the state hook and the purchase mutation/modal.
|
||||
|
||||
---
|
||||
|
||||
@@ -227,14 +259,19 @@ else paid for.
|
||||
- resident → unchanged
|
||||
- unsupported → not selectable
|
||||
|
||||
⚠️ **Depends on the "select any model" decision.** Today selection is gated on
|
||||
`GenerationCoverage`, and this task assumes that gate has moved. A LoRA-first v1 needs no view
|
||||
change; checkpoints do. That decision is Phase 0 in the checklist and it is not made yet.
|
||||
**The "select any model" decision is made (2026-09-08): checkpoints only.** Selection is gated on
|
||||
`GenerationCoverage`, and the gate moves in Phase 1.6 — a new view that drops `CoveredCheckpoint`,
|
||||
keeps `EcosystemCheckpoints`, and allows Diffusers. See
|
||||
[coverage](paid-model-loading-coverage.md). This task depends on that view existing, not on a
|
||||
decision.
|
||||
|
||||
⚠️ Offer the load only where a load is possible: the resource needs a **loadable file** and a base
|
||||
model in `GenerationBaseModel`. File-less API models are covered but must never show a CTA.
|
||||
|
||||
### C7 — navbar
|
||||
|
||||
**`src/components/ResourceLoad/ResourceLoadTracker.tsx`** — new. Mounted in
|
||||
[AppHeader.tsx](../../src/components/AppLayout/AppHeader/AppHeader.tsx#L104) next to `UploadTracker`,
|
||||
[AppHeader.tsx](../../src/components/AppLayout/AppHeader/AppHeader.tsx#L105) next to `UploadTracker`,
|
||||
inside the same `currentUser &&` block.
|
||||
|
||||
Copy `UploadTracker`'s shape exactly — `Indicator` with a count, `Popover`, a stacked list with
|
||||
@@ -281,8 +318,12 @@ pins the default toggle state — read it before choosing `toggleable`.
|
||||
## 6. Feature flag
|
||||
|
||||
`src/server/services/feature-flags.service.ts` — `resourceLoad: ['mod', 'granted']`, the cheap
|
||||
version of C14's "ship it mod-only initially". It gates `estimate`, `submit` and the mod page;
|
||||
`getState` and `getQueue` are deliberately **not** gated, because load state is a public read.
|
||||
version of C14's "ship it mod-only initially". It gates **all four procedures** and the mod page.
|
||||
|
||||
`getState` and `getQueue` are `publicProcedure` because load state is a decided public read, but they
|
||||
stay flag-gated until C5 puts it on the model page: `getState` takes up to 100 version ids and makes
|
||||
one uncached orchestrator grain call per id, so ungated it is an unauthenticated amplifier — one
|
||||
request, a hundred grain calls, repeatable by anyone. Give it a cache or a cap when you relax it.
|
||||
|
||||
⚠️ A mod-only launch **exercises none of the rate limiting** — `rateLimit()` short-circuits for
|
||||
moderators. Do not read a quiet mod rollout as evidence the caps work.
|
||||
@@ -302,13 +343,14 @@ non-purchase states, `ResourceLoadTracker`, the queue page. All of this shows re
|
||||
triggered by anything, including a generation. **No purchase path yet, so nothing depends on
|
||||
pricing** — which is the piece blocked on Koen.
|
||||
|
||||
**Phase C — the purchase. Server half built:** `estimate` + `submit`, the rate limit,
|
||||
`assertWorkflowOwner`. Outstanding: the CTA, the `RentCivit` licence refusal
|
||||
([1.1](paid-model-loading-decisions.md#11--models-without-a-rentcivit-licence)), and 🔴 C2 —
|
||||
**Phase C — the purchase. Server half built:** `estimate` + `submit`, the member gate, the daily and
|
||||
hourly rate limits, `assertWorkflowOwner`, and the coverage refusal that carries the `RentCivit` rule
|
||||
([1.1](paid-model-loading-decisions.md#11--models-without-a-rentcivit-licence--refuse)).
|
||||
Outstanding: the CTA, surfacing the orchestrator's own `CanGenerate` rejection cleanly, and 🔴 C2 —
|
||||
`CalculateCost` returns a hardcoded zero, so `whatIf` still has no number to show.
|
||||
|
||||
**Phase D — the rest.** C9 notification, then C11 auctions retirement (blocked on 868gtq1kt, and on
|
||||
`CoveredCheckpoint` ownership).
|
||||
**Phase D — the rest.** C9 notification, then C11 auctions retirement (blocked on 868gtq1kt).
|
||||
`CoveredCheckpoint` ownership is no longer part of it — Phase 1.6 removes the table from coverage.
|
||||
|
||||
The useful consequence: **Phases A and B are real, shippable work that needs nothing from Koen.**
|
||||
They also produce the demo C14 is asking for — a working view of the experience, driven by live
|
||||
|
||||
@@ -11,41 +11,25 @@ while reading the contract and the code; they have no ClickUp task and no owner
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — decisions, and what each one still gates
|
||||
## Phase 0 — decisions, all now made
|
||||
|
||||
Four decisions; one is now settled. The orchestrator questions behind them are answered and
|
||||
recorded below.
|
||||
**Closed 2026-09-08.** Every Phase 0 decision has an answer; none of them gates work any more. They
|
||||
are kept, with the answers and who gave them, in
|
||||
[paid-model-loading-decisions.md](paid-model-loading-decisions.md) §1–§2. The coverage model they
|
||||
produced, and the production audit behind it, is
|
||||
[paid-model-loading-coverage.md](paid-model-loading-coverage.md).
|
||||
|
||||
Phase 1 turned out **not** to be gated on these — the plumbing is built and none of it depends on
|
||||
an answer. What is still gated is the purchase path (the licence gate) and C6 (the "select any
|
||||
model" question). Every open item here is restated with an owner and a closing condition in
|
||||
[paid-model-loading-decisions.md](paid-model-loading-decisions.md).
|
||||
|
||||
- [ ] **C14 — decide: standalone demo client, mod-only launch, or straight into the platform.**
|
||||
Justin owns it. Gates C5–C8. ([868ktt5bz](https://app.clickup.com/t/868ktt5bz))
|
||||
*Closes when:* Justin states the choice in the task.
|
||||
- [ ] 🔴 **What we can honestly sell.** Reopened 2026-09-07: this was settled as "promise nothing"
|
||||
on the premise that residency did not exist, and that premise was false —
|
||||
[it does](#residency--in-a-repo-this-list-does-not-read), enforced by the spine controllers.
|
||||
What is open now is narrower and better: the policy declines to evict inside 48h *unless
|
||||
another controller has a copy*, so "we keep it for 48 hours" and "it stays reachable for 48
|
||||
hours" are not the same promise. Decision
|
||||
[1.3](paid-model-loading-decisions.md#13--what-the-surfaces-may-promise-now-that-residency-exists).
|
||||
*Closes when:* the CTA copy is written and someone named signs it off.
|
||||
- [ ] **Decide "select any model", and decide it as two questions.** `GenerationCoverage` is a
|
||||
**view**, not a flag. LoRAs/TI/VAE/LoCon/DoRA are already covered once licensed and scanned —
|
||||
they are merely not resident, which is the thing paid loading fixes, and need **no view
|
||||
change**. Checkpoints additionally require membership in `CoveredCheckpoint`, which the
|
||||
weekly auction job owns and prunes. A **LoRA-first v1 avoids both the view change and the
|
||||
auction entanglement** and is the obvious smallest slice.
|
||||
*Closes when:* a decision is written into paid-model-loading.md and a task exists if a
|
||||
checkpoint path is in scope.
|
||||
- [ ] 🔴 **Decide what happens to models without a `RentCivit` licence.** The coverage view
|
||||
excludes them on purpose; charging to load one sells what the licence forbids. Refuse them at
|
||||
the CTA, or get a product decision. No task, no owner, and the failure mode is a refund plus
|
||||
a creator complaint.
|
||||
*Closes when:* the purchase path either refuses them or a named person signs off that it
|
||||
should not.
|
||||
- [x] **C14 — demo client, mod-only, or platform.** Start with the mod test page, which already
|
||||
exists (Phase 1.5). ([868ktt5bz](https://app.clickup.com/t/868ktt5bz))
|
||||
- [x] **What we can honestly sell.** The 48 hours as originally pitched. A copy may move between
|
||||
spine controllers inside the window; availability does not lapse.
|
||||
- [x] **"Select any model".** Checkpoints only — size is why the loader exists and LoRAs do not have
|
||||
it. `CoveredCheckpoint` stops gating generation; `EcosystemCheckpoints` and
|
||||
`GenerationBaseModel` stay. **The earlier LoRA-first recommendation in these docs was wrong**
|
||||
and has been removed.
|
||||
- [x] 🔴 **Models without a `RentCivit` licence.** Refuse. Implemented as "refuse anything not in
|
||||
`GenerationCoverage`" — the same set today (**zero covered versions lack the licence**), and it
|
||||
inherits the rule instead of restating it.
|
||||
|
||||
Not blocking site work, but blocking **launch**:
|
||||
|
||||
@@ -66,33 +50,36 @@ Already done, verified:
|
||||
No user-visible surface. Everything in Phase 2 and 3 sits on this, and it is testable on its own
|
||||
against a real download.
|
||||
|
||||
- [ ] **C4 — the endpoint the orchestrator hits when a download starts/progresses.**
|
||||
([868ktt58f](https://app.clickup.com/t/868ktt58f)) Not built, and it may not need to be: the
|
||||
load submit points its callback straight at the signals **group** URL for
|
||||
`model-version:<id>`, so progress fans out with no hop through us. Build the endpoint only if
|
||||
C9 (the completion notification) survives scoping — that is the one thing the direct route
|
||||
cannot do.
|
||||
- [x] **C4 — the endpoint the orchestrator hits when a download starts/progresses.** **Closed as
|
||||
"not now"** — see [2.4](paid-model-loading-decisions.md#24--the-c4-webhook--not-now). Both its
|
||||
reasons went away: C9 is Phase 2, and bystanders need notification rather than live progress.
|
||||
Reopens with Phase 2, which needs a server-side moment to send from.
|
||||
([868ktt58f](https://app.clickup.com/t/868ktt58f))
|
||||
- [x] **Topic broadcast helper.** `sendSignalToTopic(topic, message, data)` in
|
||||
`src/server/orchestrator/orchestrator.utils.ts`, wrapped in `withSignals()`. Unused so far —
|
||||
the callback URL covers progress; this is for the server-side sends C9 will need.
|
||||
- [x] **New `SignalMessages` entry** — `ResourceLoadUpdate = 'resource-load:update'`, on the
|
||||
existing `SignalTopic.ModelVersion`. No collision with `SchedulerDownload`.
|
||||
`src/server/orchestrator/orchestrator.utils.ts`, wrapped in `withSignals()`. ⚠️ **Still unused,
|
||||
and no longer has a planned caller** — load progress is per-user now, and C4 is closed. It is
|
||||
here for whatever Phase 2 needs; delete it if Phase 2 does not want it.
|
||||
- [x] **New `SignalMessages` entry** — `ResourceLoadUpdate = 'resource-load:update'`, delivered on
|
||||
the **user's own channel**, not a topic. No collision with `SchedulerDownload`.
|
||||
- [x] **Extract versionId → AIR.** `modelVersionToAir` in `src/server/utils/resource-air.ts`;
|
||||
`bustOrchestratorModelCache` and `modelVersionResourceCache` both repointed at it.
|
||||
`fileType` comes from the primary file when the caller loaded files, and the two existing
|
||||
callers keep the AIRs they had.
|
||||
- [x] **Server-side resource-state read.** `getResourceLoadState(versionIds)` and
|
||||
`getResourceLoadQueue({cursor, take})` in `src/server/services/resource-load.service.ts`,
|
||||
exposed as `resourceLoad.getState` / `resourceLoad.getQueue` (both public). The queue read
|
||||
exposed as `resourceLoad.getState` / `resourceLoad.getQueue` (both `publicProcedure`,
|
||||
flag-gated until C5 — see the amplification note in the router). The queue read
|
||||
goes through a new `queryResourcesClient` wrapper beside `getModelClient`, so the SDK stays
|
||||
in `services/orchestrator/models.ts`.
|
||||
- [x] fresh, uncached — `modelVersionResourceCache` is not reused
|
||||
- [x] a status this build does not know is reported as `unknown`, not folded into one of the four
|
||||
- [x] **Service tests** — `src/server/services/__tests__/resource-load.service.test.ts`: AIR
|
||||
construction, `queuePosition` on `unavailable`, the `unknown` fallback, unresolvable queue
|
||||
rows, all three pre-submit refusals, owner-check propagation, priced vs unpriced.
|
||||
- [ ] **The purchase path.** `resourceLoad.estimate` (whatIf) and `resourceLoad.submit` exist and
|
||||
work; what is missing is a price to show and a licence gate.
|
||||
rows, four pre-submit refusals (not generatable, no weight file, unscanned file, no such
|
||||
version), the progress URL going to `/users/` and never `/groups/`, owner-check propagation,
|
||||
priced vs unpriced. Not yet pinned: the `unsupported` and already-`available` refusals.
|
||||
- [ ] **The purchase path.** `resourceLoad.estimate` (whatIf) and `resourceLoad.submit` exist, are
|
||||
gated, and work; what is missing is a price to show.
|
||||
- [x] 🔴 `assertWorkflowOwner` on the submit result
|
||||
- [x] refuse when `status === 'unsupported'`, and when we could not read the status at all
|
||||
- [x] refuse (without charging) when already `available`
|
||||
@@ -101,8 +88,9 @@ against a real download.
|
||||
can render "free" as a quote. Still blocked on C2 for a real number.
|
||||
- [ ] surface the orchestrator's own `CanGenerate` rejection cleanly — `PrepareResourceInput`
|
||||
throws a ValidationException before any charge
|
||||
- [ ] 🔴 refuse when the model lacks a `RentCivit` licence (see Phase 0). **Not implemented** —
|
||||
the submit path will currently take a load for a model whose creator did not grant it.
|
||||
- [x] 🔴 refuse when the model lacks a `RentCivit` licence — implemented as `resolveLoadable`'s
|
||||
`!eligible` refusal: coverage (`GenerationCoverageNext`) composed with ecosystem type support
|
||||
by `isGenerationEligible`, on both `estimate` and `submit`.
|
||||
- [ ] **C10 — per-tier daily rate limits.** ([868ktt5aq](https://app.clickup.com/t/868ktt5aq))
|
||||
- [x] 🔴 the free row is an **unconditional catch-all** and `founder` has its own row
|
||||
- [x] `onlyCountSuccess: true`, so a refused purchase does not burn a slot
|
||||
@@ -122,11 +110,82 @@ against a real download.
|
||||
Not in the build plan; asked for while building Phase A so the plumbing could be driven end to end
|
||||
before any of Phase 2 exists.
|
||||
|
||||
- [x] **`/moderator/resource-load`** — enter a model version id, get the estimate, then submit;
|
||||
below it, the live queue polled every 15s. `requireModerator` plus the `resourceLoad`
|
||||
feature flag.
|
||||
- [x] **`/moderator/resource-load`** — `requireModerator` plus the `resourceLoad` flag.
|
||||
- [x] enter a model version id and see it resolved **before** committing: name, AIR, size,
|
||||
availability, and whether it is generatable / has weights
|
||||
- [x] the estimate button is disabled with the reason shown, rather than failing on submit
|
||||
- [x] the estimate names itself as unpriced while the orchestrator quotes zero
|
||||
- [x] a "Waiting on" list from the persisted store, with live progress and "Stop watching"
|
||||
- [x] the cluster queue, polled every 15s, preferring live signal progress where there is any
|
||||
- [x] live progress over the buyer's own signals channel
|
||||
- ⚠️ moderators are exempt from `rateLimit()`, so this page exercises none of C10
|
||||
- [x] **Tracking and notification** — `src/store/resource-load.store.ts` (persisted, self-draining,
|
||||
48h ceiling) and `ResourceLoadDrain` mounted in `AppHeader` for any signed-in user with the
|
||||
`resourceLoad` flag, so a finished load is reported wherever they land next. Toasts require
|
||||
dismissal. See
|
||||
[1.5](paid-model-loading-decisions.md#15--notification-is-a-toast-on-return-real-notifications-are-phase-2).
|
||||
- [ ] **Nothing outside this page can start watching yet.** The store supports `kind: 'watching'`
|
||||
and the drain reports it, but the button that creates one is C5 on the model version page. So
|
||||
bystander notification is built and unreachable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.6 — the coverage change
|
||||
|
||||
Created by the Phase 0 answers on 2026-09-08. Nothing here is written yet. The rule, the audit and
|
||||
every measured number are in [paid-model-loading-coverage.md](paid-model-loading-coverage.md).
|
||||
|
||||
- [x] **A new coverage view alongside `GenerationCoverage`** —
|
||||
`packages/civitai-db-schema/prisma/migrations/20260908120000_generation_coverage_next/migration.sql`,
|
||||
creating `GenerationCoverageNext`. **Applied to production 2026-09-08.** 🔴 Not named
|
||||
`GenerationCoverage2`: that view already exists in production as a stale earlier experiment, and
|
||||
`CREATE OR REPLACE` on the name would have silently overwritten it. Deliberately not added to
|
||||
`schema.full.prisma` — the cutover replaces `GenerationCoverage`'s own body with this one and
|
||||
drops this view, so the Prisma model never changes. Three branches: no-loadable-file (covered, never loaded), in
|
||||
`EcosystemCheckpoints` with a loadable file, and checkpoint on a `GenerationBaseModel` base
|
||||
model with a loadable file. The LORA/TI/VAE/LoCon/DoRA/Upscaler branch is unchanged.
|
||||
- [x] drop the `CoveredCheckpoint` conjunct, and allow `Diffusers` while keeping Core ML and ONNX
|
||||
excluded — [the numbers](paid-model-loading-coverage.md#what-changes-in-numbers)
|
||||
- [ ] 🔴 keep `EcosystemCheckpoints` — 62 of 63 checkpoint defaults depend on it
|
||||
- [x] diffed against production 2026-09-08 — nothing loses coverage; [the numbers](paid-model-loading-coverage.md#what-changes-in-numbers)
|
||||
- [ ] **Set `usageControl = 'ExternalGeneration'` on the 36 mislabelled API versions.** All
|
||||
published, none POI, coverage preserved 36/36. Mod-only to set via the app, so it is a direct
|
||||
DB write.
|
||||
- [x] **One derivation of `canGenerate`.** `isGenerationEligible` in
|
||||
`packages/civitai-shared/src/generation-eligibility.ts`, with all four call sites repointed
|
||||
and `no-divergent-can-generate-derivation` keeping `isBaseModelGenerationSupported` out of
|
||||
`src/`. Coverage alone over-reports by **736 versions**; see
|
||||
[coverage](paid-model-loading-coverage.md#covered-is-not-cangenerate).
|
||||
- [x] **Gate the load CTA on `isGenerationEligible` AND "has a loadable file"** — not on `covered`,
|
||||
not on `usageControl`, and not on "has any file". Done on the mod page and enforced
|
||||
server-side in `resolveLoadable`; re-check when C5/C6 add public CTAs.
|
||||
- [x] **Refuse anything not in coverage** on `estimate` and `submit` (this is the `RentCivit` gate) —
|
||||
`resolveLoadable`, reading `GenerationCoverageNext`.
|
||||
- [x] **Audit every existing reader of `covered`.** 23 files, classified in
|
||||
[coverage](paid-model-loading-coverage.md#the-covered-readers-audit). Findings below are what
|
||||
it produced.
|
||||
- [ ] 🔴 **The shared rate-limit key on the generation submit path, and C2 pricing, must land BEFORE
|
||||
the SITE'S GENERATION GATE reads the new view** — `generation.service`, `resource-data.redis`,
|
||||
the search index. Widening those makes tens of thousands more versions generatable, and a
|
||||
generation submitted against a non-resident one triggers an implicit prepare — free today, and
|
||||
uncapped, because C10 only guards `resourceLoad.submit`.
|
||||
Two callers already read `GenerationCoverageNext` and are deliberately outside that rule:
|
||||
`resource-load.service` (the purchase path — flag-gated, and the widened set is the point) and
|
||||
`/api/v1/model-versions/mini/[id]` (read by the orchestrator for `CanGenerate`; no site code
|
||||
calls it, so it widens what the orchestrator accepts without changing what a user sees).
|
||||
- [ ] **Decide what search shows.** The index derives `canGenerate` from `covered`, so the swap
|
||||
advertises tens of thousands more models as generatable with no way to say "needs loading
|
||||
first". Load
|
||||
state in search was deferred; this is the surface that deferral now collides with.
|
||||
- [x] **Check the public API field.** `/api/v1/model-versions/mini/[id]` now selects `covered` from
|
||||
`GenerationCoverageNext` — done 2026-09-08, because the orchestrator reads it for `CanGenerate`
|
||||
and on the live view refused every load worth making (verified on version 3040959).
|
||||
⚠️ The field's meaning changed for third-party consumers, unflagged and unannounced. Decide
|
||||
whether that needs an announcement.
|
||||
- [ ] **Look at the pool consumers** — daily-challenge model selection, App Blocks workflow service,
|
||||
the model-list filters in `model.service.ts` and `caches.ts`.
|
||||
- [ ] **Delete `getCheckpointGenerationCoverage`** with `CoveredCheckpoint` — zero callers.
|
||||
- [ ] **Decide what happens to `handle-auctions.ts`** once nothing reads the rows it writes.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,7 +204,9 @@ the platform second.
|
||||
- [ ] not loaded → offer the paid load at the size-based price
|
||||
- [ ] downloading → auto-subscribe and show progress inline for the selected resource
|
||||
- [ ] loaded → unchanged
|
||||
- [ ] depends on the "select any model" decision from Phase 0
|
||||
- [x] the "select any model" decision is made — checkpoints only, coverage rule in
|
||||
[coverage](paid-model-loading-coverage.md). C6 now depends on **Phase 1.6** landing, not on a
|
||||
decision.
|
||||
- [ ] **C7 — navbar indicator.** ([868ktt59j](https://app.clickup.com/t/868ktt59j))
|
||||
- [ ] mirror [`UploadTracker`](../../src/components/Resource/UploadTracker.tsx) — same
|
||||
`Indicator` + `Popover` shape, mounted next to it in `AppHeader`
|
||||
@@ -178,9 +239,9 @@ the platform second.
|
||||
- [ ] **C11 — retire auctions.** ([868ktt5b2](https://app.clickup.com/t/868ktt5b2)) Do not scope
|
||||
until 868gtq1kt (splitting featuring out of auctions) has an answer — auctions do two jobs
|
||||
and paid loading replaces one. ~89 files under `src/`.
|
||||
- [ ] 🔴 whoever ends up owning `CoveredCheckpoint` must be settled **before** a checkpoint ships
|
||||
as paid-loadable: `handle-auctions.ts` deletes every row outside the weekly winner set, so
|
||||
it would silently un-cover anything someone paid for.
|
||||
- [x] the `CoveredCheckpoint` conflict is resolved by removing it from coverage (Phase 1.6), so
|
||||
the auction job can no longer un-cover a paid checkpoint. What remains is deciding whether
|
||||
that job should keep writing rows nothing reads.
|
||||
|
||||
---
|
||||
|
||||
@@ -268,11 +329,9 @@ This records Koen's answer (DM, 2026-09-04) rather than source we read.
|
||||
|
||||
### Still worth asking Koen
|
||||
|
||||
**Both answered, 2026-09-04 and 2026-09-07.** Residency exists and is enforced by the spine
|
||||
controllers ([above](#residency--in-a-repo-this-list-does-not-read)); `step:preparing` was missing
|
||||
from the spec by accident — a 2024 change with no comment — and Koen has since added the missing
|
||||
event types back. `step:*` stays the correct subscription either way. C2 (pricing) is the only
|
||||
thing still with him.
|
||||
**Nothing.** Both were answered 2026-09-04 and 2026-09-07 — see
|
||||
[Residency](#residency--in-a-repo-this-list-does-not-read) above. C2 pricing is the only thing still
|
||||
with him.
|
||||
|
||||
## Not in v1, on the record
|
||||
|
||||
@@ -281,7 +340,7 @@ thing still with him.
|
||||
- Any hard guarantee on when a model becomes available. Bandwidth into the data centre was ~10
|
||||
KB/s at the time of the call; LoRAs took four hours. Promise nothing about *arrival* — a separate
|
||||
question from how long it stays once it arrives, which is
|
||||
[1.3](paid-model-loading-decisions.md#13--what-the-surfaces-may-promise-now-that-residency-exists).
|
||||
[1.3](paid-model-loading-decisions.md#13--what-we-promise--the-original-48-hours).
|
||||
|
||||
---
|
||||
|
||||
@@ -289,7 +348,9 @@ thing still with him.
|
||||
|
||||
Real work with no task and no owner. Listed so they are decided rather than discovered.
|
||||
|
||||
- [ ] **Refund path** for a load that fails or never completes. Open decision —
|
||||
[1.2](paid-model-loading-decisions.md#12-what-happens-when-a-load-fails-or-never-finishes).
|
||||
- [ ] **Refund path** for a load that fails or never completes. Decided: refund
|
||||
([1.2](paid-model-loading-decisions.md#12--a-load-that-never-finishes--refund)). Open is
|
||||
*whose* — [K3](paid-model-loading-decisions.md#k3-does-the-orchestrator-refund-a-failed-prepare)
|
||||
asks Koen whether the orchestrator already does it.
|
||||
- [ ] **Residency display.** Residency is real, but no API reports when a resource's 48h window
|
||||
ends, so there is nothing to count down from. Needs an orchestrator ask before it is work.
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# Paid Model Loading — coverage, and what has to change
|
||||
|
||||
What decides whether a model can be generated with, what decides whether it can be *loaded*, and the
|
||||
audit of the gap between them. Every number here was measured against production on 2026-09-08 and
|
||||
the query that produced it is described, so it can be re-run rather than trusted.
|
||||
|
||||
Companion to [paid-model-loading.md](paid-model-loading.md) (the contract),
|
||||
[paid-model-loading-build-plan.md](paid-model-loading-build-plan.md) (the inventory),
|
||||
[paid-model-loading-checklist.md](paid-model-loading-checklist.md) (the state) and
|
||||
[paid-model-loading-decisions.md](paid-model-loading-decisions.md) (open decisions).
|
||||
|
||||
---
|
||||
|
||||
## The model, decided 2026-09-08
|
||||
|
||||
Justin's framing, which the audit below tests and supports:
|
||||
|
||||
- **`EcosystemCheckpoints`** is the list of models the orchestrator should support **by default** —
|
||||
the generator's default model for each ecosystem.
|
||||
- **`GenerationBaseModel`** is the list of base models where the orchestrator has extended
|
||||
checkpoint/diffuser support, i.e. where **community** models can be run.
|
||||
- **Both populations go through the model-loading system.**
|
||||
- **File-less models — external API coverage — never touch the loader.** There is nothing to
|
||||
download.
|
||||
- **Every other checkpoint requires a correct model file to use the loader.**
|
||||
|
||||
🔴 **"File-less" must mean "has no *loadable* file", not "has no file row."** 36 of the 125
|
||||
`EcosystemCheckpoints` are external API models carrying a single `Training Data` archive. Keyed on
|
||||
"has any file", every one of them reads as loadable, and the site would sell a load for a model with
|
||||
no weights — a guaranteed failure, and a refund once pricing exists. See
|
||||
[the 36](#the-36-mislabelled-api-models).
|
||||
|
||||
### Coverage becomes three branches
|
||||
|
||||
1. **No loadable file** → covered, never offered a load. External/API generation.
|
||||
2. **In `EcosystemCheckpoints` with a loadable file** → covered; the orchestrator should carry it by
|
||||
default.
|
||||
3. **Checkpoint on a `GenerationBaseModel` base model**, licensed, scanned, `baseModelType =
|
||||
'Standard'`, with a loadable file → covered, loadable on demand. This is the population
|
||||
`CoveredCheckpoint` gates today.
|
||||
|
||||
The existing LORA / TextualInversion / VAE / LoCon / DoRA / Upscaler branch is unchanged.
|
||||
|
||||
The load CTA fires only for branches 2 and 3, and only when the resource is not resident. Never for
|
||||
branch 1.
|
||||
|
||||
---
|
||||
|
||||
## The two tables do opposite jobs
|
||||
|
||||
This is the thing an earlier reading of these docs got wrong, and it inverted a plan.
|
||||
|
||||
| Table | Rows | What it is |
|
||||
| --- | --- | --- |
|
||||
| `CoveredCheckpoint` | 514 rows | Auction-won community checkpoints — a **residency proxy**. Written and pruned weekly by `handle-auctions.ts`. **This is what paid loading replaces.** (638 versions are covered *as checkpoints* — the rest come from `EcosystemCheckpoints`.) |
|
||||
| `EcosystemCheckpoints` | 125 | The generator's **default model per ecosystem**. **62 of the 63 checkpoint defaults are covered through it — and zero through `CoveredCheckpoint`.** Not a loophole; the registry that keeps the generator working. |
|
||||
|
||||
Dropping `CoveredCheckpoint` is the feature. Dropping `EcosystemCheckpoints` would remove the
|
||||
default model from half the ecosystems the generator supports — see
|
||||
[the defaults audit](#the-defaults-audit).
|
||||
|
||||
`CoveredCheckpoint` has exactly four uses, all generation:
|
||||
|
||||
- the `GenerationCoverage` view
|
||||
- `handle-auctions.ts` — inserts winners, deletes everything outside the weekly set
|
||||
- `toggleCheckpointCoverage` — a moderator tRPC tool
|
||||
- `getCheckpointGenerationCoverage` — **zero callers; dead code**
|
||||
|
||||
So it can go, and nothing outside generation notices.
|
||||
|
||||
---
|
||||
|
||||
## What changes, in numbers
|
||||
|
||||
Both views counted in the same query, 2026-09-08, so these reconcile:
|
||||
|
||||
| | `GenerationCoverage` | `GenerationCoverageNext` |
|
||||
| --- | --- | --- |
|
||||
| Covered **checkpoints** | 638 | **33,796** |
|
||||
| Covered rows, all types | 899,553 | 933,386 |
|
||||
|
||||
**+33,158 checkpoints, +33,833 rows.** The two deltas differ because dropping `Diffusers` from the
|
||||
excluded formats helps every type, not only checkpoints — about 675 of the added rows are LoRAs and
|
||||
friends.
|
||||
|
||||
⚠️ **514 is not the number of covered checkpoints.** It is the row count of `CoveredCheckpoint`, the
|
||||
auction's list; 638 versions are covered as checkpoints today because `EcosystemCheckpoints` also
|
||||
contributes. Earlier drafts of these docs used 514 for both, which is what made the headline figures
|
||||
fail to add up.
|
||||
|
||||
155 published, licensed, standard checkpoints on supported base models were blocked **only** by file
|
||||
format: 132 Diffusers, 21 Core ML, 2 ONNX. Diffusers is loadable (Justin, 2026-09-08); Core ML and
|
||||
ONNX are inference-runtime formats rather than servable weights and stay excluded.
|
||||
|
||||
### The loader population, split by bucket
|
||||
|
||||
| | `EcosystemCheckpoints` | Community checkpoints |
|
||||
| --- | --- | --- |
|
||||
| Total | 125 | 33,792 |
|
||||
| **A** — no loadable file → never loaded | 51 (15 with no file at all + 36 training-data-only) | 123 |
|
||||
| **B** — has a loadable file → goes through the loader | **74** | **33,669** |
|
||||
|
||||
≈ **33,743 loadable checkpoints**, against 514 covered by auction today.
|
||||
|
||||
---
|
||||
|
||||
## The licence gate
|
||||
|
||||
**Zero covered versions lack `RentCivit`.** Verified two ways: branch by branch, and end-to-end
|
||||
against the view (`899,068` covered rows, `0` without the licence).
|
||||
|
||||
The view has two branches that do not check the licence — `EcosystemCheckpoints` membership and
|
||||
`usageControl = 'ExternalGeneration'` — but nothing is currently using them to escape it. All 143
|
||||
versions covered via those branches hold `RentCivit` anyway.
|
||||
|
||||
**Consequence:** "refuse when `RentCivit` is false" and "refuse when not in `GenerationCoverage`"
|
||||
select the same set today. Gate on **coverage**, because it stays correct if a version later does
|
||||
use a bypass, and because it inherits the licence rule instead of keeping a second opinion of it.
|
||||
|
||||
---
|
||||
|
||||
## The defaults audit
|
||||
|
||||
`basemodel.constants.ts` declares generation support; `ecosystemSettings[].defaults.model.id` names
|
||||
the default model per ecosystem. Run against the real exports (via `tsx`), not by grepping:
|
||||
|
||||
- **105** base model records; **90** ecosystems; **60** base models with generation support
|
||||
- **64** ecosystem default model versions — **all 64 covered today**
|
||||
- **63** of them are checkpoints; **62** are covered via `EcosystemCheckpoints`, **0** via
|
||||
`CoveredCheckpoint`
|
||||
|
||||
### What a naive unification would have cost
|
||||
|
||||
Evaluated against the main path with `CoveredCheckpoint` dropped, Diffusers allowed and **no**
|
||||
`EcosystemCheckpoints` branch: **33 of the 64 defaults lose coverage.** Nine have no file at all
|
||||
(Flux3Video, HappyHorse, MAI, MuseImage, Qwen3, Reve, Seedream, Veo3, WanVideo30 — all
|
||||
`ExternalGeneration`); the rest fail on base model and/or file type.
|
||||
|
||||
That is why branch 1 and branch 2 both have to exist.
|
||||
|
||||
### `basemodel.constants.ts` ↔ `GenerationBaseModel` disagree
|
||||
|
||||
**17 base models declare generation support in the constants but have no `GenerationBaseModel` row:**
|
||||
|
||||
> ACE Audio, Flux.1 Kontext, Grok, HappyHorse, HiDream-O1, Lens, MAI, MageFlow, MiniMax Music 3,
|
||||
> Qwen 2, Reve, Upscaler, Wan Image 2.7, Wan Video 2.5 I2V, Wan Video 2.5 T2V, Wan Video 2.7,
|
||||
> Wan Video 3.0
|
||||
|
||||
**5 go the other way** — in the table, not declared in the constants: Nano Banana, SDXL 0.9,
|
||||
SDXL 1.0 LCM, Wan Video, Wan Video 1.3B t2v.
|
||||
|
||||
Those 17 line up with the failing defaults above: **those ecosystems work today only because
|
||||
`EcosystemCheckpoints` covers their default model.** The base-model allowlist never learned about
|
||||
them, and nothing surfaced the inconsistency because the other table was quietly compensating.
|
||||
|
||||
This is a real inconsistency independent of paid loading. It has no owner —
|
||||
[decisions 2.6](paid-model-loading-decisions.md#26-the-17-base-model-gap-between-the-constants-and-generationbasemodel).
|
||||
|
||||
---
|
||||
|
||||
## What stays out of scope
|
||||
|
||||
**1,676 published checkpoints across 43 base models have no `GenerationBaseModel` row**, so they are
|
||||
not loadable under the rule above. Decided (Justin, 2026-09-08): loading supports only base models in
|
||||
`GenerationBaseModel`. They fall into four groups that want different messages, not different rules:
|
||||
|
||||
| Group | Versions | Why not |
|
||||
| --- | --- | --- |
|
||||
| `Other` — the catch-all | 785 | Architecture unknown |
|
||||
| Legacy SD 2.x (2.1 768, 2.1, 2.0, 2.0 768, Unclip) | 537 | Deliberately retired |
|
||||
| API-only (Veo 3, Sora 2, Kling, Seedance, Seedream, Imagen4, OpenAI, Vidu Q1, Grok, Ideogram 4.0, Reve, MAI, Wan 2.5/2.7/3.0, MiniMax Music 3) | ~35 | Nothing to load, ever |
|
||||
| Open architectures the generator does not support yet (PixArt E 95, Lumina 39, Flux.1 Kontext 26, Kolors 19, ACE Audio 10, HiDream-O1 8, AuraFlow 7, Hunyuan 1 7, Mochi 5, MageFlow 5, Qwen 2 4, …) | ~240 | The generator cannot run the architecture |
|
||||
|
||||
Every one would fail at generation time even if loaded perfectly, so the gate is right. ⚠️ But all
|
||||
four currently produce the same silence in the UI. "We don't support this architecture yet" is a
|
||||
roadmap answer, "this is API-only" is permanent, and `Other` means the upload is missing metadata —
|
||||
worth distinguishing whenever a load CTA appears on a model page.
|
||||
|
||||
---
|
||||
|
||||
## The 36 mislabelled API models
|
||||
|
||||
36 `EcosystemCheckpoints` versions are external API models whose **only** file is type
|
||||
`Training Data`, format `Other`, scanned, with `usageControl = 'Generation'`.
|
||||
|
||||
They are FLUX, Flux.1 Kontext, Flux.2, Grok Imagine, Google Imagen 4, Kling Video, Nano Banana,
|
||||
ChatGPT Images / GPT-image-1, Qwen Image (API), Seedance, Seedream, Sora 2, Vidu Video and
|
||||
Wan 2.2 / 2.5 / 2.7 — several carrying "(API)" in the model name. **17 are ecosystem defaults.**
|
||||
|
||||
All 36 are `Published`, none is POI, none is private, and **all 36 are covered and in use in the
|
||||
generator today**.
|
||||
|
||||
**Decided (Justin, 2026-09-08): set their `usageControl` to `ExternalGeneration`,** which is what
|
||||
they are. The codebase already treats that value as "intentionally file-less, routed via external
|
||||
engines" — `ModelVersionList` stops reporting them as missing files, the upload step is skipped in
|
||||
the wizard, and `reset-to-draft-without-requirements` excludes them. Today those 36 lie to all three.
|
||||
|
||||
Verified safe: the `ExternalGeneration` branch of the view requires published and not-POI, and all 36
|
||||
satisfy both, so **coverage is preserved for 36 of 36**.
|
||||
|
||||
Notes for whoever runs it:
|
||||
|
||||
- `model-version.controller.ts` refuses `ExternalGeneration` from non-moderators, so this is a direct
|
||||
DB write or a moderator action — not creator-serviceable.
|
||||
- The `Training Data` files stay attached. Harmless for coverage, and the new rule ignores them
|
||||
because they are not loadable files. Whether an API model should carry one is a separate question.
|
||||
- After this, "no loadable file" and `ExternalGeneration` nearly coincide (51 file-less ecosystem
|
||||
checkpoints: 15 already flagged, 36 newly). **Keep the loader gated on "no loadable file" anyway** —
|
||||
it fails safe, so a future mislabelled model gets no CTA rather than an undeliverable load.
|
||||
|
||||
---
|
||||
|
||||
## `covered` is not `canGenerate`
|
||||
|
||||
The database view and `basemodel.constants.ts` answer different questions, and both are needed.
|
||||
Only the database knows licence, scan state, status and POI. Only the constants know which **model
|
||||
types** an ecosystem supports for generation — the view's type branch is one flat list
|
||||
(`LORA, TextualInversion, VAE, LoCon, DoRA`) applied to every base model.
|
||||
|
||||
Measured 2026-09-08: **736 versions across 33 (baseModel, type) pairs** are covered by the view and
|
||||
excluded by the constants — Wan Video + LORA (337), Flux.1 D + DoRA (102) / LoCon (76) / VAE (7) /
|
||||
TextualInversion (3), LTXV + LORA (67), and 28 smaller pairs on Flux.2, Krea 2, ZImage and Qwen.
|
||||
|
||||
Nothing was broken by this, because all three consumers composed the pair correctly — but each did
|
||||
so **by hand**: the models search index (twice), `model.service`, and the batch resolver in
|
||||
`generation.service`. A fourth consumer reading `covered` alone would offer those 736 a paid load,
|
||||
for a resource search already hides and the orchestrator cannot generate with.
|
||||
|
||||
**Resolved 2026-09-08.** The pair is composed once, in
|
||||
`isGenerationEligible` (`packages/civitai-shared/src/generation-eligibility.ts`), and all four call
|
||||
sites go through it. `no-divergent-can-generate-derivation` keeps
|
||||
`isBaseModelGenerationSupported` out of `src/` entirely, with an empty allowlist that fails if it
|
||||
grows — the same shape as `no-divergent-paid-gate-derivation`, written after the paid badge was
|
||||
copied four times.
|
||||
|
||||
🔴 **The paid-loading CTA gates on `isGenerationEligible`, never on `covered`.**
|
||||
|
||||
A longer-term fix would remove the divergence rather than compose around it: derive per-ecosystem
|
||||
type support into the database so the view's coarse type branch disappears. Bigger than this
|
||||
feature needs; worth filing.
|
||||
|
||||
## The `covered` readers audit
|
||||
|
||||
23 files read `GenerationCoverage` / `generationCoverage.covered`. Classified below by what changes
|
||||
when `covered` stops implying *resident*. The generation gate was read closely; the rest are
|
||||
identified and grouped, not yet read line by line.
|
||||
|
||||
### 🔴 A — the generation gate. Read this before scheduling the swap.
|
||||
|
||||
`canGenerate` in [generation.service.ts](../../src/server/services/generation/generation.service.ts)
|
||||
is `(resource.covered || explicitCoveredModelVersionIds.includes(id)) && !isUnavailable`, and an
|
||||
uncovered resource is routed through `getResourceDataSubstitutes`. `resource-data.redis.ts` carries
|
||||
`covered` into the generation resource cache and refuses to cache anything uncovered.
|
||||
|
||||
So the swap makes tens of thousands more versions generatable ([the numbers](#what-changes-in-numbers)),
|
||||
and for a non-resident checkpoint the
|
||||
orchestrator starts the download **implicitly, on submit**.
|
||||
|
||||
🔴 **That is free, uncapped model loading at the scale of the whole checkpoint catalogue.**
|
||||
`CalculateCost` returns zero, and the C10 rate limit only guards `resourceLoad.submit` — the
|
||||
implicit path via a generation submit has no cap at all. The gap was already recorded; the coverage
|
||||
change turns it from a theoretical bypass into an invitation with 33k entries.
|
||||
|
||||
**Sequencing that follows:** the shared rate-limit key on the generation submit path, and C2
|
||||
pricing, both land **before** anything reads the new view. Not after, and not in the same change.
|
||||
|
||||
### B — search
|
||||
|
||||
[models.search-index.ts](../../src/server/search-index/models.search-index.ts) and
|
||||
[models-update.ts](../../src/pages/api/mod/search/models-update.ts) derive an indexed `canGenerate`
|
||||
from `covered`. After the swap, search advertises every one of them as generatable — with no
|
||||
indication that many need a paid load first. Load state in search was deliberately deferred, so
|
||||
this widens exactly the surface that has no way to express the difference.
|
||||
|
||||
### C — display
|
||||
|
||||
`model.controller`, `model-version.controller`, `model.selector`, `modelVersion.selector`,
|
||||
`generation.selector` and `AutocompleteSearch/renderItems/models.tsx` render a badge or a Generate
|
||||
button — mostly correct after the change, since that is where the load CTA belongs.
|
||||
|
||||
**`/api/v1/model-versions/mini/[id]` has already been swapped** (2026-09-08). It is what the
|
||||
orchestrator reads for `CanGenerate`, and on the live view it made `prepareResource` refuse the very
|
||||
checkpoints paid loading exists for — verified on version 3040959. Its `covered` field therefore
|
||||
changed meaning for external consumers ahead of everything else, unflagged.
|
||||
|
||||
### D — pools and adjacent consumers
|
||||
|
||||
`daily-challenge-processing.ts` picks challenge models joined on coverage; `blocks/workflow.service.ts`
|
||||
reports coverage for App Blocks; `caches.ts` and `model.service.ts` filter model lists on it. Each
|
||||
widens with the view. None looks dangerous; all need a look before the swap.
|
||||
|
||||
### E — no action
|
||||
|
||||
`getCheckpointGenerationCoverage` is dead code (zero callers) and can go with `CoveredCheckpoint`.
|
||||
`backfill-trained-model-permissions.ts` asserts coverage never grants without `RentCivit` — still
|
||||
true under the new view.
|
||||
|
||||
## How to re-run this
|
||||
|
||||
- View definition: `SELECT pg_get_viewdef('public."GenerationCoverage"'::regclass, true)`
|
||||
- Constants side: run the real exports through `node_modules/.bin/tsx` —
|
||||
`getGenerationBaseModelRecords()`, `ecosystemSettings`, `allEcosystemDefaultVersionIds`. Regex over
|
||||
the file gives wrong answers; the array literal starts after `= [`, not at the first `[` (which
|
||||
belongs to `BaseModelRecord[]`).
|
||||
- ⚠️ Counting against `GenerationCoverage` directly is slow enough to hit the statement timeout on a
|
||||
899k-row view. Evaluate the branch predicates against the base tables instead.
|
||||
@@ -7,6 +7,7 @@ Companion to:
|
||||
- [paid-model-loading.md](paid-model-loading.md) — the contract, and the decisions already made
|
||||
- [paid-model-loading-build-plan.md](paid-model-loading-build-plan.md) — files, procedures, order
|
||||
- [paid-model-loading-checklist.md](paid-model-loading-checklist.md) — the state of the work
|
||||
- [paid-model-loading-coverage.md](paid-model-loading-coverage.md) — the coverage model and its audit
|
||||
|
||||
This file holds no implementation detail. Each entry says what is being decided, what turns on it,
|
||||
what the options are, and — following the repo's rule for anything filed as work — **who decides**
|
||||
@@ -18,9 +19,12 @@ Only genuinely open questions live here. Anything already answered belongs in
|
||||
work rather than a judgement belongs in the checklist — see [Not decisions](#not-decisions-tracked-elsewhere)
|
||||
at the end for the two big ones people keep mistaking for open questions.
|
||||
|
||||
**Nothing here blocks Phase A**, which is built. §1 blocks launch, §2 blocks specific build work,
|
||||
§3 is already implemented one way and needs ratifying or reversing, and §4 is for Koen — where both
|
||||
of his questions are now answered, and one of the answers reopened a decision we thought settled.
|
||||
**Nothing here blocks Phase A**, which is built. §1 and most of §2 were answered by Justin on
|
||||
2026-09-08 and are kept, with their answers, because each changed what gets built. What remains open
|
||||
is two build questions, the Phase A ratifications, and one new question for Koen.
|
||||
|
||||
The coverage model those answers produced — and the audit behind it — is
|
||||
[paid-model-loading-coverage.md](paid-model-loading-coverage.md).
|
||||
|
||||
## Who needs to answer what
|
||||
|
||||
@@ -29,9 +33,9 @@ answer in place. Search the file for `@dev:` to jump between them, or take just
|
||||
|
||||
| Who | Items |
|
||||
| --- | --- |
|
||||
| **Justin** | [1.1](#11--models-without-a-rentcivit-licence) licence gate · [1.2](#12-what-happens-when-a-load-fails-or-never-finishes) refund path · [1.3](#13--what-the-surfaces-may-promise-now-that-residency-exists) what we promise · [2.1](#21-c14--demo-client-mod-only-or-straight-into-the-platform) C14 · [2.2](#22-select-any-model--and-it-is-two-questions-not-one) select any model · [2.3](#23--who-owns-coveredcheckpoint) `CoveredCheckpoint` |
|
||||
| **Koen** | nothing open — K1 and K2 [answered](#answered-2026-09-04-and-2026-09-07); C2 (pricing) is still his to build |
|
||||
| **Briant / team** | [2.4](#24-whether-to-build-the-c4-webhook-at-all) C4 webhook · [2.5](#25-the-rate-limit-numbers-are-off-by-one) off-by-one · [3.1](#31-a-fifth-state-unknown)–[3.3](#33-which-buzz-account-pays) ratify Phase A |
|
||||
| **Justin** | nothing open — all six [answered 2026-09-08](#1-blocks-launch) |
|
||||
| **Koen** | [K3](#k3-does-the-orchestrator-refund-a-failed-prepare) refund on a failed prepare; C2 (pricing) is still his to build |
|
||||
| **Briant / team** | [2.4](#24--the-c4-webhook--not-now) C4 webhook · [2.5](#25-the-rate-limit-numbers-are-off-by-one) off-by-one · [2.6](#26-the-17-base-model-gap-between-the-constants-and-generationbasemodel) constants gap · [3.1](#31-a-fifth-state-unknown)–[3.3](#33-which-buzz-account-pays) ratify Phase A |
|
||||
|
||||
Answering in place is enough — nothing here needs a meeting. An item with no answer after review is
|
||||
one we will ship a default for, and each entry says what that default would be.
|
||||
@@ -40,168 +44,143 @@ one we will ship a default for, and each entry says what that default would be.
|
||||
|
||||
## 1. Blocks launch
|
||||
|
||||
### 1.1 🔴 Models without a `RentCivit` licence
|
||||
**1.1–1.3 answered by Justin, 2026-09-08**; 1.4–1.6 were decided in code while building. All six are
|
||||
kept with their answers because each changed what gets built; the work they created is in the
|
||||
[checklist](paid-model-loading-checklist.md).
|
||||
|
||||
**The decision:** whether the purchase path refuses them.
|
||||
### 1.1 ✅ Models without a `RentCivit` licence — refuse
|
||||
|
||||
**What turns on it:** `GenerationCoverage` excludes these models deliberately — the creator did not
|
||||
grant on-site generation. Taking payment to load one sells what the licence forbids. The failure
|
||||
mode is a refund *plus* a creator complaint, which is the expensive pair.
|
||||
> **Justin:** "Do we currently allow on-site generation for models without a `RentCivit` license?
|
||||
> I'm assuming that we don't. We should be refusing if `RentCivit` is false"
|
||||
|
||||
**Not implemented.** `submit` will currently accept a load for such a model. This is the only gap in
|
||||
the built code that is a policy question rather than missing work.
|
||||
Correct on the main path, and confirmed in the data: **zero covered versions lack `RentCivit`** —
|
||||
measured branch by branch and end-to-end against the view.
|
||||
|
||||
**Options:** refuse at the CTA and at `submit`; or get a product decision that loading is not
|
||||
"generating" and the licence does not reach it.
|
||||
**Implement as: refuse anything not in `GenerationCoverage`**, rather than testing the licence
|
||||
directly. The two select the same set today, but the view also carries the two branches that skip the
|
||||
licence check, so gating on coverage inherits the rule instead of keeping a second opinion of it.
|
||||
Detail in [coverage](paid-model-loading-coverage.md#the-licence-gate).
|
||||
|
||||
**Recommendation:** refuse, unless someone senior signs the opposite in writing. It is three lines
|
||||
of service code once the answer exists.
|
||||
### 1.2 ✅ A load that never finishes — refund
|
||||
|
||||
**Owner:** Justin (Briant may take it) — no ClickUp task yet.
|
||||
**Closes when:** the purchase path refuses unlicensed models, or a named person signs off that it
|
||||
should not.
|
||||
> **Justin:** "refund failed loads, though this should be occurring via the orchestrator"
|
||||
|
||||
> **@dev: Justin** — Do we refuse to sell a load for a model whose creator did not grant `RentCivit`?
|
||||
> A yes costs three lines of code; a no needs your name on it, because the licence says otherwise.
|
||||
>
|
||||
> _Answer:_
|
||||
⚠️ **The second clause is an assumption, not a confirmed behaviour.** `CalculateCost` returns zero
|
||||
today, so no prepare has ever been charged and no refund path has ever run. Whether the orchestrator
|
||||
refunds a failed or timed-out `prepareResource` is [K3](#k3-does-the-orchestrator-refund-a-failed-prepare),
|
||||
and it has to be answered before pricing goes live — not after.
|
||||
|
||||
### 1.2 What happens when a load fails or never finishes
|
||||
### 1.3 ✅ What we promise — the original 48 hours
|
||||
|
||||
**The decision:** whether there is a refund path, and who runs it.
|
||||
> **Justin:** "'another controller has it' means that the model is still downloaded on our servers
|
||||
> and available for generation. I think the original promise should suffice."
|
||||
|
||||
**What turns on it:** bandwidth was measured at ~10 KB/s with LoRAs taking four hours, and
|
||||
`PrepareResourceJob` has a 24-hour `MaxTimeout` — so a large checkpoint can plausibly hit the
|
||||
ceiling and never complete. Koen on the call: "we got to be prepared for us not giving any hard
|
||||
guarantees about when it's going to be available."
|
||||
That reading makes the eviction caveat benign: the copy can move, the availability does not. So the
|
||||
48-hour promise as originally pitched is what the surfaces say. This closes the question reopened on
|
||||
2026-09-07 when Koen's answer showed residency exists after all.
|
||||
|
||||
No task, no owner, and it only becomes visible once money is real — so it is due at C2, not before.
|
||||
### 1.4 ✅ Progress signals go to the buyer, not to a topic
|
||||
|
||||
**Owner:** Justin — no ClickUp task yet.
|
||||
**Closes when:** a refund path exists, or a named person accepts that failed loads are not refunded
|
||||
and the surfaces say so before purchase.
|
||||
**Decided 2026-09-08**, and it corrects a design these docs recorded.
|
||||
|
||||
> **@dev: Justin** — A load that never finishes: refund, or say up front that we do not refund?
|
||||
> Not urgent until C2 makes the money real, but it decides what the CTA has to say before purchase.
|
||||
>
|
||||
> _Answer:_
|
||||
The load callback used to point at the `model-version:<id>` signals **group**, so anyone watching
|
||||
the model received progress. 🔴 That leaks: the orchestrator posts its `WorkflowStepEvent` straight
|
||||
to the signals service — we are not in the path and cannot rewrite it — and `workflowId` is
|
||||
`<userId>-<timestamp>` (see `workflowOwnerId`). A group broadcast would tell everyone watching a
|
||||
model **who paid for the load**.
|
||||
|
||||
### 1.3 🔴 What the surfaces may promise, now that residency exists
|
||||
Callbacks now target `/users/{userId}/signals/`. Pinned by a test asserting the URL contains
|
||||
`/users/` and not `/groups/`.
|
||||
|
||||
**The decision:** what the CTA says a paid load buys.
|
||||
Consequence: bystanders get no **live** progress. They are told when it is ready instead — see 1.5.
|
||||
|
||||
**Reopened 2026-09-07.** This was settled as "promise a load, never a duration", on the premise that
|
||||
no residency mechanism existed. That premise was false — it lives in `civitai-spine-controller`,
|
||||
which nothing on the site side had read. The old answer is therefore not safe to keep by default: it
|
||||
was right about a world we are not in.
|
||||
### 1.5 ✅ Notification is a toast on return; real notifications are Phase 2
|
||||
|
||||
**What turns on it:** the policy refuses to evict a resource less than 48h old **unless another
|
||||
spine controller has a copy**. So the honest sentence is closer to *"it stays reachable in the
|
||||
cluster for 48 hours"* than *"we hold your copy for 48 hours"* — and for a user who paid, the
|
||||
difference only shows up on the day it bites.
|
||||
**Decided 2026-09-08.**
|
||||
|
||||
Two things nobody on the site side can currently check: we have not read the policy (private repo),
|
||||
and **no API reports when a given resource's window ends**, so we cannot show a countdown even if we
|
||||
promised one.
|
||||
A browser keeps what it is waiting on in `localStorage` — loads it requested and loads it chose to
|
||||
watch. The queue **drains on every page load**: finished ones raise a toast that must be dismissed
|
||||
and are removed, ones that can no longer finish are removed, and the rest stay subscribed. The drain
|
||||
is mounted app-wide, so a finished load is reported wherever the user lands next.
|
||||
|
||||
**Options:** promise the 48 hours as pitched; promise reachability without a duration; or promise
|
||||
the duration with the caveat stated in the CTA.
|
||||
🔴 **A ceiling is what makes "it always drains" true.** Done / gone / still-loading does not cover a
|
||||
load that FAILED or one that finished and was then evicted — both read back as `unavailable`, which
|
||||
is indistinguishable from "queued". Without a deadline such an item is re-subscribed forever. Items
|
||||
expire at 48h, matching the residency policy.
|
||||
|
||||
**Recommendation:** ask Koen to confirm the user-visible consequence of the "unless another
|
||||
controller has it" branch before writing any of the three. It is one question and it decides the
|
||||
sentence.
|
||||
**Accepted limits:** this reaches someone only when they return, in that browser. A different device
|
||||
or a cleared browser gets nothing, and that is fine (Justin, 2026-09-08). Reaching a user who does
|
||||
not come back is the **Phase 2** goal — API-level notifications, for users who want them.
|
||||
|
||||
**Owner:** Justin — no ClickUp task yet.
|
||||
**Closes when:** the CTA copy is written and someone named signs it off.
|
||||
### 1.6 ✅ There is a durable record of a purchased load, and it is not ours
|
||||
|
||||
> **@dev: Justin** — Residency turned out to exist (see §4). What do we tell a buyer they are
|
||||
> getting: "48 hours", "loaded and kept available", or "48 hours, usually"? Worth one confirmation
|
||||
> from Koen on the eviction caveat first.
|
||||
>
|
||||
> _Answer:_
|
||||
**Decided 2026-09-08.** `submitResourceLoad` tags every load `resource-load`, and
|
||||
`queryWorkflows({ token, tags })` returns that user's workflows — durable, cross-device, no site-side
|
||||
storage. So `localStorage` is **not** the record of what someone bought; it is one browser's list of
|
||||
what it is watching.
|
||||
|
||||
Redis was considered and rejected as the home for this: something that must survive hours and drive
|
||||
a notification should not sit somewhere evictable.
|
||||
|
||||
⚠️ Not yet built as a procedure (`getMyLoads`), and one unknown remains — **how long the
|
||||
orchestrator retains a completed workflow**, which bounds how far back such a list can look. Worth
|
||||
asking Koen alongside [K3](#k3-does-the-orchestrator-refund-a-failed-prepare).
|
||||
|
||||
---
|
||||
|
||||
## 2. Blocks specific build work
|
||||
|
||||
### 2.1 C14 — demo client, mod-only, or straight into the platform
|
||||
### 2.1 ✅ C14 — start with the mod test page
|
||||
|
||||
**Gates:** C5–C8, the three real surfaces.
|
||||
> **Justin:** "We are going to start with the test page I asked for. The page that allows me, a mod,
|
||||
> to request a model to be loaded and see what models are loaded and get status updates as a model is
|
||||
> loading. This should already be documented."
|
||||
|
||||
Justin's counter to a full platform rollout is a small standalone first-party app driving Koen's API
|
||||
end to end, or a mod-only launch. **The mod test page at `/moderator/resource-load` is the cheap
|
||||
version of the third option and already exists** — it drives estimate, submit and the live queue
|
||||
against the real orchestrator. That may narrow the question rather than answer it.
|
||||
It is built and documented — `/moderator/resource-load`, Phase 1.5 in the checklist. So C14 is
|
||||
answered by something that already exists: no standalone demo client, no platform rollout yet.
|
||||
|
||||
**Owner:** Justin ([868ktt5bz](https://app.clickup.com/t/868ktt5bz)).
|
||||
**Closes when:** Justin states the choice in the task.
|
||||
### 2.2 ✅ Checkpoints only — **not** LoRA-first
|
||||
|
||||
> **@dev: Justin** — Demo client, mod-only, or straight into the platform? Worth looking at
|
||||
> `/moderator/resource-load` first — it already drives the real orchestrator end to end, which may
|
||||
> be the demo you were asking for rather than an argument for building a separate app.
|
||||
>
|
||||
> _Answer:_
|
||||
> **Justin:** "model loading only applies to checkpoints. Checkpoints have this separate loading
|
||||
> system due to the size of the models. Loras typically aren't large enough to worry about."
|
||||
|
||||
### 2.2 "Select any model" — and it is two questions, not one
|
||||
🔴 **This reverses the recommendation these docs carried.** The LoRA-first argument — that LoRAs need
|
||||
no view change and are therefore the smallest slice — was solving the wrong problem: size is the
|
||||
reason the loader exists, and LoRAs do not have it. Every "LoRA-first" recommendation in this
|
||||
document set was wrong and has been removed.
|
||||
|
||||
**Gates:** C6 (the generator), and nothing else. Phase A and the rest of Phase B do not touch it.
|
||||
Consequence: 2.3 is not optional, it is the critical path.
|
||||
|
||||
`GenerationCoverage` is a **view**, not a flag, so there is no `covered` boolean to set.
|
||||
### 2.3 ✅ `CoveredCheckpoint` goes away
|
||||
|
||||
- **LoRA / TextualInversion / VAE / LoCon / DoRA / Upscaler** are already covered once licensed and
|
||||
scanned. They are merely not *resident* — exactly the problem paid loading solves. **No view
|
||||
change.**
|
||||
- **Checkpoints** additionally require membership in `CoveredCheckpoint`, which the weekly auction
|
||||
job owns and prunes (see 2.3).
|
||||
> **Justin:** "In theory, coveredCheckpoint shouldn't affect generation going forward. If
|
||||
> CoveredCheckpoint is only used for generation, then CoveredCheckpoint should go away. […] So,
|
||||
> canGenerate for checkpoint models should no longer be conditional on CoveredCheckpoint from the
|
||||
> auction system."
|
||||
|
||||
**Recommendation:** LoRA-first v1. It avoids both the view change and the auction entanglement, and
|
||||
it is the only slice that can ship without settling 2.3.
|
||||
The conditional holds: `CoveredCheckpoint` has four uses and all four are generation, one of which
|
||||
is dead code. Removing it widens covered checkpoints by roughly two orders of magnitude —
|
||||
[the numbers](paid-model-loading-coverage.md#what-changes-in-numbers).
|
||||
|
||||
**Owner:** Justin — no ClickUp task yet.
|
||||
**Closes when:** the choice is written into paid-model-loading.md, and a task exists if a checkpoint
|
||||
path is in scope.
|
||||
⚠️ The audit that followed found the neighbouring table is the opposite case: **`EcosystemCheckpoints`
|
||||
must stay**, because 62 of 63 checkpoint defaults are covered through it and none through
|
||||
`CoveredCheckpoint`. See [coverage](paid-model-loading-coverage.md#the-two-tables-do-opposite-jobs).
|
||||
|
||||
> **@dev: Justin** — LoRA-first, or checkpoints in v1 too? Checkpoints drag in a view change and the
|
||||
> auction entanglement in 2.3; LoRAs need neither and are already the resources people cannot
|
||||
> generate with today.
|
||||
>
|
||||
> _Answer:_
|
||||
### 2.4 ✅ The C4 webhook — not now
|
||||
|
||||
### 2.3 🔴 Who owns `CoveredCheckpoint`
|
||||
**Closed 2026-09-08: no.** Both reasons to build it went away on the same day.
|
||||
|
||||
**Gates:** any checkpoint shipping as paid-loadable. Follows directly from 2.2.
|
||||
It existed to do two things a direct-to-signals callback cannot: fire the completion notification
|
||||
(C9), and let a bystander see a load without disclosing who paid for it. C9 is now a **Phase 2**
|
||||
goal, and bystanders **do not need live progress** — they need to be told when it is ready, which
|
||||
the localStorage drain does by asking `getState` on their next visit. Neither needs a hop.
|
||||
|
||||
`handle-auctions.ts` deletes every row outside the weekly winner set on each cycle, so a checkpoint
|
||||
someone paid to load loses its coverage at the next auction run — silently.
|
||||
Not building it also avoids an endpoint that would fire every 10 seconds per in-flight download
|
||||
across the whole cluster.
|
||||
|
||||
**Owner:** Justin — no ClickUp task yet, and entangled with 868gtq1kt (splitting featuring out of
|
||||
auctions).
|
||||
**Closes when:** ownership of the table is settled, before — not after — a checkpoint is offered.
|
||||
|
||||
> **@dev: Justin** — Only if 2.2 lets checkpoints in. Who owns `CoveredCheckpoint` once paid loading
|
||||
> can put rows in it? As it stands the weekly auction job deletes anything it did not put there, so a
|
||||
> paid checkpoint silently loses coverage.
|
||||
>
|
||||
> _Answer:_
|
||||
|
||||
### 2.4 Whether to build the C4 webhook at all
|
||||
|
||||
**The decision:** whether progress needs a server-side hop. Contingent on C9 being scoped.
|
||||
|
||||
The load submit points its callback straight at the signals **group** URL for `model-version:<id>`,
|
||||
so progress already fans out with no endpoint of ours in the path. The only things a webhook would
|
||||
add are the completion notification (C9) and resolving AIR → version id once instead of per client.
|
||||
|
||||
**Recommendation:** build it if and only if C9 survives scoping. Otherwise it is a hop that does
|
||||
nothing.
|
||||
|
||||
**Owner:** whoever scopes C9.
|
||||
**Closes when:** C9 is scoped in or out.
|
||||
|
||||
> **@dev:** Is C9 (the completion notification) in scope? That is the whole question — if yes we need
|
||||
> the webhook, if no it does nothing.
|
||||
>
|
||||
> _Answer:_
|
||||
⚠️ **It comes back with Phase 2.** A real notification has to be sent from somewhere, and that
|
||||
somewhere is a server-side moment this feature does not otherwise have. Reopen this rather than
|
||||
inventing a second mechanism.
|
||||
|
||||
### 2.5 The rate-limit numbers are off by one
|
||||
|
||||
@@ -226,6 +205,27 @@ is already documented beside the limiter, so only the renumber option is still o
|
||||
>
|
||||
> _Answer:_
|
||||
|
||||
### 2.6 The 17-base-model gap between the constants and `GenerationBaseModel`
|
||||
|
||||
**The decision:** whether `basemodel.constants.ts` or the database is wrong.
|
||||
|
||||
17 base models declare generation support in the constants and have no `GenerationBaseModel` row;
|
||||
5 rows exist in the table that the constants do not declare. The 17 are generatable today **only**
|
||||
because `EcosystemCheckpoints` covers their default model — the allowlist never learned about them,
|
||||
and the other table quietly compensated. Full lists in
|
||||
[coverage](paid-model-loading-coverage.md#basemodelconstantsts--generationbasemodel-disagree).
|
||||
|
||||
**What turns on it:** nothing for paid loading, which gates on `GenerationBaseModel` either way. It
|
||||
matters because the two sources of truth disagree and nothing detects it — a guard-shaped problem.
|
||||
|
||||
**Owner:** unowned.
|
||||
**Closes when:** the rows are added, or the constants stop claiming generation support, or a test
|
||||
pins the two together.
|
||||
|
||||
> **@dev:** Worth fixing now, or filing? It predates paid loading and does not block it.
|
||||
>
|
||||
> _Answer:_
|
||||
|
||||
---
|
||||
|
||||
## 3. Already decided in code during Phase A — ratify or reverse
|
||||
@@ -279,6 +279,23 @@ from 2026-09-01, on a neighbouring topic, for whoever chases it: *"The whole pri
|
||||
orchestrator is one big mess with many features hacked on top of other features, that makes me
|
||||
irrationally reluctant to touch it, but I do agree with your reasoning, will put it on my list."*
|
||||
|
||||
### K3 Does the orchestrator refund a failed prepare?
|
||||
|
||||
Justin decided a load that never finishes is refunded, and expects the orchestrator to be doing it
|
||||
("though this should be occurring via the orchestrator"). Nothing confirms that: `CalculateCost`
|
||||
returns zero, so no prepare has ever been charged and no refund has ever been exercised.
|
||||
`PrepareResourceJob` has a 24-hour `MaxTimeout`, and at the bandwidth measured on the lab call a
|
||||
large checkpoint can plausibly reach it.
|
||||
|
||||
**What turns on it:** whether the refund is the orchestrator's or ours. If ours, it is unscoped work
|
||||
that has to land with pricing rather than after it.
|
||||
|
||||
> **@dev: Koen** — When a `prepareResource` step fails or hits its 24h timeout, does the charge get
|
||||
> refunded automatically, or does the consumer have to reverse it? Same conversation as C2, since
|
||||
> neither can be observed until a prepare actually costs something.
|
||||
>
|
||||
> _Answer:_
|
||||
|
||||
### Answered, 2026-09-04 and 2026-09-07
|
||||
|
||||
Kept as a record because both answers changed what this document says.
|
||||
@@ -289,7 +306,7 @@ controllers check with each other before evicting a resource and refuse to evict
|
||||
and that policy guards its lifetime from then on — `ClusterAwareEvictionPolicy.cs` in
|
||||
`civitai-spine-controller`. **`PinModelJob` is legacy: "don't even look at it."** This register had
|
||||
it as the intended primitive, which was wrong. What the answer opens rather than closes is
|
||||
[1.3](#13--what-the-surfaces-may-promise-now-that-residency-exists).
|
||||
[1.3](#13--what-we-promise--the-original-48-hours).
|
||||
|
||||
**K2 — is `step:preparing` unadvertised on purpose?** No. Koen: the intent was that `preparing` and
|
||||
`scheduled` are step statuses and never workflow statuses, but their absence from the callback enum
|
||||
@@ -303,4 +320,4 @@ the types.
|
||||
|
||||
- **C2 — pricing and charging.** Koen's, and not a config toggle — see [§4](#c2--pricing).
|
||||
- **Residency.** Built, and not ours — the spine controllers enforce it. What is ours is the copy
|
||||
question, [1.3](#13--what-the-surfaces-may-promise-now-that-residency-exists).
|
||||
question, [1.3](#13--what-we-promise--the-original-48-hours).
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Paid Model Loading — six questions for Justin
|
||||
|
||||
> ✅ **All six answered, 2026-09-08.** Justin's answers are inline under each `@dev:` marker below.
|
||||
> This page is now a **record of what was asked and answered** — the recommendations in it were
|
||||
> written before the answers and one of them (2.2, LoRA-first) turned out to be wrong. For the
|
||||
> current position read [the register](paid-model-loading-decisions.md) and
|
||||
> [the coverage model](paid-model-loading-coverage.md), not this page.
|
||||
|
||||
Everything on this page is a decision only you can make. Each one is stated in a sentence, with what
|
||||
turns on it, the options, and a recommendation you can just say "yes" to.
|
||||
|
||||
**Answer in place** — write after the `@dev:` marker under each question, or reply wherever this
|
||||
reached you. Search `@dev:` to jump between them.
|
||||
|
||||
This is a view for reading. The register with owners, closing conditions and the engineering detail
|
||||
is [paid-model-loading-decisions.md](paid-model-loading-decisions.md); numbering matches it, so 1.1
|
||||
here is 1.1 there. Where the two ever disagree, that one is right.
|
||||
|
||||
**If you answer only one, make it 2.2.** It closes itself, makes 2.3 moot, and unblocks the
|
||||
generator — three of the six on one reply.
|
||||
|
||||
| | Question | If you don't answer |
|
||||
| --- | --- | --- |
|
||||
| [1.1](#11-do-we-refuse-models-without-a-rentcivit-licence) 🔴 | Refuse models without a `RentCivit` licence? | We refuse them |
|
||||
| [1.2](#12-what-happens-when-a-load-never-finishes) | Refund a load that never finishes? | The CTA says we don't refund |
|
||||
| [1.3](#13-what-do-we-tell-a-buyer-they-are-getting) 🔴 | What do we promise a buyer? | We promise a load, no duration |
|
||||
| [2.1](#21-demo-client-mod-only-or-straight-into-the-platform) | C14: demo, mod-only, or platform? | Stays mod-only |
|
||||
| [2.2](#22-lora-first-or-checkpoints-in-v1-too) | LoRA-first, or checkpoints too? | LoRA-first |
|
||||
| [2.3](#23-who-owns-coveredcheckpoint) 🔴 | Who owns `CoveredCheckpoint`? | Only matters if 2.2 says checkpoints |
|
||||
|
||||
Where the work stands: the server side is built and driveable today on a mod-only page
|
||||
(`/moderator/resource-load`) — enter a model version id, get an estimate, submit, watch the queue.
|
||||
What is missing is pricing (Koen's, in progress) and the answers below.
|
||||
|
||||
---
|
||||
|
||||
## Blocks launch
|
||||
|
||||
### 1.1 Do we refuse models without a `RentCivit` licence?
|
||||
|
||||
A creator who has not granted `RentCivit` has deliberately said their model may not be generated
|
||||
with on-site. Paid loading would take money to pull that model into the cluster — selling something
|
||||
the licence forbids.
|
||||
|
||||
The code accepted it when this was written; the refusal shipped 2026-09-08.
|
||||
|
||||
**What it costs to get wrong:** a refund *and* a creator complaint, which is the expensive pair.
|
||||
|
||||
**Options:** refuse them at the button and on the server; or decide that loading is not "generating"
|
||||
and the licence does not reach it.
|
||||
|
||||
**Recommendation:** refuse. It is three lines of code. If we go the other way I would want your name
|
||||
on it in writing, because the licence text says otherwise.
|
||||
|
||||
@dev: Do we currently allow on-site generation for models without a `RentCivit` license? I'm assuming that we don't. We should be refusing if `RentCivit` is false
|
||||
|
||||
### 1.2 What happens when a load never finishes?
|
||||
|
||||
Bandwidth into the data centre was measured at roughly 10 KB/s at the time of the lab call — LoRAs
|
||||
took four hours — and the orchestrator gives a load 24 hours before it gives up. So a large model
|
||||
plausibly never completes.
|
||||
|
||||
Not urgent until pricing lands and the money is real. But it decides what the button has to say
|
||||
*before* someone clicks it, so it cannot wait until after.
|
||||
|
||||
**Options:** refund failed loads; or say plainly up front that we don't, and let people decide with
|
||||
that in hand.
|
||||
|
||||
**Recommendation:** no strong view — this is a support-cost question more than an engineering one.
|
||||
Whichever you pick, the CTA has to say it before purchase.
|
||||
|
||||
@dev: refund failed loads, though this should be occurring via the orchestrator
|
||||
|
||||
### 1.3 What do we tell a buyer they are getting?
|
||||
|
||||
**This one reopened last week.** We had settled on promising nothing about duration, because as far
|
||||
as we could tell the 48-hour residency did not exist. That was wrong — we had only read the
|
||||
orchestrator, and the mechanism lives in a different repo. Koen, 4 Sept:
|
||||
|
||||
> "The spine controllers check with each other before evicting a resource, it refuses to evict
|
||||
> something thats less than 48h old unless other spine controllers have it"
|
||||
|
||||
So residency is real. The catch is the last clause: a copy **can** be dropped inside 48 hours if
|
||||
another controller still has one. That makes *"we keep your copy for 48 hours"* and *"it stays
|
||||
reachable for 48 hours"* different promises — and the difference only shows up on the day it bites
|
||||
someone who paid.
|
||||
|
||||
Also worth knowing: nothing reports **when** a given model's 48 hours run out, so we cannot show a
|
||||
countdown even if we promised one.
|
||||
|
||||
**Options:** promise the 48 hours as originally pitched; promise it stays loaded without naming a
|
||||
duration; or promise 48 hours with the caveat said out loud.
|
||||
|
||||
**Recommendation:** one question to Koen first — what that "unless another controller has it" branch
|
||||
actually means for someone who paid. It is a small ask and it picks the sentence for you. I can send
|
||||
it if you want.
|
||||
|
||||
@dev: "another controller has it" means that the model is still downloaded on our servers and available for generation. I think the original promise should suffice.
|
||||
|
||||
---
|
||||
|
||||
## Blocks build work
|
||||
|
||||
### 2.1 Demo client, mod-only, or straight into the platform?
|
||||
|
||||
Your position on the lab call was that a change this large to how generation works should be shown
|
||||
to power users before it is sprinkled through the platform — either a small standalone app driving
|
||||
Koen's API, or a mod-only launch.
|
||||
|
||||
**Worth knowing before you decide:** the mod-only version already exists. `/moderator/resource-load`
|
||||
drives the real orchestrator end to end — estimate, submit, live queue. That may be the demo you
|
||||
were asking for, rather than an argument for building a separate app.
|
||||
|
||||
**Recommendation:** look at that page first, then decide. If it does what you wanted, this question
|
||||
answers itself and we skip building a second client.
|
||||
|
||||
@dev: We are going to start with the test page I asked for. The page that allows me, a mod, to request a model to be loaded and see what models are loaded and get status updates as a model is loading. This should already be documented.
|
||||
|
||||
### 2.2 LoRA-first, or checkpoints in v1 too?
|
||||
|
||||
These are two different jobs, and only one of them is small.
|
||||
|
||||
- **LoRAs** (and embeddings, VAEs, LoCon, DoRA) are already allowed to generate once licensed and
|
||||
scanned. They are simply not loaded into the cluster — exactly what paid loading fixes. **No
|
||||
plumbing change at all.**
|
||||
- **Checkpoints** additionally have to be on a curated list that the weekly auction job owns and
|
||||
rewrites. That drags in both a database change and the auction question in 2.3.
|
||||
|
||||
**Recommendation:** LoRA-first. It ships without touching auctions, and LoRAs are the resources
|
||||
people actually cannot generate with today. Checkpoints can follow once 2.3 has an owner.
|
||||
|
||||
@dev: model loading only applies to checkpoints. Checkpoints have this separate loading system due to the size of the models. Loras typically aren't large enough to worry about.
|
||||
|
||||
### 2.3 Who owns `CoveredCheckpoint`?
|
||||
|
||||
**Skip this if 2.2 is LoRA-first.**
|
||||
|
||||
If checkpoints are in scope: the weekly auction job currently deletes every checkpoint outside that
|
||||
week's winners. So a checkpoint someone *paid* to load would quietly lose its place at the next
|
||||
auction run, with nothing telling them or us.
|
||||
|
||||
Nobody owns that table for this purpose, and it is tangled with the existing task about splitting
|
||||
"featured" out of auctions.
|
||||
|
||||
**Recommendation:** do not ship a paid checkpoint until this has an owner. The failure is silent,
|
||||
which is the kind we find out about from users.
|
||||
|
||||
@dev: In theory, coveredCheckpoint shouldn't affect generation going forward. If CoveredCheckpoint is only used for generation, then CoveredCheckpoint should go away. The idea is that a user can generate with any checkpoint for ecosystems that support community checkpoints. If a checkpoint isn't currently available in the generator, a user will be prompted to pay to load that model, and they should be able to know when that model becomes available. So, canGenerate for checkpoint models should no longer be conditional on CoveredCheckpoint from the auction system.
|
||||
|
||||
---
|
||||
|
||||
## Not for you, listed so the picture is complete
|
||||
|
||||
- **Pricing (C2)** — Koen's. The orchestrator currently prices a load at zero, so there is no number
|
||||
to show yet. Everything else on the purchase path is built and waiting on it.
|
||||
- **Two questions Koen already answered** — residency (above) and a spec gap in the progress events,
|
||||
which he has since fixed.
|
||||
- **Three engineering choices** already made in code and cheap to reverse, plus two small build
|
||||
questions. Those are ours, not yours; they are in the register if you want them.
|
||||
@@ -12,6 +12,7 @@ repo, `civitai-spine-controller`. Reading only the orchestrator produced a confi
|
||||
conclusion here once already (see What it is), so "verified against source" in this document means
|
||||
verified against the orchestrator unless it says otherwise.
|
||||
**Tracking:** ClickUp C2–C14, `Synced Team`.
|
||||
**Coverage model and audit:** [paid-model-loading-coverage.md](paid-model-loading-coverage.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -28,11 +29,12 @@ from then on — the code is `ClusterAwareEvictionPolicy.cs` in `civitai-spine-c
|
||||
`PinModelJob`, which an earlier reading of this document called the intended primitive, is
|
||||
**legacy — Koen: "don't even look at it."**
|
||||
|
||||
⚠️ Nobody on the site side has read that policy: the repo is private to us here. And the condition
|
||||
is not unconditional — a copy *can* go inside 48h when another controller holds one — which is the
|
||||
difference between "we keep it for 48 hours" and "it stays reachable for 48 hours". What we may
|
||||
therefore promise is
|
||||
[1.3](paid-model-loading-decisions.md#13--what-the-surfaces-may-promise-now-that-residency-exists).
|
||||
The "unless another controller has it" clause is benign: Justin, 2026-09-08 — it means the model is
|
||||
still downloaded on our servers and available for generation. The copy may move; availability does
|
||||
not lapse. **So the surfaces promise the 48 hours as originally pitched.**
|
||||
|
||||
⚠️ Nobody on the site side has read that policy — the repo is private to us here — and nothing
|
||||
exposes when a given resource's window ends.
|
||||
|
||||
🔴 **The price half is still missing.** The cost function returns a hardcoded zero, so there is no
|
||||
size-scaled price to show. See [Orchestrator state](paid-model-loading-checklist.md#orchestrator-state--verified-against-source).
|
||||
@@ -176,9 +178,14 @@ agreed to. A new `SignalMessages` entry is all that is needed on top.
|
||||
subscribers per topic and joins/leaves the group automatically.
|
||||
`useSignalConnection(message, cb)` receives.
|
||||
|
||||
**Client persistence.** What the user is waiting on lives in `localStorage`. On load: poll
|
||||
`GET /v2/resources/{air}` first; if complete, drop it; if not, resubscribe and carry on. That is
|
||||
the whole reconnect story — the design as discussed has no server-side "my loads" record.
|
||||
**Client persistence.** What a browser is *watching* lives in `localStorage`, drained on every page
|
||||
load (see Decided). That is not the record of a purchase: the orchestrator holds that, as workflows
|
||||
tagged `resource-load` queryable with the buyer's token.
|
||||
|
||||
⚠️ **Progress for this feature is NOT a topic broadcast.** It goes to `/users/{userId}/signals/`,
|
||||
because the orchestrator posts its event body straight through and that body names the paying user.
|
||||
The topic convention below is still how the site subscribes to a model version generally — it is
|
||||
just not how load progress is delivered.
|
||||
|
||||
---
|
||||
|
||||
@@ -189,10 +196,13 @@ Site-side only, deliberately. Anyone can go straight to the orchestrator; the co
|
||||
|
||||
| Tier | Loads per day |
|
||||
| --- | --- |
|
||||
| Free | 0 |
|
||||
| Free | 0 — refused by `assertCanRequestLoad` before the limiter is reached |
|
||||
| Bronze | 3 |
|
||||
| Silver | 6 |
|
||||
| Gold | 10 |
|
||||
| Gold / Founder | 10 |
|
||||
|
||||
On top of the daily ladder there is a flat, tier-independent **3 per hour** — burst protection for
|
||||
the cluster, not an entitlement, so no plan buys its way out of it.
|
||||
|
||||
Free started at 1; Koen suggested members-only to start; Justin settled on 0. Silver was left at
|
||||
5–6 on the call and shipped as 6. These are deliberately low and meant to be raised.
|
||||
@@ -204,7 +214,9 @@ whether the cap actually holds:
|
||||
1. **A tier ladder composes correctly.** Per period the *highest* matching limit wins, so
|
||||
declaring all four tiers with `userReq` predicates and letting a gold user match several of
|
||||
them yields 10, not 3.
|
||||
2. **`limit: 0` short-circuits immediately**, so free = 0 works with no special case.
|
||||
2. **`limit: 0` is not the member gate.** It short-circuits cleanly, but the middleware returns
|
||||
early for moderators and in dev/test/preview, so on a preview build nothing else would stand
|
||||
between a free account and a free load. `assertCanRequestLoad()` in the router is the gate.
|
||||
3. **It is off by one.** The check is `relevantAttempts > limit`, so a limit of 3 permits 4.
|
||||
Either accept it and write the numbers down as "3 means 4", or fix the comparison — but that
|
||||
comparison is shared with every other limiter in the app, so fixing it changes them all.
|
||||
@@ -222,46 +234,43 @@ button.
|
||||
|
||||
---
|
||||
|
||||
## "Select any model" — what it actually costs
|
||||
## "Select any model" — the coverage change
|
||||
|
||||
The premise of the feature is that the generator stops being restricted to a curated set. No task
|
||||
covers that change, and it is the largest single piece of work in the feature.
|
||||
The premise of the feature is that the generator stops being restricted to a curated set. As of
|
||||
2026-09-08 this is scoped and decided; the full model, the audit and every measured number live in
|
||||
[paid-model-loading-coverage.md](paid-model-loading-coverage.md). In short:
|
||||
|
||||
`GenerationCoverage` is **a database view, not a table** — there is no `covered` boolean to flip.
|
||||
Read the live definition with
|
||||
`SELECT pg_get_viewdef('public."GenerationCoverage"'::regclass, true)`. It computes coverage from,
|
||||
among other things:
|
||||
- **`CoveredCheckpoint` goes away.** It is the auction's residency proxy, it has four uses and all
|
||||
four are generation, and dropping it widens covered checkpoints by roughly two orders of magnitude
|
||||
([the numbers](paid-model-loading-coverage.md#what-changes-in-numbers)).
|
||||
- **`EcosystemCheckpoints` stays.** It is the generator's default model per ecosystem — 62 of the 63
|
||||
checkpoint defaults are covered through it and none through `CoveredCheckpoint`. Removing it would
|
||||
strip the default model from half the supported ecosystems.
|
||||
- **`GenerationBaseModel` stays as the gate.** It marks the base models where the orchestrator has
|
||||
extended checkpoint/diffuser support, i.e. where community models can run.
|
||||
- **Diffusers becomes loadable**; Core ML and ONNX stay excluded.
|
||||
- **File-less models never touch the loader**, and "file-less" means *no loadable file*, not *no file
|
||||
row* — 36 API models carry a `Training Data` archive and would otherwise read as loadable.
|
||||
|
||||
- `m."allowCommercialUse" && ARRAY['RentCivit']` — a **licence** gate
|
||||
- an eligible, scanned `ModelFile` of an accepted type and format
|
||||
- `mv."baseModel" IN (SELECT "baseModel" FROM "GenerationBaseModel")` — a base-model allowlist
|
||||
- and then, **for checkpoints only**, `mv.id IN (SELECT version_id FROM "CoveredCheckpoint")`
|
||||
The licence gate survives all of this: **zero covered versions lack `RentCivit`**, and the purchase
|
||||
path refuses anything not in coverage, which inherits the rule rather than restating it.
|
||||
|
||||
Three things follow, and they change the shape of the work:
|
||||
|
||||
1. 🔴 **The licence gate is a money problem, and nobody raised it.** A model whose creator has not
|
||||
granted `RentCivit` is deliberately not generatable on-site. Taking payment to load such a model
|
||||
into the cluster sells something the licence forbids. Whatever replaces the coverage gate has to
|
||||
keep this one, or the feature has to refuse those models explicitly.
|
||||
2. **LoRAs and checkpoints are not the same job.** LoRA, TextualInversion, VAE, LoCon, DoRA and
|
||||
Upscaler need no `CoveredCheckpoint` membership at all — they are already covered once they pass
|
||||
the licence, scan and base-model gates. They are simply not *resident*, which is exactly the
|
||||
problem paid loading solves. **Checkpoints** are the ones gated behind the curated list. A LoRA-
|
||||
first v1 needs no view change; a checkpoint v1 needs one.
|
||||
3. **`GenerationBaseModel` and `getCanAuctionForGeneration()` already encode which ecosystems can
|
||||
reach the generator.** That is the site-side twin of the orchestrator's `unsupported` status.
|
||||
Whichever one disagrees with the other is a bug users will find by paying for it.
|
||||
**Loading is for checkpoints.** Size is the reason the loader exists and LoRAs do not have it
|
||||
(Justin, 2026-09-08). Earlier drafts of these docs recommended a LoRA-first v1 on the grounds that it
|
||||
needed no view change; that was solving the wrong problem and has been removed.
|
||||
|
||||
## Auctions
|
||||
|
||||
Paid loading replaces auctions *as a way into the cluster*. It does not replace what auctions
|
||||
also do.
|
||||
|
||||
🔴 **The two are mechanically incompatible, not just conceptually.** `CoveredCheckpoint` is
|
||||
populated by [handle-auctions.ts](../../src/server/jobs/handle-auctions.ts) — auction winners plus
|
||||
the top weekly earners — and the same job **deletes every row not in that set** on each cycle. A
|
||||
checkpoint someone paid to load would therefore lose its coverage at the next auction run. Paid
|
||||
loading cannot ship for checkpoints while that job still owns the table.
|
||||
🔴 **The two were mechanically incompatible.** `CoveredCheckpoint` is populated by
|
||||
[handle-auctions.ts](../../src/server/jobs/handle-auctions.ts) — auction winners plus the top weekly
|
||||
earners — and the same job **deletes every row not in that set** on each cycle, so a checkpoint
|
||||
someone paid to load would lose its coverage at the next auction run.
|
||||
|
||||
**Resolved 2026-09-08:** the table stops gating generation entirely, so the conflict goes with it.
|
||||
Whether `handle-auctions.ts` keeps writing rows nothing reads is a cleanup question, not a blocker.
|
||||
|
||||
Auctions have carried double duty since inception: choosing the week's checkpoints **and**
|
||||
promoting content into Featured spaces. That conflation is already a known problem with its own
|
||||
@@ -286,17 +295,16 @@ owner and a closing condition in
|
||||
[paid-model-loading-decisions.md](paid-model-loading-decisions.md) — the register to read before
|
||||
deciding anything.
|
||||
|
||||
1. **"Select any model" is not in any task**, and C6 assumes it is already done. See the coverage
|
||||
section above for what it involves — including the licence gate, which is the part with a
|
||||
refund attached.
|
||||
2. **What happens when a paid load fails or never finishes?** Bandwidth was measured at ~10 KB/s
|
||||
with LoRAs taking four hours, which Koen read as an unstable tunnel into the data centre.
|
||||
Koen: "we got to be prepared for us not giving any hard guarantees about when it's going to be
|
||||
available." A refund path is implied and unscoped.
|
||||
3. **The 48-hour residency exists, but nothing exposes an expiry.** The spine controllers enforce
|
||||
it; no API reports when a given resource's window ends, so there is still no countdown to design.
|
||||
What the surfaces may claim is
|
||||
[1.3](paid-model-loading-decisions.md#13--what-the-surfaces-may-promise-now-that-residency-exists).
|
||||
1. ✅ **"Select any model"** — decided 2026-09-08, and scoped as Phase 1.6 in the checklist. See
|
||||
[coverage](paid-model-loading-coverage.md).
|
||||
2. **A failed load is refunded** (Justin, 2026-09-08) — but *by whom* is unconfirmed. Justin expects
|
||||
the orchestrator to be doing it; nothing has ever exercised that path, because a prepare has
|
||||
never been charged. It is [K3](paid-model-loading-decisions.md#k3-does-the-orchestrator-refund-a-failed-prepare)
|
||||
and it has to be answered with pricing, not after. Bandwidth was measured at ~10 KB/s with LoRAs
|
||||
taking four hours, and `PrepareResourceJob` gives up at 24h, so this is not a rare path.
|
||||
3. **Nothing exposes when a resource's 48h window ends.** The spine controllers enforce residency,
|
||||
but no API reports an expiry, so there is no countdown to design even though we now promise the
|
||||
duration.
|
||||
4. **Cluster capacity is unknown.** Briant's concern in the call: someone queues a pile of small
|
||||
irrelevant checkpoints and starves the popular ones. The answers on record are that popular
|
||||
models stay resident because workers keep them, plus the rate limits, plus Koen's
|
||||
@@ -314,14 +322,33 @@ deciding anything.
|
||||
- Rate limits are site-side, not orchestrator-side.
|
||||
- Pay-to-boost queue position is out for v1.
|
||||
- Load state in search is deferred.
|
||||
- Client keeps in-flight loads in `localStorage`, polls the resource endpoint on refresh, and
|
||||
resubscribes if still downloading.
|
||||
- A browser keeps what it is **watching** in `localStorage` and drains that queue on every page
|
||||
load — finished loads raise a toast that must be dismissed, then leave; items that can no longer
|
||||
finish leave; the rest stay subscribed. Items expire at 48h so every one has an exit.
|
||||
- The durable record of a **purchased** load is the orchestrator's, not ours: workflows tagged
|
||||
`resource-load`, queried with the buyer's token. Not `localStorage`, not Redis.
|
||||
- Progress signals go to the **buyer's own channel**, never to a model-version group — the payload
|
||||
carries `workflowId`, which names the paying user.
|
||||
- Bystanders do not get live progress. They get told when the load is ready, on their next visit.
|
||||
- Reaching someone who does not come back — real API-level notifications — is a **Phase 2** goal.
|
||||
A different device or a cleared browser getting nothing is accepted.
|
||||
- Progress shows in three places: navbar, model version page (below Create), and the generator for
|
||||
the selected resource.
|
||||
- A bystander on the model page can subscribe to someone else's in-flight load and get the
|
||||
notification. Load state and the queue are therefore **public reads** — everyone sees them, not
|
||||
only the buyer.
|
||||
- A bystander on the model page can subscribe to someone else's in-flight load and be told when it
|
||||
is ready. Load state and the queue are **public reads** — everyone sees them, not only the buyer.
|
||||
- `PinModelJob` is legacy and is not part of this feature (Koen, 2026-09-04).
|
||||
- The surfaces promise the **48 hours as pitched**. A copy may move between spine controllers inside
|
||||
the window; availability does not lapse (Justin, 2026-09-08).
|
||||
- **Loading is for checkpoints.** LoRAs are not large enough to need it.
|
||||
- **`CoveredCheckpoint` stops gating generation**; `EcosystemCheckpoints` and `GenerationBaseModel`
|
||||
stay. Coverage means *allowed to generate*; residency is the orchestrator's axis.
|
||||
- **Only base models in `GenerationBaseModel` are loadable.** Everything else is out of scope for v1.
|
||||
- A checkpoint needs a **correct model file** to be loadable; file-less API models never are.
|
||||
- A load that never finishes is **refunded**.
|
||||
- The purchase path refuses anything **not in `GenerationCoverageNext`** (composed with ecosystem
|
||||
type support by `isGenerationEligible`), which is how the `RentCivit` rule is enforced without
|
||||
restating it. Gating on the live view would refuse every load worth making, since it still requires
|
||||
`CoveredCheckpoint`.
|
||||
- The daily cap must also cover the implicit path — a generation submitted against a non-resident
|
||||
resource — or it is decorative. Same quota, not a second one.
|
||||
- Free tier gets 0 per day at launch.
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@
|
||||
"test:packages:run": "vitest run --project '@civitai/*'",
|
||||
"test:apps": "vitest --project 'app:*'",
|
||||
"test:apps:run": "vitest run --project 'app:*'",
|
||||
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts",
|
||||
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-can-generate-derivation.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts",
|
||||
"test:component": "node scripts/test-component-run.mjs",
|
||||
"test:component:watch": "vitest --project component",
|
||||
"test:geometry": "vitest run --project geometry",
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
-- GenerationCoverageNext — the paid-model-loading coverage rule, staged alongside the live view.
|
||||
--
|
||||
-- 🔴 NOT named "GenerationCoverage2": that view ALREADY EXISTS in production. It is a stale earlier
|
||||
-- experiment — hardcoded base-model list instead of the GenerationBaseModel table, a wider licence
|
||||
-- array (RentCivit/Rent/Sell), still carrying CoveredCheckpoint, no ExternalGeneration branch, an
|
||||
-- older file-type list. Nothing in this repo reads it and no database object depends on it, but
|
||||
-- CREATE OR REPLACE on that name would have silently overwritten it. Whether to drop it is a
|
||||
-- separate question for whoever left it there.
|
||||
--
|
||||
-- Read by the paid-load path only: resource-load.service's eligibility gate, and the mini
|
||||
-- model-version endpoint the orchestrator reads. Every other caller still reads
|
||||
-- `GenerationCoverage`, which is untouched.
|
||||
--
|
||||
-- Deliberately NOT added to schema.full.prisma. The cutover plan is to replace
|
||||
-- `GenerationCoverage`'s own definition with this body and drop this view, at which point the
|
||||
-- Prisma model needs no change at all. Adding a second mapped model would create client churn that
|
||||
-- the cutover then has to undo.
|
||||
--
|
||||
-- Two changes from `GenerationCoverage`, and only two:
|
||||
--
|
||||
-- 1. The checkpoint branch no longer requires membership in `CoveredCheckpoint`. That table is
|
||||
-- the weekly auction's residency proxy — it is written and pruned by handle-auctions.ts, so a
|
||||
-- checkpoint someone paid to load would lose coverage at the next auction run. Paid loading
|
||||
-- replaces it. Measured 2026-09-08: 638 -> 33,796 covered checkpoints.
|
||||
--
|
||||
-- 2. 'Diffusers' is removed from the excluded file formats. Confirmed loadable by the
|
||||
-- orchestrator (Justin, 2026-09-08). ~675 added rows across all types. 'Core ML' and 'ONNX'
|
||||
-- stay excluded — they are inference-runtime formats, not servable weights.
|
||||
--
|
||||
-- Deliberately UNCHANGED:
|
||||
--
|
||||
-- * The `EcosystemCheckpoints` branch. It is not a loophole: it is the generator's default model
|
||||
-- per ecosystem, and 62 of the 63 checkpoint defaults are covered through it while ZERO are
|
||||
-- covered through `CoveredCheckpoint`. Removing it would strip the default model from half the
|
||||
-- supported ecosystems.
|
||||
-- * The `usageControl = 'ExternalGeneration'` branch, which covers file-less API models. There is
|
||||
-- nothing to download for these, so they are covered but must never be offered a paid load.
|
||||
-- * `baseModel IN GenerationBaseModel` — the base models where the orchestrator has extended
|
||||
-- checkpoint/diffuser support. Loading supports only these (Justin, 2026-09-08).
|
||||
-- * The `RentCivit` licence gate. Zero covered versions lack it today; the purchase path refuses
|
||||
-- anything not in coverage, which inherits this rule rather than restating it.
|
||||
--
|
||||
-- ⚠️ AFTER THE CUTOVER, `covered` MEANS SOMETHING DIFFERENT. Today, for checkpoints, it effectively
|
||||
-- means "resident in the cluster" because the auction put them there. Here it means only "allowed
|
||||
-- to generate"; residency becomes a separate axis reported by the orchestrator. Every existing
|
||||
-- reader that treats `covered` as "will generate right now" is correct today and wrong after.
|
||||
-- That audit must happen before anything is repointed.
|
||||
--
|
||||
-- See docs/features/paid-model-loading-coverage.md.
|
||||
|
||||
CREATE OR REPLACE VIEW "GenerationCoverageNext" AS
|
||||
SELECT
|
||||
m.id AS "modelId",
|
||||
mv.id AS "modelVersionId",
|
||||
true AS covered
|
||||
FROM "ModelVersion" mv
|
||||
JOIN "Model" m ON m.id = mv."modelId"
|
||||
WHERE
|
||||
-- Branch 1: the generator's per-ecosystem default models.
|
||||
mv.id IN (SELECT "EcosystemCheckpoints".id FROM "EcosystemCheckpoints")
|
||||
|
||||
-- Branch 2: file-less external/API generation. Covered, never loadable.
|
||||
OR (
|
||||
mv."usageControl" = 'ExternalGeneration'::"ModelUsageControl"
|
||||
AND mv.status = 'Published'::"ModelStatus"
|
||||
AND NOT m.poi
|
||||
)
|
||||
|
||||
-- Branch 3: the ordinary path — licensed, scanned, on a supported base model.
|
||||
OR (
|
||||
NOT m.poi
|
||||
AND (
|
||||
mv.status = 'Published'::"ModelStatus"
|
||||
OR m.availability = 'Private'::"Availability"
|
||||
OR m."uploadType" = 'Trained'::"ModelUploadType"
|
||||
)
|
||||
AND m."allowCommercialUse" && ARRAY['RentCivit'::"CommercialUse"]
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "ModelFile" mf
|
||||
WHERE mf."modelVersionId" = mv.id
|
||||
AND (
|
||||
(
|
||||
mf."scannedAt" IS NOT NULL
|
||||
AND mf.type = ANY (ARRAY['Model'::text, 'Pruned Model'::text, 'Diffusion Model'::text, 'UNet'::text, 'Negative'::text, 'VAE'::text])
|
||||
-- CHANGE 2: 'Diffusers' removed from this exclusion list.
|
||||
AND COALESCE(mf.metadata ->> 'format'::text, ''::text) <> ALL (ARRAY['Core ML'::text, 'ONNX'::text])
|
||||
)
|
||||
OR (mf.metadata -> 'trainingResults'::text) IS NOT NULL
|
||||
)
|
||||
)
|
||||
AND (
|
||||
mv."baseModel" IN (SELECT "GenerationBaseModel"."baseModel" FROM "GenerationBaseModel")
|
||||
OR m.type = 'Upscaler'::"ModelType"
|
||||
)
|
||||
AND (
|
||||
-- CHANGE 1: the `AND mv.id IN (SELECT version_id FROM "CoveredCheckpoint")` conjunct that
|
||||
-- used to sit here is gone. Any standard checkpoint on a supported base model now qualifies.
|
||||
(m.type = 'Checkpoint'::"ModelType" AND mv."baseModelType" = 'Standard')
|
||||
OR m.type = ANY (ARRAY['LORA'::"ModelType", 'TextualInversion'::"ModelType", 'VAE'::"ModelType", 'LoCon'::"ModelType", 'DoRA'::"ModelType"])
|
||||
OR m.type = 'Upscaler'::"ModelType"
|
||||
)
|
||||
);
|
||||
@@ -7,6 +7,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./basemodel.constants": "./src/basemodel.constants.ts",
|
||||
"./generation-eligibility": "./src/generation-eligibility.ts",
|
||||
"./clickhouse-ip-filters": "./src/clickhouse-ip-filters.ts",
|
||||
"./lazy": "./src/lazy.ts",
|
||||
"./type-guards": "./src/type-guards.ts",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { isBaseModelGenerationSupported } from './basemodel.constants';
|
||||
import { isGenerationDisabled } from './model-version-flags.constants';
|
||||
import type { ModelType } from '@civitai/db-schema/enums';
|
||||
|
||||
/**
|
||||
* Whether a model version may generate: covered in the database, not generation-disabled, and the
|
||||
* ecosystem supports this MODEL TYPE.
|
||||
*
|
||||
* `covered` alone over-reports — the view's type branch is one flat list applied to every base
|
||||
* model. Neither half can answer alone, so this must be the only place they are composed;
|
||||
* `no-divergent-can-generate-derivation` keeps `isBaseModelGenerationSupported` out of `src/` and
|
||||
* carries the measurement.
|
||||
*/
|
||||
export function isGenerationEligible({
|
||||
covered,
|
||||
baseModel,
|
||||
modelType,
|
||||
flags,
|
||||
}: {
|
||||
covered: boolean | null | undefined;
|
||||
baseModel: string;
|
||||
modelType: ModelType;
|
||||
/** ModelVersion.flags — carries the GenerationDisabled bit. */
|
||||
flags: number;
|
||||
}): boolean {
|
||||
return (
|
||||
!!covered &&
|
||||
!isGenerationDisabled(flags) &&
|
||||
isBaseModelGenerationSupported(baseModel, modelType)
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Logo } from '~/components/Logo/Logo';
|
||||
import { ImpersonateButton } from '~/components/Moderation/ImpersonateButton';
|
||||
import { ModerationNav } from '~/components/Moderation/ModerationNav';
|
||||
import { NotificationBell } from '~/components/Notifications/NotificationBell';
|
||||
import { ResourceLoadDrain } from '~/components/ResourceLoad/resource-load.utils';
|
||||
import { UploadTracker } from '~/components/Resource/UploadTracker';
|
||||
import { SupportButton } from '~/components/SupportButton/SupportButton';
|
||||
import { useCurrentUser } from '~/hooks/useCurrentUser';
|
||||
@@ -105,6 +106,7 @@ export function AppHeader({ renderSearchComponent = defaultRenderSearchComponent
|
||||
<CivitaiLinkPopover />
|
||||
</>
|
||||
)}
|
||||
{currentUser && features.resourceLoad && <ResourceLoadDrain />}
|
||||
{currentUser && features.canViewNsfw && <BrowsingModeIcon />}
|
||||
{currentUser && <NotificationBell />}
|
||||
{currentUser && showChat && <ChatButton />}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { Air } from '@civitai/client';
|
||||
import { toResourceLoadProgress } from '~/components/ResourceLoad/resource-load.utils';
|
||||
|
||||
/**
|
||||
* The global `@civitai/client` stub has no `Air.parseSafe`, so without a real one every payload here
|
||||
* would be dropped and every assertion below would pass for the wrong reason.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
(Air as unknown as Record<string, unknown>).parseSafe = (identifier: string) => {
|
||||
const match = /^urn:air:([^:]+):([^:]+):([^:]+):(\d+)@(\d+)$/.exec(identifier);
|
||||
if (!match) return null;
|
||||
const [, ecosystem, type, source, id, version] = match;
|
||||
return { ecosystem, type, source, id, version };
|
||||
};
|
||||
});
|
||||
|
||||
const preparing = (air: string, extra: Record<string, unknown> = {}) => ({
|
||||
workflowId: '7-123',
|
||||
name: 'prepare-resource',
|
||||
status: 'preparing',
|
||||
preparation: { resource: air, queuePosition: 0, progress: 0.5, etaSeconds: 120, ...extra },
|
||||
});
|
||||
|
||||
describe('toResourceLoadProgress', () => {
|
||||
it('takes the version id from the payload AIR, not from the fact that it arrived', () => {
|
||||
// Every load this user has in flight arrives on the same per-user channel, so attributing by
|
||||
// delivery would paint one model's progress onto another.
|
||||
const update = toResourceLoadProgress(preparing('urn:air:sdxl:checkpoint:civitai:42@999'));
|
||||
|
||||
expect(update?.modelVersionId).toBe(999);
|
||||
});
|
||||
|
||||
it('carries queue position, progress and eta through', () => {
|
||||
const update = toResourceLoadProgress(preparing('urn:air:sdxl:checkpoint:civitai:42@501'));
|
||||
|
||||
expect(update).toMatchObject({
|
||||
modelVersionId: 501,
|
||||
queuePosition: 0,
|
||||
progress: 0.5,
|
||||
etaSeconds: 120,
|
||||
workflowId: '7-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalises a still-queued download to null progress rather than zero', () => {
|
||||
// Null and 0.0 mean different things: "not started" vs "started, nothing transferred". A bar
|
||||
// rendered from 0 looks identical to one rendered from null, so the distinction has to survive.
|
||||
const update = toResourceLoadProgress(
|
||||
preparing('urn:air:sdxl:checkpoint:civitai:42@501', { progress: null, queuePosition: 3 })
|
||||
);
|
||||
|
||||
expect(update?.progress).toBeNull();
|
||||
expect(update?.queuePosition).toBe(3);
|
||||
});
|
||||
|
||||
it('ignores a step event with no preparation — most statuses have none', () => {
|
||||
expect(
|
||||
toResourceLoadProgress({ workflowId: '7-123', name: 'prepare-resource', status: 'succeeded' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores an AIR that does not resolve to a version', () => {
|
||||
expect(toResourceLoadProgress(preparing('not-an-air'))).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a payload of an entirely different shape', () => {
|
||||
expect(toResourceLoadProgress({ hello: 'world' })).toBeNull();
|
||||
expect(toResourceLoadProgress(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSignalConnection } from '~/components/Signals/SignalsProvider';
|
||||
import { SignalMessages } from '~/server/common/enums';
|
||||
import { resourceLoadSignalSchema } from '~/server/schema/resource-load.schema';
|
||||
import { parseAIRSafe } from '~/shared/utils/air';
|
||||
import { resourceLoadDrainVerdict, useResourceLoadStore } from '~/store/resource-load.store';
|
||||
import { showSuccessNotification } from '~/utils/notifications';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
export type ResourceLoadProgress = {
|
||||
modelVersionId: number;
|
||||
/** Downloads ahead of this one. Zero means it is transferring now. */
|
||||
queuePosition: number;
|
||||
/** 0..1, or null while the download is still queued. */
|
||||
progress: number | null;
|
||||
etaSeconds: number | null;
|
||||
workflowId: string | null;
|
||||
receivedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One `resource-load:update` payload → progress for the version its AIR names, or null if it says
|
||||
* nothing about a download.
|
||||
*/
|
||||
export function toResourceLoadProgress(raw: unknown): ResourceLoadProgress | null {
|
||||
const parsed = resourceLoadSignalSchema.safeParse(raw);
|
||||
if (!parsed.success || !parsed.data.preparation) return null;
|
||||
|
||||
const { resource, queuePosition, progress, etaSeconds } = parsed.data.preparation;
|
||||
const air = parseAIRSafe(resource);
|
||||
if (!air?.version) return null;
|
||||
|
||||
return {
|
||||
modelVersionId: air.version,
|
||||
queuePosition,
|
||||
progress: progress ?? null,
|
||||
etaSeconds: etaSeconds ?? null,
|
||||
workflowId: parsed.data.workflowId ?? null,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live download progress for this user's own loads, keyed by model version id.
|
||||
*
|
||||
* 🔴 The version id comes from the payload's AIR. Every load this user has in flight arrives on the
|
||||
* same per-user channel, so a card that assumed "an update arrived, therefore it is mine" would show
|
||||
* one model's progress on another as soon as two loads run at once.
|
||||
*
|
||||
* Payloads that do not parse, or that carry no `preparation`, are dropped: the orchestrator posts
|
||||
* an event for every step transition and only a `preparing` one describes a download.
|
||||
*/
|
||||
export function useResourceLoadProgress() {
|
||||
const [progress, setProgress] = useState<Record<number, ResourceLoadProgress>>({});
|
||||
|
||||
useSignalConnection(
|
||||
SignalMessages.ResourceLoadUpdate,
|
||||
useCallback((raw: unknown) => {
|
||||
const update = toResourceLoadProgress(raw);
|
||||
if (!update) return;
|
||||
setProgress((current) => ({ ...current, [update.modelVersionId]: update }));
|
||||
}, [])
|
||||
);
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the tracked-loads queue once per page load: tell the user what finished, drop what can no
|
||||
* longer finish, and keep what is still in flight.
|
||||
*
|
||||
* Runs once on mount rather than on an interval. While the page is open, live progress arrives on
|
||||
* the signals channel; this exists for the gap where the browser was closed, which is precisely when
|
||||
* a poll cannot run.
|
||||
*/
|
||||
export function useDrainTrackedResourceLoads() {
|
||||
const tracked = useResourceLoadStore((s) => s.tracked);
|
||||
const untrack = useResourceLoadStore((s) => s.untrack);
|
||||
const drained = useRef(false);
|
||||
|
||||
const ids = tracked.map((x) => x.modelVersionId);
|
||||
const { data: states } = trpc.resourceLoad.getState.useQuery(
|
||||
{ modelVersionIds: ids },
|
||||
{ enabled: ids.length > 0 }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (drained.current || !states) return;
|
||||
drained.current = true;
|
||||
|
||||
const byId = new Map(states.map((s) => [s.modelVersionId, s]));
|
||||
for (const item of tracked) {
|
||||
const verdict = resourceLoadDrainVerdict(item, byId.get(item.modelVersionId));
|
||||
if (verdict.action === 'keep') continue;
|
||||
|
||||
if (verdict.action === 'complete') {
|
||||
showSuccessNotification({
|
||||
title: 'Model loaded',
|
||||
message: `${item.modelName} — ${item.name} is ready to generate with.`,
|
||||
// Requires acknowledgement: the whole point is that it survives being away from the
|
||||
// screen, so it must not vanish before it is seen.
|
||||
autoClose: false,
|
||||
});
|
||||
}
|
||||
untrack(item.modelVersionId);
|
||||
}
|
||||
// `tracked` is intentionally not a dependency — draining mutates it, and re-running on that
|
||||
// change would reconsider items this pass already decided.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [states, untrack]);
|
||||
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once, app-wide, so a finished load is reported wherever the user lands next — not only on
|
||||
* the page they started it from.
|
||||
*/
|
||||
export function ResourceLoadDrain() {
|
||||
useDrainTrackedResourceLoads();
|
||||
return null;
|
||||
}
|
||||
@@ -145,7 +145,17 @@ export default MixedAuthEndpoint(async function handler(
|
||||
(m."availability" = 'Private')
|
||||
|
||||
) AS "checkPermission",
|
||||
(SELECT covered FROM "GenerationCoverage" WHERE "modelVersionId" = mv.id) AS "covered",
|
||||
-- 🔴 GenerationCoverageNext, not GenerationCoverage. This endpoint is what the ORCHESTRATOR
|
||||
-- reads to decide whether a resource can generate, and prepareResource refuses anything it
|
||||
-- believes cannot -- so on the live view paid model loading cannot load the very checkpoints
|
||||
-- it exists for. Verified 2026-09-08 on version 3040959: covered by the new view, not by the
|
||||
-- live one, and the estimate failed with the orchestrator's "not enabled for generation".
|
||||
--
|
||||
-- No site code calls this endpoint, and the site's own generator gates on
|
||||
-- GenerationCoverage through resource-data/generation.service -- so this widens what the
|
||||
-- orchestrator will accept without changing what a user sees on the site.
|
||||
-- See docs/features/paid-model-loading-coverage.md.
|
||||
(SELECT covered FROM "GenerationCoverageNext" WHERE "modelVersionId" = mv.id) AS "covered",
|
||||
mv."meta"->'generationAlias' AS "generationAlias",
|
||||
(
|
||||
CASE
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useState } from 'react';
|
||||
import { Page } from '~/components/AppLayout/Page';
|
||||
import { Meta } from '~/components/Meta/Meta';
|
||||
import { NextLink } from '~/components/NextLink/NextLink';
|
||||
import { useResourceLoadProgress } from '~/components/ResourceLoad/resource-load.utils';
|
||||
import { useResourceLoadStore } from '~/store/resource-load.store';
|
||||
import type { TrackedResourceLoad } from '~/store/resource-load.store';
|
||||
import type { ResourceLoadProgress } from '~/components/ResourceLoad/resource-load.utils';
|
||||
import type { ResourceLoadAvailability } from '~/server/schema/resource-load.schema';
|
||||
import { createServerSideProps } from '~/server/utils/server-side-helpers';
|
||||
import { formatBytes } from '~/utils/number-helpers';
|
||||
@@ -50,10 +54,64 @@ function AvailabilityBadge({ availability }: { availability: ResourceLoadAvailab
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitCard() {
|
||||
/** Why a version cannot be loaded, in the order the server refuses. */
|
||||
function loadBlockedReason(state: {
|
||||
eligible: boolean;
|
||||
loadable: boolean;
|
||||
availability: ResourceLoadAvailability;
|
||||
}) {
|
||||
if (!state.eligible)
|
||||
return 'Not generatable on the site — coverage or the ecosystem does not support this model type.';
|
||||
if (!state.loadable) return 'No weight file — this runs through an external provider.';
|
||||
if (state.availability.status === 'unsupported') return 'The cluster cannot host this resource.';
|
||||
if (state.availability.status === 'unknown')
|
||||
return 'Could not read status from the orchestrator.';
|
||||
if (state.availability.status === 'available')
|
||||
return 'Already loaded and ready to generate with.';
|
||||
return null;
|
||||
}
|
||||
|
||||
function LiveProgress({ live }: { live: ResourceLoadProgress }) {
|
||||
const pct = live.progress != null ? Math.round(live.progress * 100) : null;
|
||||
const eta = live.etaSeconds != null ? ` · ~${Math.round(live.etaSeconds / 60)}m left` : '';
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Progress value={pct ?? 0} animated={pct != null} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{live.queuePosition > 0
|
||||
? `${live.queuePosition} download${live.queuePosition === 1 ? '' : 's'} ahead`
|
||||
: pct != null
|
||||
? `Downloading — ${pct}%${eta}`
|
||||
: 'Transferring…'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitCard({
|
||||
progress,
|
||||
onWatch,
|
||||
}: {
|
||||
progress: Record<number, ResourceLoadProgress>;
|
||||
onWatch: (item: {
|
||||
modelVersionId: number;
|
||||
modelId: number;
|
||||
name: string;
|
||||
modelName: string;
|
||||
kind: 'requested' | 'watching';
|
||||
}) => void;
|
||||
}) {
|
||||
const [modelVersionId, setModelVersionId] = useState<number | undefined>();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: states, isFetching: isLooking } = trpc.resourceLoad.getState.useQuery(
|
||||
{ modelVersionIds: [modelVersionId ?? 0] },
|
||||
{ enabled: !!modelVersionId }
|
||||
);
|
||||
const state = states?.[0];
|
||||
const blocked = state ? loadBlockedReason(state) : null;
|
||||
const live = state ? progress[state.modelVersionId] : undefined;
|
||||
|
||||
const estimate = trpc.resourceLoad.estimate.useMutation({
|
||||
onError: (error) =>
|
||||
showErrorNotification({ title: 'Could not estimate', error: new Error(error.message) }),
|
||||
@@ -67,7 +125,15 @@ function SubmitCard() {
|
||||
})`,
|
||||
});
|
||||
estimate.reset();
|
||||
onWatch({
|
||||
modelVersionId: result.modelVersionId,
|
||||
modelId: result.modelId,
|
||||
name: result.name,
|
||||
modelName: result.modelName,
|
||||
kind: 'requested',
|
||||
});
|
||||
utils.resourceLoad.getQueue.invalidate();
|
||||
utils.resourceLoad.getState.invalidate();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorNotification({ title: 'Could not submit', error: new Error(error.message) }),
|
||||
@@ -78,7 +144,7 @@ function SubmitCard() {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Title order={4}>Submit a load</Title>
|
||||
<Title order={4}>Request a load</Title>
|
||||
<Group align="flex-end">
|
||||
<NumberInput
|
||||
label="Model version id"
|
||||
@@ -93,53 +159,79 @@ function SubmitCard() {
|
||||
/>
|
||||
<Button
|
||||
onClick={() => modelVersionId && estimate.mutate({ modelVersionId })}
|
||||
disabled={!modelVersionId}
|
||||
disabled={!state || !!blocked}
|
||||
loading={estimate.isPending}
|
||||
>
|
||||
Get estimate
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{quote && (
|
||||
{isLooking && <Loader size="sm" />}
|
||||
|
||||
{!isLooking && modelVersionId && !state && (
|
||||
<Text c="dimmed" size="sm">
|
||||
No model version with id {modelVersionId}.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state && (
|
||||
<Card withBorder padding="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>
|
||||
<NextLink
|
||||
href={`/models/${quote.modelId}?modelVersionId=${quote.modelVersionId}`}
|
||||
href={`/models/${state.modelId}?modelVersionId=${state.modelVersionId}`}
|
||||
target="_blank"
|
||||
>
|
||||
{quote.modelName} — {quote.name}
|
||||
{state.modelName} — {state.name}
|
||||
</NextLink>
|
||||
</Text>
|
||||
<AvailabilityBadge availability={quote.availability} />
|
||||
<AvailabilityBadge availability={state.availability} />
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ wordBreak: 'break-all' }}>
|
||||
{quote.air}
|
||||
{state.air}
|
||||
</Text>
|
||||
<Group gap="xl">
|
||||
<Text size="sm">
|
||||
Size: {quote.size != null ? formatBytes(quote.size) : 'unknown'}
|
||||
Size: {state.size != null ? formatBytes(state.size) : 'unknown'}
|
||||
</Text>
|
||||
<Text size="sm">Cost: {quote.cost} Buzz</Text>
|
||||
<Badge color={state.eligible ? 'green' : 'red'} variant="light">
|
||||
{state.eligible ? 'generatable' : 'not generatable'}
|
||||
</Badge>
|
||||
<Badge color={state.loadable ? 'green' : 'red'} variant="light">
|
||||
{state.loadable ? 'has weights' : 'no weight file'}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{!quote.priced && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
The orchestrator quoted zero. Prepare steps are not priced yet (C2), so this is
|
||||
not a price — submitting loads the model for free.
|
||||
{live && <LiveProgress live={live} />}
|
||||
|
||||
{blocked && (
|
||||
<Alert color="gray" icon={<IconAlertTriangle size={16} />}>
|
||||
{blocked}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => submit.mutate({ modelVersionId: quote.modelVersionId })}
|
||||
loading={submit.isPending}
|
||||
>
|
||||
Submit load
|
||||
</Button>
|
||||
</Group>
|
||||
{quote && quote.modelVersionId === state.modelVersionId && (
|
||||
<>
|
||||
<Text size="sm">Cost: {quote.cost} Buzz</Text>
|
||||
{!quote.priced && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
The orchestrator quoted zero. Prepare steps are not priced yet (C2), so this
|
||||
is not a price — submitting loads the model for free.
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => submit.mutate({ modelVersionId: quote.modelVersionId })}
|
||||
loading={submit.isPending}
|
||||
disabled={!!blocked}
|
||||
>
|
||||
Submit load
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
@@ -148,7 +240,52 @@ function SubmitCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function QueueCard() {
|
||||
function TrackedCard({
|
||||
tracked,
|
||||
progress,
|
||||
}: {
|
||||
tracked: TrackedResourceLoad[];
|
||||
progress: Record<number, ResourceLoadProgress>;
|
||||
}) {
|
||||
const untrack = useResourceLoadStore((s) => s.untrack);
|
||||
if (!tracked.length) return null;
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Title order={4}>Waiting on ({tracked.length})</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Kept in this browser and drained on every page load — finished loads are reported once and
|
||||
removed. The durable record of a purchased load is the orchestrator's.
|
||||
</Text>
|
||||
{tracked.map((item) => (
|
||||
<Group key={item.modelVersionId} justify="space-between" align="flex-start">
|
||||
<Stack gap={2} className="flex-1">
|
||||
<NextLink
|
||||
href={`/models/${item.modelId}?modelVersionId=${item.modelVersionId}`}
|
||||
target="_blank"
|
||||
>
|
||||
{item.modelName} — {item.name}
|
||||
</NextLink>
|
||||
{progress[item.modelVersionId] ? (
|
||||
<LiveProgress live={progress[item.modelVersionId]} />
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Requested {new Date(item.requestedAt).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Button variant="subtle" size="compact-sm" onClick={() => untrack(item.modelVersionId)}>
|
||||
Stop watching
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueCard({ progress }: { progress: Record<number, ResourceLoadProgress> }) {
|
||||
const { data, isLoading, isRefetching, refetch } = trpc.resourceLoad.getQueue.useQuery(
|
||||
{ take: 50 },
|
||||
{ refetchInterval: QUEUE_POLL_MS }
|
||||
@@ -207,10 +344,12 @@ function QueueCard() {
|
||||
<Table.Td>
|
||||
<AvailabilityBadge availability={item.availability} />
|
||||
</Table.Td>
|
||||
<Table.Td width={160}>
|
||||
{item.availability.status === 'loading' && (
|
||||
<Table.Td width={200}>
|
||||
{progress[item.modelVersionId] ? (
|
||||
<LiveProgress live={progress[item.modelVersionId]} />
|
||||
) : item.availability.status === 'loading' ? (
|
||||
<Progress value={item.availability.progress * 100} />
|
||||
)}
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -223,6 +362,11 @@ function QueueCard() {
|
||||
}
|
||||
|
||||
function ResourceLoadTestPage() {
|
||||
const progress = useResourceLoadProgress();
|
||||
// Drain is app-wide (ResourceLoadDrain, in the header); calling it here too would double-drain.
|
||||
const tracked = useResourceLoadStore((s) => s.tracked);
|
||||
const track = useResourceLoadStore((s) => s.track);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Resource Loading" deIndex />
|
||||
@@ -234,8 +378,9 @@ function ResourceLoadTestPage() {
|
||||
Submit a model version to the generation cluster and watch what is loading or queued.
|
||||
</Text>
|
||||
</Stack>
|
||||
<SubmitCard />
|
||||
<QueueCard />
|
||||
<SubmitCard progress={progress} onWatch={track} />
|
||||
<TrackedCard tracked={tracked} progress={progress} />
|
||||
<QueueCard progress={progress} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowCallback } from '@civitai/client';
|
||||
import { env } from '~/env/server';
|
||||
import { SignalMessages, SignalTopic } from '~/server/common/enums';
|
||||
import { SignalMessages } from '~/server/common/enums';
|
||||
import { withSignals } from '~/server/signals/wrapper';
|
||||
|
||||
export function getOrchestratorCallbacks(userId: number): Array<WorkflowCallback> | undefined {
|
||||
@@ -26,15 +26,19 @@ export function getWorkflowCallbacks(userId: number): Array<WorkflowCallback> |
|
||||
];
|
||||
}
|
||||
|
||||
/** `step:*` because the orchestrator's callback-type enum has no `step:preparing` — the wildcard is
|
||||
* the only way to receive a download's progress. */
|
||||
export function getResourceLoadCallbacks(
|
||||
modelVersionId: number
|
||||
): Array<WorkflowCallback> | undefined {
|
||||
/**
|
||||
* Progress goes to the buyer's user channel, not a model-version group: a `WorkflowStepEvent`
|
||||
* carries `workflowId`, which the orchestrator names `<userId>-<timestamp>` (see
|
||||
* `workflowOwnerId`), so a broadcast would tell every watcher who paid. Showing a load to
|
||||
* bystanders needs a server-side hop to strip identity first.
|
||||
*
|
||||
* `step:*` because the orchestrator's callback-type enum has no `step:preparing`.
|
||||
*/
|
||||
export function getResourceLoadCallbacks(userId: number): Array<WorkflowCallback> | undefined {
|
||||
if (!env.SIGNALS_ENDPOINT) return;
|
||||
return [
|
||||
{
|
||||
url: `${env.SIGNALS_ENDPOINT}/groups/${SignalTopic.ModelVersion}:${modelVersionId}/signals/${SignalMessages.ResourceLoadUpdate}`,
|
||||
url: `${env.SIGNALS_ENDPOINT}/users/${userId}/signals/${SignalMessages.ResourceLoadUpdate}`,
|
||||
type: ['step:*'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SessionUser } from '~/types/session';
|
||||
import {
|
||||
RESOURCE_LOAD_HOURLY_LIMIT,
|
||||
assertCanRequestLoad,
|
||||
resourceLoadRateLimits,
|
||||
} from '~/server/routers/resource-load.router';
|
||||
import { CacheTTL } from '~/server/common/constants';
|
||||
import { userTiers } from '~/server/services/feature-flags.service';
|
||||
|
||||
const user = (overrides: Partial<SessionUser> = {}) =>
|
||||
({ id: 7, tier: 'bronze', isModerator: false, ...overrides } as SessionUser);
|
||||
|
||||
/** The gate, not the quota — see `assertCanRequestLoad` for why the limiter cannot stand in for it. */
|
||||
describe('assertCanRequestLoad', () => {
|
||||
it.each(['bronze', 'silver', 'gold', 'founder'] as const)('allows a %s member', (tier) => {
|
||||
expect(() => assertCanRequestLoad(user({ tier }))).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows a moderator whatever their tier', () => {
|
||||
expect(() => assertCanRequestLoad(user({ tier: 'free', isModerator: true }))).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a free account', () => {
|
||||
expect(() => assertCanRequestLoad(user({ tier: 'free' }))).toThrow(/member benefit/);
|
||||
});
|
||||
|
||||
it('refuses an account with no tier at all', () => {
|
||||
// A session that predates tiers, or one whose subscription lookup failed, must fail CLOSED.
|
||||
expect(() => assertCanRequestLoad(user({ tier: undefined }))).toThrow(/member benefit/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 🔴 A tier that matches NO row gets no limit at all — not the strictest one. `rateLimit()` collects
|
||||
* matching rules into `validLimits` and then loops over them; an empty list means the loop never
|
||||
* runs and `canProceed` stays true. So "every tier matches something, in every window" is the
|
||||
* property, and it is one an added tier or a deleted unconditional row silently breaks.
|
||||
*/
|
||||
describe('resourceLoadRateLimits', () => {
|
||||
const matching = (tier: string) =>
|
||||
resourceLoadRateLimits.filter((r) => !('userReq' in r) || r.userReq?.({ tier }));
|
||||
|
||||
it.each([...userTiers])('a %s user matches a rule in every window', (tier) => {
|
||||
const periods = new Set(matching(tier).map((r) => r.period));
|
||||
|
||||
expect(periods.has(CacheTTL.day), `${tier} has no daily limit`).toBe(true);
|
||||
expect(periods.has(CacheTTL.hour), `${tier} has no hourly limit`).toBe(true);
|
||||
});
|
||||
|
||||
it('caps bursts for every tier, including the most generous', () => {
|
||||
// The hourly row is the cluster's, not the plan's: no tier may buy its way out of it.
|
||||
for (const tier of userTiers) {
|
||||
const hourly = matching(tier).filter((r) => r.period === CacheTTL.hour);
|
||||
expect(hourly.map((r) => r.limit)).toEqual([RESOURCE_LOAD_HOURLY_LIMIT]);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a free account outright on the daily window', () => {
|
||||
const daily = matching('free').filter((r) => r.period === CacheTTL.day);
|
||||
|
||||
// `limit: 0` short-circuits before the off-by-one comparison, so free means exactly zero.
|
||||
expect(Math.max(...daily.map((r) => r.limit))).toBe(0);
|
||||
});
|
||||
|
||||
it('gives paying tiers a daily allowance above zero', () => {
|
||||
for (const tier of ['bronze', 'silver', 'gold', 'founder'] as const) {
|
||||
const daily = matching(tier).filter((r) => r.period === CacheTTL.day);
|
||||
expect(Math.max(...daily.map((r) => r.limit)), `${tier} daily`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,29 +20,69 @@ import {
|
||||
router,
|
||||
} from '~/server/trpc';
|
||||
import { getAllowedAccountTypes } from '~/server/utils/buzz-helpers';
|
||||
import { throwAuthorizationError } from '~/server/utils/errorHandling';
|
||||
import type { SessionUser } from '~/types/session';
|
||||
|
||||
/**
|
||||
* The unconditional row is required, not stylistic: a tier matching no row gets NO limit at all —
|
||||
* `validLimits` comes out empty and the check loop never runs. `userTiers` is
|
||||
* Loading a model is a member benefit. Same derivation the generation form uses
|
||||
* (`status.tier !== 'free' || isModerator`), stated once here so the two cannot drift.
|
||||
*
|
||||
* 🔴 Not left to the rate limiter's `limit: 0` free row. That row does refuse a free user, but
|
||||
* `rateLimit()` short-circuits entirely for moderators AND in dev/test/preview — so on a preview
|
||||
* build the only thing standing between a free account and a free model load would be a middleware
|
||||
* that had already returned. This is the gate; the rate limit is the quota.
|
||||
*/
|
||||
export function assertCanRequestLoad(user: SessionUser) {
|
||||
if (user.isModerator) return;
|
||||
if (!user.tier || user.tier === 'free')
|
||||
throw throwAuthorizationError('Loading models is a member benefit.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Two windows, and they mean different things.
|
||||
*
|
||||
* **Daily rows are entitlement** — what a plan includes. **The hourly row is the cluster's**, so it
|
||||
* is flat and unconditional: no plan buys its way out of burst protection.
|
||||
*
|
||||
* The middleware keeps ONE list of attempt timestamps per key and filters it per rule, so the two
|
||||
* windows compose on the same `sharedKey` with no extra bookkeeping. Per period the highest
|
||||
* matching limit wins, which is what lets the unconditional daily row sit alongside the tier rows.
|
||||
*
|
||||
* 🔴 The unconditional daily row is required, not stylistic: a tier matching NO row gets no limit at
|
||||
* all — `validLimits` comes out empty and the check loop never runs. `userTiers` is
|
||||
* `[free, founder, bronze, silver, gold]`, so `founder` needs its own row too.
|
||||
*
|
||||
* The comparison is `attempts > limit`, so the nonzero numbers permit one load more than they say
|
||||
* (`limit: 0` short-circuits and is exact).
|
||||
* The comparison is `attempts > limit`, so every nonzero number permits one more than it says —
|
||||
* 3/hour is really 4 (`limit: 0` short-circuits and is exact).
|
||||
*/
|
||||
const resourceLoadRateLimits = [
|
||||
export const RESOURCE_LOAD_HOURLY_LIMIT = 3;
|
||||
|
||||
export const resourceLoadRateLimits = [
|
||||
{ limit: 0, period: CacheTTL.day, errorMessage: 'Loading models is a member benefit.' },
|
||||
{ limit: 3, period: CacheTTL.day, userReq: (u: { tier?: string }) => u.tier === 'bronze' },
|
||||
{ limit: 6, period: CacheTTL.day, userReq: (u: { tier?: string }) => u.tier === 'silver' },
|
||||
{ limit: 10, period: CacheTTL.day, userReq: (u: { tier?: string }) => u.tier === 'gold' },
|
||||
{ limit: 10, period: CacheTTL.day, userReq: (u: { tier?: string }) => u.tier === 'founder' },
|
||||
{
|
||||
limit: RESOURCE_LOAD_HOURLY_LIMIT,
|
||||
period: CacheTTL.hour,
|
||||
errorMessage: 'You can only queue a few model loads an hour. Try again shortly.',
|
||||
},
|
||||
];
|
||||
|
||||
export const resourceLoadRouter = router({
|
||||
// Public: load state is shown to everyone on a model page, not only to whoever paid for it.
|
||||
/**
|
||||
* 🔴 Flag-gated, and not for tidiness. `getState` takes up to 100 version ids and makes one
|
||||
* uncached orchestrator call per id, so ungated it is an unauthenticated amplifier: one request,
|
||||
* a hundred grain calls, repeatable by anyone. Drop the flag guard when C5 puts load state on the
|
||||
* model page — and give it a cache or a cap when you do.
|
||||
*/
|
||||
getState: publicProcedure
|
||||
.use(isFlagProtected('resourceLoad'))
|
||||
.input(getResourceLoadStateSchema)
|
||||
.query(({ input }) => getResourceLoadState(input.modelVersionIds)),
|
||||
getQueue: publicProcedure
|
||||
.use(isFlagProtected('resourceLoad'))
|
||||
.input(getResourceLoadQueueSchema)
|
||||
.query(({ input }) => getResourceLoadQueue(input)),
|
||||
// Only `protectedProcedure`, so a user who has not finished onboarding still sees a price rather
|
||||
@@ -51,6 +91,7 @@ export const resourceLoadRouter = router({
|
||||
.use(isFlagProtected('resourceLoad'))
|
||||
.input(resourceLoadVersionSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
assertCanRequestLoad(ctx.user);
|
||||
const token = await getOrchestratorToken(ctx.user.id, ctx);
|
||||
return estimateResourceLoad({
|
||||
modelVersionId: input.modelVersionId,
|
||||
@@ -70,6 +111,7 @@ export const resourceLoadRouter = router({
|
||||
)
|
||||
.input(resourceLoadVersionSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
assertCanRequestLoad(ctx.user);
|
||||
const token = await getOrchestratorToken(ctx.user.id, ctx);
|
||||
return submitResourceLoad({
|
||||
modelVersionId: input.modelVersionId,
|
||||
|
||||
@@ -48,3 +48,29 @@ export const resourceLoadVersionSchema = z.object({
|
||||
export type GetResourceLoadStateInput = z.infer<typeof getResourceLoadStateSchema>;
|
||||
export type GetResourceLoadQueueInput = z.infer<typeof getResourceLoadQueueSchema>;
|
||||
export type ResourceLoadVersionInput = z.infer<typeof resourceLoadVersionSchema>;
|
||||
|
||||
/**
|
||||
* What arrives on the buyer's user channel as `resource-load:update`.
|
||||
*
|
||||
* The orchestrator posts its `WorkflowStepEvent` straight to signals — we are not in the path — so
|
||||
* this is that shape, narrowed to what a progress UI needs. Only a `preparing` step carries
|
||||
* `preparation`; every other status arrives here too and is ignored.
|
||||
*/
|
||||
export const resourceLoadSignalSchema = z.object({
|
||||
workflowId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
status: z.string().nullish(),
|
||||
preparation: z
|
||||
.object({
|
||||
/** AIR of the resource holding the step back — the only thing identifying WHICH load this is. */
|
||||
resource: z.string(),
|
||||
/** Downloads ahead of this one. Zero means it is transferring now. */
|
||||
queuePosition: z.number(),
|
||||
/** 0..1, null while still queued. */
|
||||
progress: z.number().nullish(),
|
||||
etaSeconds: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export type ResourceLoadSignal = z.infer<typeof resourceLoadSignalSchema>;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { isGenerationEligible } from '@civitai/shared/generation-eligibility';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { chunk, isEqual } from 'lodash-es';
|
||||
import type { TypoTolerance } from 'meilisearch';
|
||||
import {
|
||||
type BaseModel,
|
||||
isBaseModelGenerationSupported,
|
||||
} from '~/shared/constants/basemodel.constants';
|
||||
import { type BaseModel } from '~/shared/constants/basemodel.constants';
|
||||
import { MODELS_SEARCH_INDEX } from '~/server/common/constants';
|
||||
import { searchClient as client, updateDocs } from '~/server/meilisearch/client';
|
||||
import { dbRead } from '~/server/db/client';
|
||||
@@ -35,7 +33,6 @@ import { parseBitwiseBrowsingLevel } from '~/shared/constants/browsingLevel.cons
|
||||
import { Availability, ModelStatus } from '~/shared/utils/prisma/enums';
|
||||
import { isDefined } from '~/utils/type-guards';
|
||||
import { modelSearchIndexSelect } from '../selectors/model.selector';
|
||||
import { isGenerationDisabled } from '~/shared/constants/model-version-flags.constants';
|
||||
|
||||
const READ_BATCH_SIZE = 2000;
|
||||
const MEILISEARCH_DOCUMENT_BATCH_SIZE = READ_BATCH_SIZE;
|
||||
@@ -259,11 +256,13 @@ const transformData = async ({ models, tags, cosmetics, images }: PullDataResult
|
||||
|
||||
const { files, ...restVersion } = version;
|
||||
|
||||
const canGenerate = modelVersions.some(
|
||||
(x) =>
|
||||
x.generationCoverage?.covered &&
|
||||
!isGenerationDisabled(x.flags) &&
|
||||
isBaseModelGenerationSupported(x.baseModel, model.type)
|
||||
const canGenerate = modelVersions.some((x) =>
|
||||
isGenerationEligible({
|
||||
covered: x.generationCoverage?.covered,
|
||||
baseModel: x.baseModel,
|
||||
modelType: model.type,
|
||||
flags: x.flags,
|
||||
})
|
||||
);
|
||||
const cannotPromote = (meta as ModelMeta | null)?.cannotPromote;
|
||||
|
||||
@@ -311,10 +310,12 @@ const transformData = async ({ models, tags, cosmetics, images }: PullDataResult
|
||||
metrics: maskHiddenVersionMetrics(vMetrics[0], hidden),
|
||||
hashes: hashes.map((hash) => hash.hash),
|
||||
hashData: hashes.map((hash) => ({ hash: hash.hash, type: hash.hashType })),
|
||||
canGenerate:
|
||||
generationCoverage?.covered &&
|
||||
!isGenerationDisabled(x.flags) &&
|
||||
isBaseModelGenerationSupported(x.baseModel, model.type),
|
||||
canGenerate: isGenerationEligible({
|
||||
covered: generationCoverage?.covered,
|
||||
baseModel: x.baseModel,
|
||||
modelType: model.type,
|
||||
flags: x.flags,
|
||||
}),
|
||||
settings: settings as RecommendedSettingsSchema,
|
||||
baseModel: x.baseModel as BaseModel,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isGenerationEligible } from '@civitai/shared/generation-eligibility';
|
||||
import { ModelType } from '~/shared/utils/prisma/enums';
|
||||
|
||||
/**
|
||||
* "Can this version generate" needs BOTH halves, and only one module may compose them.
|
||||
*
|
||||
* `GenerationCoverage.covered` and `isBaseModelGenerationSupported()` answer different questions.
|
||||
* Only the database knows licence, scan state, status and POI. Only `basemodel.constants.ts` knows
|
||||
* which MODEL TYPES an ecosystem supports — the view's type branch is one flat list applied to
|
||||
* every base model, so it reports LORA/LoCon/DoRA/VAE/TextualInversion versions as covered on
|
||||
* ecosystems that cannot generate with them. Measured against production 2026-09-08: **736 versions
|
||||
* across 33 (baseModel, type) pairs**, led by Wan Video + LORA (337) and Flux.1 D + DoRA (102).
|
||||
*
|
||||
* So the pair is correct and neither half can be dropped. The risk is that it is COMPOSED BY HAND
|
||||
* at each call site: it was, at three of them (the models search index twice, and model.service),
|
||||
* plus the batch resolver in generation.service. A fourth consumer that reads `covered` alone would
|
||||
* offer those 736 a paid model load — charging for a resource search already hides and the
|
||||
* orchestrator cannot generate with. That fourth consumer is the one being written now.
|
||||
*
|
||||
* Same shape, and the same reason, as `no-divergent-paid-gate-derivation`.
|
||||
*
|
||||
* WHY A TEXT GUARD. The composition cannot be required by a type: `covered` is a plain boolean off
|
||||
* a Prisma select, and nothing stops a caller reading it. What CAN be checked is that the
|
||||
* ecosystem-support function has exactly one caller, which is a textual property — the kind a text
|
||||
* guard checks well.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
const SRC = path.join(REPO_ROOT, 'src');
|
||||
const HELPER = '@civitai/shared/generation-eligibility';
|
||||
|
||||
/**
|
||||
* Files under `src/` allowed to name `isBaseModelGenerationSupported`. Empty on purpose: the only
|
||||
* legitimate caller is `isGenerationEligible`, which lives in `packages/civitai-shared`. Adding a
|
||||
* line here is the change that must be visible in review.
|
||||
*/
|
||||
const ALLOWLIST: readonly string[] = [];
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules') walk(full, out);
|
||||
} else if (full.endsWith('.ts') || full.endsWith('.tsx')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Called from INSIDE the tests, never at module scope. Thrown from a `describe` body this would
|
||||
* be a COLLECTION failure: the file contributes zero tests, the suite's failure count does not
|
||||
* move, and the guard is silently absent from every full-suite run.
|
||||
*/
|
||||
function scan() {
|
||||
const directCallers: string[] = [];
|
||||
const helperImporters: string[] = [];
|
||||
|
||||
for (const file of walk(SRC)) {
|
||||
const rel = path.relative(REPO_ROOT, file).split(path.sep).join('/');
|
||||
// Tests may call it directly — they are asserting on it, not deriving a gate from it.
|
||||
if (rel.includes('__tests__') || /\.test\.tsx?$/.test(rel)) continue;
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
if (source.includes('isBaseModelGenerationSupported')) directCallers.push(rel);
|
||||
if (source.includes(HELPER)) helperImporters.push(rel);
|
||||
}
|
||||
|
||||
return { directCallers, helperImporters };
|
||||
}
|
||||
|
||||
describe('canGenerate is derived in one place', () => {
|
||||
it('nothing under src/ calls isBaseModelGenerationSupported directly', () => {
|
||||
const { directCallers } = scan();
|
||||
expect(
|
||||
directCallers.filter((f) => !ALLOWLIST.includes(f)),
|
||||
'Coverage alone is not canGenerate — the ecosystem must also support this model TYPE. Compose ' +
|
||||
'both through `isGenerationEligible` from @civitai/shared/generation-eligibility instead of ' +
|
||||
'pairing them by hand. A consumer that reads `covered` alone over-reports by 736 versions, ' +
|
||||
'and for paid model loading that means charging for a load the orchestrator cannot use.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('the allowlist is empty, and shrinking it is the only allowed direction', () => {
|
||||
// A ratchet, not a snapshot: this fails if someone adds an exemption, so the list cannot grow
|
||||
// quietly the way the paid-gate one did.
|
||||
expect(ALLOWLIST).toEqual([]);
|
||||
});
|
||||
|
||||
it('the helper is actually used — a guard over zero call sites guards nothing', () => {
|
||||
const { helperImporters } = scan();
|
||||
expect(helperImporters.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('requires the ecosystem to support the model type, not just coverage', () => {
|
||||
// Flux.1 D covers DoRA in the view and does not support it for generation — 102 versions.
|
||||
expect(
|
||||
isGenerationEligible({
|
||||
covered: true,
|
||||
baseModel: 'Flux.1 D',
|
||||
modelType: ModelType.DoRA,
|
||||
flags: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when coverage and ecosystem support agree', () => {
|
||||
expect(
|
||||
isGenerationEligible({
|
||||
covered: true,
|
||||
baseModel: 'Flux.1 D',
|
||||
modelType: ModelType.LORA,
|
||||
flags: 0,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false without coverage, however well supported the type is', () => {
|
||||
expect(
|
||||
isGenerationEligible({
|
||||
covered: false,
|
||||
baseModel: 'Flux.1 D',
|
||||
modelType: ModelType.LORA,
|
||||
flags: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,9 @@ const version = {
|
||||
id: 501,
|
||||
name: 'v1',
|
||||
baseModel: 'SDXL 1.0',
|
||||
flags: 0,
|
||||
model: { id: 42, name: 'Test Model', type: 'LORA' },
|
||||
files: [{ type: 'Model', metadata: { format: 'SafeTensor' } }],
|
||||
files: [{ type: 'Model', scannedAt: new Date(), metadata: { format: 'SafeTensor' } }],
|
||||
};
|
||||
|
||||
const versionAir = 'urn:air:sdxl:lora:civitai:42@501';
|
||||
@@ -65,6 +66,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
installAirCodec();
|
||||
dbMock.dbRead.modelVersion.findMany.mockResolvedValue([version]);
|
||||
// The GenerationCoverageNext lookup — covered by default.
|
||||
dbMock.dbRead.$queryRaw.mockResolvedValue([{ modelVersionId: 501 }]);
|
||||
});
|
||||
|
||||
describe('getResourceLoadState', () => {
|
||||
@@ -146,6 +149,47 @@ describe('the purchase path refuses before it submits', () => {
|
||||
expect(submitWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a resource the site cannot generate with, whatever the cluster says', async () => {
|
||||
orchestratorReturns({ status: 'unavailable', queuePosition: null });
|
||||
dbMock.dbRead.$queryRaw.mockResolvedValue([]); // not in GenerationCoverageNext
|
||||
|
||||
await expect(
|
||||
submitResourceLoad({ modelVersionId: 501, userId: 7, token: 'user-token', currencies: [] })
|
||||
).rejects.toThrow(/cannot be generated with/);
|
||||
expect(submitWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a resource with no weight file — an external API model', async () => {
|
||||
orchestratorReturns({ status: 'unavailable', queuePosition: null });
|
||||
dbMock.dbRead.modelVersion.findMany.mockResolvedValue([
|
||||
{
|
||||
...version,
|
||||
// What the 36 mislabelled API versions carried: an archive, not weights.
|
||||
files: [{ type: 'Training Data', scannedAt: new Date(), metadata: { format: 'Other' } }],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
submitResourceLoad({ modelVersionId: 501, userId: 7, token: 'user-token', currencies: [] })
|
||||
).rejects.toThrow(/no model file to load/);
|
||||
expect(submitWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an unscanned file — scanning is what makes a weight servable', async () => {
|
||||
orchestratorReturns({ status: 'unavailable', queuePosition: null });
|
||||
dbMock.dbRead.modelVersion.findMany.mockResolvedValue([
|
||||
{
|
||||
...version,
|
||||
files: [{ type: 'Model', scannedAt: null, metadata: { format: 'SafeTensor' } }],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
submitResourceLoad({ modelVersionId: 501, userId: 7, token: 'user-token', currencies: [] })
|
||||
).rejects.toThrow(/no model file to load/);
|
||||
expect(submitWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a version that does not exist', async () => {
|
||||
dbMock.dbRead.modelVersion.findMany.mockResolvedValue([]);
|
||||
|
||||
@@ -178,6 +222,22 @@ describe('submitResourceLoad', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends progress to the buyer, not to a model-version topic', async () => {
|
||||
// The payload carries workflowId, which the orchestrator names `<userId>-<timestamp>`. A group
|
||||
// broadcast would tell everyone watching the model version who paid for the load.
|
||||
await submitResourceLoad({
|
||||
modelVersionId: 501,
|
||||
userId: 7,
|
||||
token: 'user-token',
|
||||
currencies: [],
|
||||
});
|
||||
|
||||
const [args] = submitWorkflow.mock.calls[0];
|
||||
const urls = (args.body.callbacks ?? []).map((c: { url: string }) => c.url);
|
||||
expect(urls.join(' ')).toContain('/users/7/signals/');
|
||||
expect(urls.join(' ')).not.toContain('/groups/');
|
||||
});
|
||||
|
||||
it('checks who the orchestrator attributed the workflow to', async () => {
|
||||
await submitResourceLoad({
|
||||
modelVersionId: 501,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isGenerationEligible } from '@civitai/shared/generation-eligibility';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type ModelVersionTerms } from '@civitai/buzz';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
@@ -63,7 +64,6 @@ import type { BaseModelGroup } from '~/shared/constants/basemodel.constants';
|
||||
import {
|
||||
baseModelByName,
|
||||
ecosystemById,
|
||||
isBaseModelGenerationSupported,
|
||||
SELF_HOSTED_ECOSYSTEM_KEYS,
|
||||
} from '~/shared/constants/basemodel.constants';
|
||||
import { getVisibleSystemWildcardSetIdsByVersionId } from '~/server/services/generation/version-generation-state.service';
|
||||
@@ -990,8 +990,7 @@ export function getResourceCanGenerate({
|
||||
* - `Wildcards`-type versions: gated on a visible System-kind `WildcardSet`
|
||||
* (one batched query via `getVisibleSystemWildcardSetIdsByVersionId`),
|
||||
* since their baseModel isn't on the generation-supported list.
|
||||
* - Everything else: the standard `getResourceCanGenerate` +
|
||||
* `isBaseModelGenerationSupported` pair.
|
||||
* - Everything else: the standard `getResourceCanGenerate` + `isGenerationEligible` pair.
|
||||
*
|
||||
* Reads the disable-generation flag off each version's `flags` and fetches
|
||||
* `ecosystemConfig` internally so call sites don't have to thread them through.
|
||||
@@ -1083,7 +1082,13 @@ export async function resolveCanGenerateForVersions(
|
||||
},
|
||||
user: ctx.user,
|
||||
hiddenGates,
|
||||
}) && isBaseModelGenerationSupported(gate.baseModel, gate.modelType);
|
||||
}) &&
|
||||
isGenerationEligible({
|
||||
covered: gate.covered,
|
||||
baseModel: gate.baseModel,
|
||||
modelType: gate.modelType,
|
||||
flags: gate.flags,
|
||||
});
|
||||
result.set(key, { canGenerate });
|
||||
}
|
||||
}
|
||||
@@ -1380,19 +1385,43 @@ const EMPTY_HASH = 'e3b0c44298fc';
|
||||
* -- see that function's header for the incident. Change both together.
|
||||
*/
|
||||
const RESOURCE_ROLES = new Set([
|
||||
'model', 'checkpoint', 'refinermodel',
|
||||
'lora', 'lycoris', 'locon', 'dora',
|
||||
'embed', 'embedding', 'textualinversion', 'used_embeddings',
|
||||
'model',
|
||||
'checkpoint',
|
||||
'refinermodel',
|
||||
'lora',
|
||||
'lycoris',
|
||||
'locon',
|
||||
'dora',
|
||||
'embed',
|
||||
'embedding',
|
||||
'textualinversion',
|
||||
'used_embeddings',
|
||||
'hypernet',
|
||||
]);
|
||||
const COMPONENT_ROLES = new Set([
|
||||
'vae', 'refinervae', 'clip', 'clipvision', 'cliplmodel', 'unet',
|
||||
'textencoder', 'text_encoder', 'upscaler', 'controlnet',
|
||||
'qwenmodel', 'llamamodel', 'txxlmodel', 'seedvrmodel',
|
||||
'vae',
|
||||
'refinervae',
|
||||
'clip',
|
||||
'clipvision',
|
||||
'cliplmodel',
|
||||
'unet',
|
||||
'textencoder',
|
||||
'text_encoder',
|
||||
'upscaler',
|
||||
'controlnet',
|
||||
'qwenmodel',
|
||||
'llamamodel',
|
||||
'txxlmodel',
|
||||
'seedvrmodel',
|
||||
]);
|
||||
const NON_RESOURCE_FILE_TYPES = [
|
||||
'Training Data', 'Archive', 'Config', 'Workflow',
|
||||
'VAE', 'Text Encoder', 'CLIPVision',
|
||||
'Training Data',
|
||||
'Archive',
|
||||
'Config',
|
||||
'Workflow',
|
||||
'VAE',
|
||||
'Text Encoder',
|
||||
'CLIPVision',
|
||||
];
|
||||
|
||||
/** A role we cannot read is not a role we can reject -- see the hashes branch in the SQL. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isGenerationEligible } from '@civitai/shared/generation-eligibility';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import type { ManipulateType } from 'dayjs';
|
||||
@@ -14,11 +15,7 @@ import {
|
||||
MODELS_SEARCH_INDEX,
|
||||
nsfwRestrictedBaseModels,
|
||||
} from '~/server/common/constants';
|
||||
import {
|
||||
type BaseModel,
|
||||
DEPRECATED_BASE_MODELS,
|
||||
isBaseModelGenerationSupported,
|
||||
} from '~/shared/constants/basemodel.constants';
|
||||
import { type BaseModel, DEPRECATED_BASE_MODELS } from '~/shared/constants/basemodel.constants';
|
||||
import { ModelSort, SearchIndexUpdateQueueAction } from '~/server/common/enums';
|
||||
import { toApiModelFile } from '~/server/common/model-helpers';
|
||||
import type { Context } from '~/server/createContext';
|
||||
@@ -1632,10 +1629,12 @@ export const getModelsWithImagesAndModelVersions = async ({
|
||||
(input.user || input.username || includeDrafts);
|
||||
if (!filteredImages.length && !showImageless) return null;
|
||||
|
||||
const canGenerate =
|
||||
!!version?.covered &&
|
||||
!isGenerationDisabled(version.flags) &&
|
||||
isBaseModelGenerationSupported(version.baseModel, model.type);
|
||||
const canGenerate = isGenerationEligible({
|
||||
covered: version?.covered,
|
||||
baseModel: version?.baseModel ?? '',
|
||||
modelType: model.type,
|
||||
flags: version?.flags ?? 0,
|
||||
});
|
||||
|
||||
const isOwner = isMod || model.user.id === user?.id;
|
||||
const modelHidden = gateHiddenMetrics(metricPrivacyEnabled, () =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { isGenerationEligible } from '@civitai/shared/generation-eligibility';
|
||||
import type { ModelType } from '~/shared/utils/prisma/enums';
|
||||
import { env } from '~/env/server';
|
||||
import { dbRead } from '~/server/db/client';
|
||||
@@ -19,6 +21,23 @@ import { BuzzTypes } from '~/shared/constants/buzz.constants';
|
||||
|
||||
const PREPARE_STEP_NAME = 'prepare-resource';
|
||||
|
||||
/**
|
||||
* A file the cluster can serve as weights. Mirrors the coverage view's accepted types minus its
|
||||
* `trainingResults` disjunct — a training archive is not a weight. Core ML and ONNX are
|
||||
* inference-runtime formats.
|
||||
*/
|
||||
const LOADABLE_FILE_TYPES = ['Model', 'Pruned Model', 'Diffusion Model', 'UNet', 'Negative', 'VAE'];
|
||||
const UNLOADABLE_FORMATS = ['Core ML', 'ONNX'];
|
||||
|
||||
function hasLoadableFile(files: VersionForAir['files']) {
|
||||
return files.some(
|
||||
(f) =>
|
||||
!!f.scannedAt &&
|
||||
LOADABLE_FILE_TYPES.includes(f.type) &&
|
||||
!UNLOADABLE_FORMATS.includes(String(f.metadata?.format ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
/** Each state fetch is an orchestrator grain call, so keep the fan-out bounded. */
|
||||
const STATE_FETCH_CONCURRENCY = 10;
|
||||
|
||||
@@ -31,14 +50,19 @@ export type ResourceLoadState = {
|
||||
/** Bytes, as the orchestrator reports it. Absent when the resource is unknown to it. */
|
||||
size?: number;
|
||||
availability: ResourceLoadAvailability;
|
||||
/** Coverage alone over-reports; see docs/features/paid-model-loading-coverage.md. */
|
||||
eligible: boolean;
|
||||
/** Whether there is a weight file to download. False for external/API models. */
|
||||
loadable: boolean;
|
||||
};
|
||||
|
||||
type VersionForAir = {
|
||||
id: number;
|
||||
name: string;
|
||||
baseModel: string;
|
||||
flags: number;
|
||||
model: { id: number; name: string; type: ModelType };
|
||||
files: { type: string; metadata: BasicFileMetadata }[];
|
||||
files: { type: string; scannedAt: Date | null; metadata: BasicFileMetadata }[];
|
||||
};
|
||||
|
||||
async function getVersionsForAir(modelVersionIds: number[]) {
|
||||
@@ -48,12 +72,28 @@ async function getVersionsForAir(modelVersionIds: number[]) {
|
||||
id: true,
|
||||
name: true,
|
||||
baseModel: true,
|
||||
flags: true,
|
||||
model: { select: { id: true, name: true, type: true } },
|
||||
files: { select: { type: true, metadata: true } },
|
||||
files: { select: { type: true, scannedAt: true, metadata: true } },
|
||||
},
|
||||
})) as VersionForAir[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The live view still gates checkpoints on `CoveredCheckpoint` — the weekly auction's residency
|
||||
* proxy — so it reports exactly the community checkpoints this feature exists to load as NOT
|
||||
* covered. Gating on it would refuse every load worth making. The two converge when the staged view
|
||||
* replaces the live one.
|
||||
*/
|
||||
async function getNextCoveredVersionIds(modelVersionIds: number[]) {
|
||||
if (!modelVersionIds.length) return new Set<number>();
|
||||
const rows = await dbRead.$queryRaw<{ modelVersionId: number }[]>`
|
||||
SELECT "modelVersionId" FROM "GenerationCoverageNext"
|
||||
WHERE "modelVersionId" IN (${Prisma.join(modelVersionIds)})
|
||||
`;
|
||||
return new Set(rows.map((r) => r.modelVersionId));
|
||||
}
|
||||
|
||||
function parseAvailability(availability: unknown): ResourceLoadAvailability {
|
||||
const parsed = resourceAvailabilitySchema.safeParse(availability);
|
||||
return parsed.success ? parsed.data : { status: 'unknown' };
|
||||
@@ -71,6 +111,8 @@ export async function getResourceLoadState(
|
||||
const versions = await getVersionsForAir(modelVersionIds);
|
||||
if (!versions.length) return [];
|
||||
|
||||
const coveredIds = await getNextCoveredVersionIds(versions.map((v) => v.id));
|
||||
|
||||
const results: ResourceLoadState[] = [];
|
||||
const tasks = versions.map((version) => async () => {
|
||||
const air = modelVersionToAir(version);
|
||||
@@ -80,6 +122,13 @@ export async function getResourceLoadState(
|
||||
air,
|
||||
name: version.name,
|
||||
modelName: version.model.name,
|
||||
eligible: isGenerationEligible({
|
||||
covered: coveredIds.has(version.id),
|
||||
baseModel: version.baseModel,
|
||||
modelType: version.model.type,
|
||||
flags: version.flags,
|
||||
}),
|
||||
loadable: hasLoadableFile(version.files),
|
||||
};
|
||||
|
||||
const response = await getModelClient({ token: env.ORCHESTRATOR_ACCESS_TOKEN, air });
|
||||
@@ -145,6 +194,15 @@ async function resolveLoadable(modelVersionId: number) {
|
||||
const [state] = await getResourceLoadState([modelVersionId]);
|
||||
if (!state) throw throwNotFoundError(`No model version with id ${modelVersionId}`);
|
||||
|
||||
if (!state.eligible)
|
||||
throw throwBadRequestError(
|
||||
'This resource cannot be generated with on the site, so loading it would buy nothing.'
|
||||
);
|
||||
if (!state.loadable)
|
||||
throw throwBadRequestError(
|
||||
'This resource has no model file to load — it runs through an external provider.'
|
||||
);
|
||||
|
||||
const { status } = state.availability;
|
||||
if (status === 'unsupported')
|
||||
throw throwBadRequestError('The generation cluster cannot host this resource.');
|
||||
@@ -206,7 +264,7 @@ export async function submitResourceLoad({
|
||||
token,
|
||||
body: {
|
||||
steps: [prepareResourceStep(state.air)],
|
||||
callbacks: getResourceLoadCallbacks(modelVersionId),
|
||||
callbacks: getResourceLoadCallbacks(userId),
|
||||
tags: ['resource-load'],
|
||||
// @ts-ignore - BuzzSpendType is properly supported
|
||||
currencies: BuzzTypes.toOrchestratorType(currencies),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
RESOURCE_LOAD_EXPIRY_MS,
|
||||
resourceLoadDrainVerdict,
|
||||
type TrackedResourceLoad,
|
||||
} from '~/store/resource-load.store';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
const item = (overrides: Partial<TrackedResourceLoad> = {}): TrackedResourceLoad => ({
|
||||
modelVersionId: 501,
|
||||
modelId: 42,
|
||||
name: 'v1',
|
||||
modelName: 'Test Model',
|
||||
requestedAt: NOW,
|
||||
kind: 'requested',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const state = (status: string, queuePosition?: number | null) => ({
|
||||
availability: { status, queuePosition },
|
||||
});
|
||||
|
||||
describe('resourceLoadDrainVerdict', () => {
|
||||
it('completes a load that is now available', () => {
|
||||
expect(resourceLoadDrainVerdict(item(), state('available'), NOW)).toEqual({
|
||||
action: 'complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an item whose version no longer resolves', () => {
|
||||
expect(resourceLoadDrainVerdict(item(), undefined, NOW)).toEqual({
|
||||
action: 'drop',
|
||||
reason: 'missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a download in progress', () => {
|
||||
expect(resourceLoadDrainVerdict(item(), state('loading'), NOW)).toEqual({ action: 'keep' });
|
||||
});
|
||||
|
||||
it('keeps one that is queued behind others', () => {
|
||||
expect(resourceLoadDrainVerdict(item(), state('unavailable', 3), NOW)).toEqual({
|
||||
action: 'keep',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an unavailable resource with no queue position — nothing is in flight', () => {
|
||||
// A load that failed, or finished and was evicted, reads back exactly like this. Treating it as
|
||||
// "still queued" is what would keep it in the queue forever.
|
||||
expect(resourceLoadDrainVerdict(item(), state('unavailable', null), NOW)).toEqual({
|
||||
action: 'drop',
|
||||
reason: 'not-loading',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['unsupported', 'unknown'])('drops a %s resource', (status) => {
|
||||
expect(resourceLoadDrainVerdict(item(), state(status), NOW)).toEqual({
|
||||
action: 'drop',
|
||||
reason: 'not-loading',
|
||||
});
|
||||
});
|
||||
|
||||
it('expires an item the orchestrator still claims is queued', () => {
|
||||
// The case the ceiling exists for: without it this item is re-subscribed on every page load,
|
||||
// forever, and no other rule can remove it.
|
||||
const stale = item({ requestedAt: NOW - RESOURCE_LOAD_EXPIRY_MS - 1 });
|
||||
|
||||
expect(resourceLoadDrainVerdict(stale, state('unavailable', 2), NOW)).toEqual({
|
||||
action: 'drop',
|
||||
reason: 'expired',
|
||||
});
|
||||
expect(resourceLoadDrainVerdict(stale, state('loading'), NOW)).toEqual({
|
||||
action: 'drop',
|
||||
reason: 'expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('still reports a completed load that arrived after the ceiling', () => {
|
||||
// Expiry must not swallow good news: the user waited, it finished, they should be told.
|
||||
const stale = item({ requestedAt: NOW - RESOURCE_LOAD_EXPIRY_MS - 1 });
|
||||
|
||||
expect(resourceLoadDrainVerdict(stale, state('available'), NOW)).toEqual({
|
||||
action: 'complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an item right up to the ceiling', () => {
|
||||
const edge = item({ requestedAt: NOW - RESOURCE_LOAD_EXPIRY_MS });
|
||||
|
||||
expect(resourceLoadDrainVerdict(edge, state('loading'), NOW)).toEqual({ action: 'keep' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* What this browser is waiting on. A queue that drains, not a history: every item leaves on the
|
||||
* next page load.
|
||||
*
|
||||
* ⚠️ Per-browser, and NOT the record of what a user bought — that is the orchestrator's own
|
||||
* (workflows tagged `resource-load`). This exists for the case nothing server-side knows about:
|
||||
* someone watching a load they did not pay for.
|
||||
*/
|
||||
export type TrackedResourceLoad = {
|
||||
modelVersionId: number;
|
||||
modelId: number;
|
||||
name: string;
|
||||
modelName: string;
|
||||
/** Epoch ms. The only thing that guarantees an item can always leave — see EXPIRY_MS. */
|
||||
requestedAt: number;
|
||||
/** `requested` paid for it; `watching` is a bystander. */
|
||||
kind: 'requested' | 'watching';
|
||||
};
|
||||
|
||||
/**
|
||||
* 🔴 The ceiling that makes "the queue always drains" true.
|
||||
*
|
||||
* The three obvious drain rules — done, gone, still-loading — do not cover a load that FAILED or one
|
||||
* that finished and was then evicted. Both read back as `unavailable`, which is indistinguishable
|
||||
* from "queued and waiting", so without a deadline such an item is re-subscribed forever and the
|
||||
* queue is a graveyard. 48h matches the residency policy: past it, nothing is still in flight.
|
||||
*/
|
||||
export const RESOURCE_LOAD_EXPIRY_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
type ResourceLoadStore = {
|
||||
tracked: TrackedResourceLoad[];
|
||||
track: (item: Omit<TrackedResourceLoad, 'requestedAt'>) => void;
|
||||
untrack: (modelVersionId: number) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export const useResourceLoadStore = create<ResourceLoadStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
tracked: [],
|
||||
|
||||
track: (item) =>
|
||||
set((state) => {
|
||||
const existing = state.tracked.find((x) => x.modelVersionId === item.modelVersionId);
|
||||
// Re-tracking keeps the original timestamp: the expiry measures how long the load has been
|
||||
// outstanding, and refreshing it on every visit would let an item outlive its own deadline.
|
||||
if (existing)
|
||||
return {
|
||||
tracked: state.tracked.map((x) =>
|
||||
x.modelVersionId === item.modelVersionId
|
||||
? { ...x, ...item, requestedAt: x.requestedAt }
|
||||
: x
|
||||
),
|
||||
};
|
||||
return { tracked: [...state.tracked, { ...item, requestedAt: Date.now() }] };
|
||||
}),
|
||||
|
||||
untrack: (modelVersionId) =>
|
||||
set((state) => ({
|
||||
tracked: state.tracked.filter((x) => x.modelVersionId !== modelVersionId),
|
||||
})),
|
||||
|
||||
clear: () => set({ tracked: [] }),
|
||||
}),
|
||||
{
|
||||
name: 'resource-load-tracking',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
version: 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export type ResourceLoadDrainVerdict =
|
||||
/** Loaded. Tell the user, then drop it. */
|
||||
| { action: 'complete' }
|
||||
/** The version is gone, or nothing is in flight for it any more. Drop it silently. */
|
||||
| { action: 'drop'; reason: 'missing' | 'not-loading' | 'expired' }
|
||||
/** Still queued or downloading. Keep it and subscribe. */
|
||||
| { action: 'keep' };
|
||||
|
||||
/** Decide what happens to one tracked item on page load. */
|
||||
export function resourceLoadDrainVerdict(
|
||||
item: TrackedResourceLoad,
|
||||
state: { availability: { status: string; queuePosition?: number | null } } | undefined,
|
||||
now = Date.now()
|
||||
): ResourceLoadDrainVerdict {
|
||||
if (!state) return { action: 'drop', reason: 'missing' };
|
||||
if (state.availability.status === 'available') return { action: 'complete' };
|
||||
|
||||
// Checked BEFORE the loading cases: an expired item leaves even if the orchestrator still claims
|
||||
// it is queued, which is the failure this ceiling exists for.
|
||||
if (now - item.requestedAt > RESOURCE_LOAD_EXPIRY_MS)
|
||||
return { action: 'drop', reason: 'expired' };
|
||||
|
||||
if (state.availability.status === 'loading') return { action: 'keep' };
|
||||
if (state.availability.status === 'unavailable' && state.availability.queuePosition != null)
|
||||
return { action: 'keep' };
|
||||
|
||||
return { action: 'drop', reason: 'not-loading' };
|
||||
}
|
||||
Reference in New Issue
Block a user