@moldesk/core 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud/accounts.d.ts +69 -0
- package/dist/cloud/accounts.js +113 -0
- package/dist/cloud/accounts.test.d.ts +1 -0
- package/dist/cloud/accounts.test.js +188 -0
- package/dist/cloud/backend.test.d.ts +1 -0
- package/dist/cloud/backend.test.js +339 -0
- package/dist/cloud/deps.d.ts +15 -0
- package/dist/cloud/deps.js +30 -0
- package/dist/cloud/manage.d.ts +57 -0
- package/dist/cloud/manage.js +210 -0
- package/dist/cloud/manage.test.d.ts +1 -0
- package/dist/cloud/manage.test.js +105 -0
- package/dist/cloud/quote.d.ts +131 -0
- package/dist/cloud/quote.js +225 -0
- package/dist/cloud/quote.test.d.ts +1 -0
- package/dist/cloud/quote.test.js +264 -0
- package/dist/cloud/run.d.ts +114 -0
- package/dist/cloud/run.js +498 -0
- package/dist/cloud/run.test.d.ts +1 -0
- package/dist/cloud/run.test.js +597 -0
- package/dist/cloud/state.d.ts +159 -0
- package/dist/cloud/state.js +395 -0
- package/dist/cloud/state.test.d.ts +1 -0
- package/dist/cloud/state.test.js +199 -0
- package/dist/cloud/worker/cache.d.ts +75 -0
- package/dist/cloud/worker/cache.js +152 -0
- package/dist/cloud/worker/cache.test.d.ts +1 -0
- package/dist/cloud/worker/cache.test.js +123 -0
- package/dist/cloud/worker/main.d.ts +2 -0
- package/dist/cloud/worker/main.js +34 -0
- package/dist/cloud/worker/pipeline.d.ts +26 -0
- package/dist/cloud/worker/pipeline.js +160 -0
- package/dist/cloud/worker/pipeline.test.d.ts +1 -0
- package/dist/cloud/worker/pipeline.test.js +298 -0
- package/dist/cloud/worker/server.d.ts +38 -0
- package/dist/cloud/worker/server.js +287 -0
- package/dist/cloud/worker/server.test.d.ts +1 -0
- package/dist/cloud/worker/server.test.js +391 -0
- package/dist/cloud/worker/watchdog.d.ts +27 -0
- package/dist/cloud/worker/watchdog.js +46 -0
- package/dist/cloud/worker-release.d.ts +12 -0
- package/dist/cloud/worker-release.js +25 -0
- package/dist/cloud/worker-release.test.d.ts +1 -0
- package/dist/cloud/worker-release.test.js +21 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -2
- package/dist/run-lifecycle.d.ts +74 -0
- package/dist/run-lifecycle.js +178 -0
- package/dist/run.d.ts +61 -1
- package/dist/run.js +54 -125
- package/dist/run.test.js +47 -1
- package/package.json +10 -6
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { getMoldeskPaths } from "@moldesk/runtime";
|
|
6
|
+
import { executeCloudCacheDeletion, listCloudCaches, planCloudCacheDeletion } from "./manage.js";
|
|
7
|
+
import { CloudJournal } from "./state.js";
|
|
8
|
+
const dirs = [];
|
|
9
|
+
const RUN = "2026-10-13T11-55-45-123Z-0a1b2c3d-1111-2222-3333-444455556666";
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const dir of dirs.splice(0))
|
|
12
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
function box() {
|
|
15
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk manage "));
|
|
16
|
+
dirs.push(home);
|
|
17
|
+
const paths = getMoldeskPaths({ MOLDESK_HOME: home });
|
|
18
|
+
const volumes = new Map();
|
|
19
|
+
const state = { failDelete: false, stickyDelete: false, deleted: [] };
|
|
20
|
+
const provider = {
|
|
21
|
+
name: "runpod",
|
|
22
|
+
listVolumes: async () => [...volumes.values()],
|
|
23
|
+
getVolume: async (id) => volumes.get(id) ?? null,
|
|
24
|
+
deleteVolume: async (id) => {
|
|
25
|
+
if (state.failDelete)
|
|
26
|
+
throw new Error("provider down");
|
|
27
|
+
if (!state.stickyDelete)
|
|
28
|
+
volumes.delete(id);
|
|
29
|
+
state.deleted.push(id);
|
|
30
|
+
return { id, alreadyAbsent: false };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const journal = CloudJournal.open(paths);
|
|
34
|
+
const seed = (model, volumeId, owned = true, key = "k".repeat(64)) => {
|
|
35
|
+
volumes.set(volumeId, { id: volumeId, name: "n", sizeGb: 40, dataCenterId: "DC-1", type: "STANDARD" });
|
|
36
|
+
if (owned) {
|
|
37
|
+
const intent = journal.recordResourceIntent({ role: "volume" });
|
|
38
|
+
if (!journal.findOwnedByProviderId(volumeId))
|
|
39
|
+
journal.recordResourceCreated(intent.intentId, volumeId);
|
|
40
|
+
}
|
|
41
|
+
journal.upsertCache({ accountScope: "acct", datacenterId: "DC-1", volumeId, model, modelVersion: "1", cacheKey: key, layoutRevision: "1", manifestDigest: "m", cachePath: "/workspace/x", sizeBytes: null, checksumManifestDigest: null });
|
|
42
|
+
};
|
|
43
|
+
const deps = { provider, paths, accountScope: "acct" };
|
|
44
|
+
return { journal, volumes, state, seed, deps };
|
|
45
|
+
}
|
|
46
|
+
describe("cache management", () => {
|
|
47
|
+
it("lists caches with whether the provider still has the volume", async () => {
|
|
48
|
+
const b = box();
|
|
49
|
+
b.seed("boltz", "vol1");
|
|
50
|
+
b.volumes.delete("vol1");
|
|
51
|
+
b.seed("other", "vol2");
|
|
52
|
+
const listing = await listCloudCaches(b.deps);
|
|
53
|
+
expect(listing.map((entry) => [entry.cache.model, entry.volumeExists])).toEqual([["boltz", false], ["other", true]]);
|
|
54
|
+
});
|
|
55
|
+
it("plans exactly the volumes for one model", async () => {
|
|
56
|
+
const b = box();
|
|
57
|
+
b.seed("boltz", "vol1");
|
|
58
|
+
b.seed("other", "vol2");
|
|
59
|
+
const plan = await planCloudCacheDeletion("boltz", b.deps);
|
|
60
|
+
expect(plan).toEqual({ model: "boltz", volumes: [{ volumeId: "vol1", datacenterId: "DC-1", sizeGb: 40, cacheKeys: ["k".repeat(64)] }], blockers: [] });
|
|
61
|
+
});
|
|
62
|
+
it("blocks deletion while a run uses the volume or another model shares it", async () => {
|
|
63
|
+
const b = box();
|
|
64
|
+
b.seed("boltz", "vol1");
|
|
65
|
+
b.journal.createJob({ runId: RUN, accountScope: "acct", quoteDigest: "q", cachePolicy: "persistent", deadlineAt: "2099-01-01T00:00:00.000Z", manifestDigest: "m" });
|
|
66
|
+
b.journal.updateJob(RUN, { volumeId: "vol1", stage: "running" });
|
|
67
|
+
b.seed("other", "vol1", true, "j".repeat(64));
|
|
68
|
+
const plan = await planCloudCacheDeletion("boltz", b.deps);
|
|
69
|
+
expect(plan.blockers.join(" ")).toMatch(/still using volume vol1/);
|
|
70
|
+
expect(plan.blockers.join(" ")).toMatch(/also holds caches for: other/);
|
|
71
|
+
await expect(executeCloudCacheDeletion(plan, b.deps)).rejects.toMatchObject({ code: "CLOUD_CACHE_IN_USE" });
|
|
72
|
+
expect(b.state.deleted).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
it("deletes an owned volume, verifies it is gone, and removes the cache row", async () => {
|
|
75
|
+
const b = box();
|
|
76
|
+
b.seed("boltz", "vol1");
|
|
77
|
+
const result = await executeCloudCacheDeletion(await planCloudCacheDeletion("boltz", b.deps), b.deps);
|
|
78
|
+
expect(result).toEqual({ deleted: [{ volumeId: "vol1" }], failed: [] });
|
|
79
|
+
expect(b.volumes.size).toBe(0);
|
|
80
|
+
expect(b.journal.listCaches()).toHaveLength(0);
|
|
81
|
+
expect(b.journal.findOwnedByProviderId("vol1")).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
it("refuses to delete a volume this installation did not create", async () => {
|
|
84
|
+
const b = box();
|
|
85
|
+
b.seed("boltz", "vol1", false);
|
|
86
|
+
const result = await executeCloudCacheDeletion(await planCloudCacheDeletion("boltz", b.deps), b.deps);
|
|
87
|
+
expect(result.deleted).toEqual([]);
|
|
88
|
+
expect(result.failed[0].reason).toMatch(/did not create/);
|
|
89
|
+
expect(b.state.deleted).toEqual([]);
|
|
90
|
+
expect(b.journal.listCaches()).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
it("reports provider failures and a volume that still exists after deletion, keeping the journal truthful", async () => {
|
|
93
|
+
const b = box();
|
|
94
|
+
b.seed("boltz", "vol1");
|
|
95
|
+
b.state.failDelete = true;
|
|
96
|
+
const failed = await executeCloudCacheDeletion(await planCloudCacheDeletion("boltz", b.deps), b.deps);
|
|
97
|
+
expect(failed.failed[0].reason).toMatch(/provider down/);
|
|
98
|
+
expect(b.journal.listCaches()).toHaveLength(1);
|
|
99
|
+
b.state.failDelete = false;
|
|
100
|
+
b.state.stickyDelete = true;
|
|
101
|
+
const sticky = await executeCloudCacheDeletion(await planCloudCacheDeletion("boltz", b.deps), b.deps);
|
|
102
|
+
expect(sticky.failed[0].reason).toMatch(/still lists the volume/);
|
|
103
|
+
expect(b.journal.listCaches()).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { CloudCatalog } from "@moldesk/runtime";
|
|
2
|
+
import type { CloudSpec, ModelManifestV1 } from "@moldesk/registry";
|
|
3
|
+
import type { CloudCachePolicy } from "./state.js";
|
|
4
|
+
export declare const QUOTE_VERSION = 1;
|
|
5
|
+
/** Approvals older than this are refused; the user must see a fresh quote. */
|
|
6
|
+
export declare const APPROVAL_MAX_AGE_MS: number;
|
|
7
|
+
/** A price rise above this fraction since approval requires a new approval. */
|
|
8
|
+
export declare const PRICE_TOLERANCE = 0.1;
|
|
9
|
+
/** Storage amounts below these absolute rises are noise, not a material change (USD). */
|
|
10
|
+
export declare const STORAGE_NOISE_FLOOR_USD = 0.01;
|
|
11
|
+
export declare const RECURRING_NOISE_FLOOR_USD_PER_MONTH = 0.1;
|
|
12
|
+
/** Shown prominently before approval and must be explicitly acknowledged: there is no independent shutdown service. */
|
|
13
|
+
export declare const LIFETIME_RISK_WARNING = "If MoleculeDesk closes or loses connection, your Runpod Pod may keep running and charging. Automatic shutdown is not guaranteed. Check your Runpod dashboard and terminate any remaining Pod.";
|
|
14
|
+
export declare const STORAGE_CHARGES_WARNING = "Persistent caching keeps a network volume. Storage charges continue until you delete it (`moldesk cloud cache delete`, or in the Runpod dashboard).";
|
|
15
|
+
export type CloudAcknowledgment = "lifetime-risk" | "storage-charges";
|
|
16
|
+
export interface QuoteWarning {
|
|
17
|
+
id: CloudAcknowledgment;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const DEFAULT_MAX_LIFETIME_SECONDS = 3600;
|
|
21
|
+
export declare const DEFAULT_PREP_CPU: {
|
|
22
|
+
readonly id: "cpu3c";
|
|
23
|
+
readonly vcpuCount: 2;
|
|
24
|
+
};
|
|
25
|
+
export interface QuoteRange {
|
|
26
|
+
minSeconds: number;
|
|
27
|
+
maxSeconds: number;
|
|
28
|
+
}
|
|
29
|
+
export interface CloudQuote {
|
|
30
|
+
version: typeof QUOTE_VERSION;
|
|
31
|
+
/** Digest of everything the user consents to; excludes volatile prices and retrieval time. */
|
|
32
|
+
id: string;
|
|
33
|
+
provider: string;
|
|
34
|
+
retrievedAt: string;
|
|
35
|
+
model: string;
|
|
36
|
+
modelVersion: string;
|
|
37
|
+
cachePolicy: CloudCachePolicy;
|
|
38
|
+
gpu: {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
memoryGb: number;
|
|
42
|
+
priceUsdPerHour: number;
|
|
43
|
+
};
|
|
44
|
+
datacenterId: string;
|
|
45
|
+
cpu?: {
|
|
46
|
+
id: string;
|
|
47
|
+
vcpuCount: number;
|
|
48
|
+
priceUsdPerHour: number;
|
|
49
|
+
};
|
|
50
|
+
images: {
|
|
51
|
+
gpu: string;
|
|
52
|
+
cpu: string;
|
|
53
|
+
};
|
|
54
|
+
upload: {
|
|
55
|
+
files: number;
|
|
56
|
+
bytes: number;
|
|
57
|
+
};
|
|
58
|
+
maxLifetimeSeconds: number;
|
|
59
|
+
maxBudgetUsd: number;
|
|
60
|
+
cost: {
|
|
61
|
+
/** Hourly rate times the approved lifetime for every paid compute role. A ceiling, not a spend cap. */
|
|
62
|
+
worstCaseComputeUsd: number;
|
|
63
|
+
/** Container disk for the machines' lifetime; `null` when the provider publishes no rate. */
|
|
64
|
+
temporaryStorage: {
|
|
65
|
+
diskGb: number;
|
|
66
|
+
machines: number;
|
|
67
|
+
worstCaseUsd: number | null;
|
|
68
|
+
};
|
|
69
|
+
/** Retained network volume; billed monthly until deleted. `null` when the provider publishes no rate. */
|
|
70
|
+
recurringStorage: {
|
|
71
|
+
recurring: boolean;
|
|
72
|
+
volumeGb: number | null;
|
|
73
|
+
usdPerMonth: number | null;
|
|
74
|
+
};
|
|
75
|
+
/** What `maxBudgetUsd` is compared against: compute plus any temporary storage with a known rate. */
|
|
76
|
+
worstCaseBudgetedUsd: number;
|
|
77
|
+
};
|
|
78
|
+
/** Exactly what the budget guard covers, what it does not, and which costs have no known figure. */
|
|
79
|
+
budgetCoverage: {
|
|
80
|
+
counted: string[];
|
|
81
|
+
notCounted: string[];
|
|
82
|
+
unknown: string[];
|
|
83
|
+
};
|
|
84
|
+
/** `null` means no measured estimate exists; it is never guessed. */
|
|
85
|
+
timing: {
|
|
86
|
+
preparation: QuoteRange | null;
|
|
87
|
+
inference: QuoteRange | null;
|
|
88
|
+
transfer: QuoteRange | null;
|
|
89
|
+
};
|
|
90
|
+
/** Every warning here must be acknowledged by the user for the approval to be valid. */
|
|
91
|
+
warnings: QuoteWarning[];
|
|
92
|
+
disclosures: string[];
|
|
93
|
+
bindings: {
|
|
94
|
+
manifestDigest: string;
|
|
95
|
+
inputs: Array<{
|
|
96
|
+
role: string;
|
|
97
|
+
sha256: string;
|
|
98
|
+
}>;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export interface QuoteInputs {
|
|
102
|
+
provider: string;
|
|
103
|
+
manifest: ModelManifestV1;
|
|
104
|
+
cloud: CloudSpec;
|
|
105
|
+
catalog: CloudCatalog;
|
|
106
|
+
cachePolicy: CloudCachePolicy;
|
|
107
|
+
gpuId?: string;
|
|
108
|
+
datacenterId?: string;
|
|
109
|
+
maxBudgetUsd: number;
|
|
110
|
+
maxLifetimeSeconds: number;
|
|
111
|
+
inputs: Array<{
|
|
112
|
+
role: string;
|
|
113
|
+
sha256: string;
|
|
114
|
+
sizeBytes: number;
|
|
115
|
+
}>;
|
|
116
|
+
/** Datacenter of a compatible retained volume; persistent runs must stay there. */
|
|
117
|
+
existingVolumeDatacenterId?: string;
|
|
118
|
+
}
|
|
119
|
+
/** Pure: builds the quote shown to the user. Rejects over-budget quotes before anything is provisioned. */
|
|
120
|
+
export declare function buildQuote(input: QuoteInputs): CloudQuote;
|
|
121
|
+
export interface CloudApproval {
|
|
122
|
+
quoteId: string;
|
|
123
|
+
approvedAt: string;
|
|
124
|
+
/** Warning ids the user explicitly acknowledged; must include every `quote.warnings` id. */
|
|
125
|
+
acknowledged: CloudAcknowledgment[];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Verifies that what was approved is what is about to be provisioned: same quote id, not expired,
|
|
129
|
+
* and no material price rise or location change since. Throws before any resource is created.
|
|
130
|
+
*/
|
|
131
|
+
export declare function assertApprovalValid(approved: CloudQuote, approval: CloudApproval, fresh: CloudQuote, now?: Date): void;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { MoldeskError } from "../errors.js";
|
|
2
|
+
import { digest } from "../hash.js";
|
|
3
|
+
export const QUOTE_VERSION = 1;
|
|
4
|
+
/** Approvals older than this are refused; the user must see a fresh quote. */
|
|
5
|
+
export const APPROVAL_MAX_AGE_MS = 15 * 60 * 1000;
|
|
6
|
+
/** A price rise above this fraction since approval requires a new approval. */
|
|
7
|
+
export const PRICE_TOLERANCE = 0.1;
|
|
8
|
+
/** Storage amounts below these absolute rises are noise, not a material change (USD). */
|
|
9
|
+
export const STORAGE_NOISE_FLOOR_USD = 0.01;
|
|
10
|
+
export const RECURRING_NOISE_FLOOR_USD_PER_MONTH = 0.1;
|
|
11
|
+
/** Shown prominently before approval and must be explicitly acknowledged: there is no independent shutdown service. */
|
|
12
|
+
export const LIFETIME_RISK_WARNING = "If MoleculeDesk closes or loses connection, your Runpod Pod may keep running and charging. Automatic shutdown is not guaranteed. Check your Runpod dashboard and terminate any remaining Pod.";
|
|
13
|
+
export const STORAGE_CHARGES_WARNING = "Persistent caching keeps a network volume. Storage charges continue until you delete it (`moldesk cloud cache delete`, or in the Runpod dashboard).";
|
|
14
|
+
export const DEFAULT_MAX_LIFETIME_SECONDS = 3600;
|
|
15
|
+
/** Billing months are 730 hours. */
|
|
16
|
+
const HOURS_PER_MONTH = 730;
|
|
17
|
+
export const DEFAULT_PREP_CPU = { id: "cpu3c", vcpuCount: 2 };
|
|
18
|
+
function rejected(code, message, remediation, details) {
|
|
19
|
+
return new MoldeskError({ code, message, remediation, details });
|
|
20
|
+
}
|
|
21
|
+
function datacenterSupports(dc, persistent) {
|
|
22
|
+
return dc !== undefined && (!persistent || dc.networkVolumeTypes.length > 0);
|
|
23
|
+
}
|
|
24
|
+
/** Cheapest eligible GPU in a datacenter that can host the policy; a user's explicit choice is validated, never silently replaced. */
|
|
25
|
+
function chooseGpuAndDatacenter(input) {
|
|
26
|
+
const persistent = input.cachePolicy === "persistent";
|
|
27
|
+
const dcById = new Map(input.catalog.dataCenters.map((dc) => [dc.id, dc]));
|
|
28
|
+
const eligible = input.catalog.gpus.filter((gpu) => gpu.memoryGb >= input.cloud.minVramGb && gpu.priceUsdPerHour > 0 && gpu.availability !== "NONE");
|
|
29
|
+
if (input.gpuId) {
|
|
30
|
+
const gpu = input.catalog.gpus.find((candidate) => candidate.id === input.gpuId);
|
|
31
|
+
if (!gpu)
|
|
32
|
+
throw rejected("CLOUD_GPU_UNKNOWN", `GPU "${input.gpuId}" is not in the provider catalog.`, "Pick an id from the provider's GPU catalog.");
|
|
33
|
+
if (gpu.memoryGb < input.cloud.minVramGb) {
|
|
34
|
+
throw rejected("CLOUD_GPU_INELIGIBLE", `${gpu.name} has ${gpu.memoryGb} GB VRAM; ${input.manifest.name} needs at least ${input.cloud.minVramGb} GB.`, "Choose a larger GPU.");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const candidates = (input.gpuId ? eligible.filter((gpu) => gpu.id === input.gpuId) : eligible).sort((a, b) => a.priceUsdPerHour - b.priceUsdPerHour);
|
|
38
|
+
for (const gpu of candidates) {
|
|
39
|
+
const options = gpu.dataCenters
|
|
40
|
+
.filter((dc) => dc.availability !== "NONE")
|
|
41
|
+
.map((dc) => dc.id)
|
|
42
|
+
.filter((id) => datacenterSupports(dcById.get(id), persistent))
|
|
43
|
+
.filter((id) => !input.datacenterId || id === input.datacenterId)
|
|
44
|
+
.filter((id) => !input.existingVolumeDatacenterId || id === input.existingVolumeDatacenterId);
|
|
45
|
+
if (options.length > 0)
|
|
46
|
+
return { gpu, datacenterId: options[0] };
|
|
47
|
+
}
|
|
48
|
+
throw rejected("CLOUD_CAPACITY_UNAVAILABLE", "No eligible GPU currently has capacity in a compatible datacenter.", "Retry later, relax --gpu/--datacenter, or use an ephemeral cache policy if a persistent volume location is the constraint. Quotes never reserve capacity.", { cachePolicy: input.cachePolicy, minVramGb: input.cloud.minVramGb });
|
|
49
|
+
}
|
|
50
|
+
function chooseCpu(catalog) {
|
|
51
|
+
const type = catalog.cpus.find((cpu) => cpu.id === DEFAULT_PREP_CPU.id);
|
|
52
|
+
return type ? { type, vcpuCount: Math.max(DEFAULT_PREP_CPU.vcpuCount, type.minVcpu) } : undefined;
|
|
53
|
+
}
|
|
54
|
+
function requiredWarnings(persistent) {
|
|
55
|
+
return [
|
|
56
|
+
{ id: "lifetime-risk", text: LIFETIME_RISK_WARNING },
|
|
57
|
+
...(persistent ? [{ id: "storage-charges", text: STORAGE_CHARGES_WARNING }] : []),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
const round = (value) => Math.round(value * 10_000) / 10_000;
|
|
61
|
+
/** Pure: builds the quote shown to the user. Rejects over-budget quotes before anything is provisioned. */
|
|
62
|
+
export function buildQuote(input) {
|
|
63
|
+
if (!Number.isFinite(input.maxBudgetUsd) || input.maxBudgetUsd <= 0) {
|
|
64
|
+
throw rejected("CLOUD_BUDGET_REQUIRED", "A positive maximum budget is required.", "Pass --max-budget <usd>.");
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isInteger(input.maxLifetimeSeconds) || input.maxLifetimeSeconds <= 0) {
|
|
67
|
+
throw rejected("CLOUD_LIFETIME_REQUIRED", "A positive maximum lifetime in seconds is required.", "Pass --max-lifetime <seconds>.");
|
|
68
|
+
}
|
|
69
|
+
const { gpu, datacenterId } = chooseGpuAndDatacenter(input);
|
|
70
|
+
const persistent = input.cachePolicy === "persistent";
|
|
71
|
+
const hours = input.maxLifetimeSeconds / 3600;
|
|
72
|
+
let cpu;
|
|
73
|
+
if (persistent && input.cloud.cpuAssetPreparation) {
|
|
74
|
+
const chosen = chooseCpu(input.catalog);
|
|
75
|
+
if (!chosen)
|
|
76
|
+
throw rejected("CLOUD_CPU_UNAVAILABLE", `CPU type ${DEFAULT_PREP_CPU.id} is not in the provider catalog.`, "Retry later or use an ephemeral cache policy.");
|
|
77
|
+
cpu = { id: chosen.type.id, vcpuCount: chosen.vcpuCount, priceUsdPerHour: round(chosen.type.priceUsdPerVcpuHour * chosen.vcpuCount) };
|
|
78
|
+
}
|
|
79
|
+
const worstCaseComputeUsd = round((gpu.priceUsdPerHour + (cpu?.priceUsdPerHour ?? 0)) * hours);
|
|
80
|
+
// Storage: only rates the provider actually publishes are used; everything else is stated as unknown.
|
|
81
|
+
const rates = input.catalog.storageRates;
|
|
82
|
+
const machines = cpu ? 2 : 1;
|
|
83
|
+
const diskGb = input.cloud.ephemeralDiskGb;
|
|
84
|
+
const diskRate = rates?.containerDiskUsdPerGbMonth;
|
|
85
|
+
const temporaryUsd = diskRate !== undefined ? round(diskGb * machines * diskRate * (hours / HOURS_PER_MONTH)) : null;
|
|
86
|
+
const volumeRate = rates?.networkVolumeUsdPerGbMonth;
|
|
87
|
+
const volumeGb = persistent ? input.cloud.volumeGb : null;
|
|
88
|
+
const volumeMonthlyUsd = persistent && volumeRate !== undefined ? round(input.cloud.volumeGb * volumeRate) : null;
|
|
89
|
+
const worstCaseBudgetedUsd = round(worstCaseComputeUsd + (temporaryUsd ?? 0));
|
|
90
|
+
const counted = [`GPU${cpu ? " and preparation-CPU" : ""} compute at catalog rates for up to ${input.maxLifetimeSeconds}s each`];
|
|
91
|
+
const notCounted = [];
|
|
92
|
+
const unknown = [];
|
|
93
|
+
if (temporaryUsd !== null)
|
|
94
|
+
counted.push(`container disk (${diskGb} GB x ${machines} machine${machines > 1 ? "s" : ""}) at the published rate`);
|
|
95
|
+
else {
|
|
96
|
+
notCounted.push(`container disk charges (${diskGb} GB x ${machines} machine${machines > 1 ? "s" : ""})`);
|
|
97
|
+
unknown.push("container disk rate is not published by the provider's catalog API");
|
|
98
|
+
}
|
|
99
|
+
if (persistent) {
|
|
100
|
+
notCounted.push(`retained network volume storage (${input.cloud.volumeGb} GB), billed monthly until deleted${volumeMonthlyUsd !== null ? `: about $${volumeMonthlyUsd}/month` : ""}`);
|
|
101
|
+
if (volumeMonthlyUsd === null)
|
|
102
|
+
unknown.push("network volume rate is not published by the provider's catalog API");
|
|
103
|
+
}
|
|
104
|
+
notCounted.push("network transfer or any other provider fees", "charges for time beyond the lifetime limit if a machine is not terminated");
|
|
105
|
+
if (worstCaseBudgetedUsd > input.maxBudgetUsd) {
|
|
106
|
+
throw rejected("CLOUD_BUDGET_EXCEEDED", `Worst-case cost counted against the budget, $${worstCaseBudgetedUsd} (${gpu.name} at $${gpu.priceUsdPerHour}/h for up to ${input.maxLifetimeSeconds}s${temporaryUsd !== null ? " plus container disk" : ""}), exceeds the $${input.maxBudgetUsd} budget.`, "Raise --max-budget, lower --max-lifetime, or choose a cheaper GPU. The budget is a guard on this estimate, not a provider-enforced cap.", { worstCaseBudgetedUsd, maxBudgetUsd: input.maxBudgetUsd });
|
|
107
|
+
}
|
|
108
|
+
const bindings = {
|
|
109
|
+
manifestDigest: digest(input.manifest),
|
|
110
|
+
inputs: input.inputs.map((file) => ({ role: file.role, sha256: file.sha256 })),
|
|
111
|
+
};
|
|
112
|
+
const id = digest({
|
|
113
|
+
v: QUOTE_VERSION,
|
|
114
|
+
provider: input.provider,
|
|
115
|
+
model: input.manifest.name,
|
|
116
|
+
modelVersion: input.manifest.modelVersion,
|
|
117
|
+
cachePolicy: input.cachePolicy,
|
|
118
|
+
gpuId: gpu.id,
|
|
119
|
+
datacenterId,
|
|
120
|
+
cpuId: cpu?.id ?? null,
|
|
121
|
+
images: input.cloud.images,
|
|
122
|
+
maxLifetimeSeconds: input.maxLifetimeSeconds,
|
|
123
|
+
maxBudgetUsd: input.maxBudgetUsd,
|
|
124
|
+
acknowledgments: requiredWarnings(persistent).map((warning) => warning.id),
|
|
125
|
+
bindings,
|
|
126
|
+
});
|
|
127
|
+
const disclosures = [
|
|
128
|
+
`Uploads ${input.inputs.length} input file(s) (${input.inputs.reduce((total, file) => total + file.sizeBytes, 0)} bytes) to ${input.provider} in ${datacenterId}.`,
|
|
129
|
+
`MoleculeDesk enforces the ${input.maxLifetimeSeconds}s lifetime limit and terminates machines only while it is running and connected, plus a best-effort watchdog inside the machine; results may be lost if the limit is reached before download.`,
|
|
130
|
+
"Capacity and price are not reserved by this quote.",
|
|
131
|
+
];
|
|
132
|
+
if (persistent) {
|
|
133
|
+
disclosures.push("A network volume is created or reused and kept after the run; its storage is billed until you delete it with `moldesk cloud cache delete`.");
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
disclosures.push("Model assets are downloaded again on every run; nothing is retained after cleanup.");
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
version: QUOTE_VERSION,
|
|
140
|
+
id,
|
|
141
|
+
provider: input.provider,
|
|
142
|
+
retrievedAt: input.catalog.retrievedAt,
|
|
143
|
+
model: input.manifest.name,
|
|
144
|
+
modelVersion: input.manifest.modelVersion,
|
|
145
|
+
cachePolicy: input.cachePolicy,
|
|
146
|
+
gpu: { id: gpu.id, name: gpu.name, memoryGb: gpu.memoryGb, priceUsdPerHour: gpu.priceUsdPerHour },
|
|
147
|
+
datacenterId,
|
|
148
|
+
...(cpu ? { cpu } : {}),
|
|
149
|
+
images: input.cloud.images,
|
|
150
|
+
upload: { files: input.inputs.length, bytes: input.inputs.reduce((total, file) => total + file.sizeBytes, 0) },
|
|
151
|
+
maxLifetimeSeconds: input.maxLifetimeSeconds,
|
|
152
|
+
maxBudgetUsd: input.maxBudgetUsd,
|
|
153
|
+
cost: {
|
|
154
|
+
worstCaseComputeUsd,
|
|
155
|
+
temporaryStorage: { diskGb, machines, worstCaseUsd: temporaryUsd },
|
|
156
|
+
recurringStorage: { recurring: persistent, volumeGb, usdPerMonth: volumeMonthlyUsd },
|
|
157
|
+
worstCaseBudgetedUsd,
|
|
158
|
+
},
|
|
159
|
+
budgetCoverage: { counted, notCounted, unknown },
|
|
160
|
+
timing: { preparation: null, inference: null, transfer: null },
|
|
161
|
+
warnings: requiredWarnings(persistent),
|
|
162
|
+
disclosures,
|
|
163
|
+
bindings,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Verifies that what was approved is what is about to be provisioned: same quote id, not expired,
|
|
168
|
+
* and no material price rise or location change since. Throws before any resource is created.
|
|
169
|
+
*/
|
|
170
|
+
export function assertApprovalValid(approved, approval, fresh, now = new Date()) {
|
|
171
|
+
if (approval.quoteId !== approved.id) {
|
|
172
|
+
throw rejected("CLOUD_APPROVAL_MISMATCH", "The approval does not match the quote.", "Review and approve the quote again.");
|
|
173
|
+
}
|
|
174
|
+
const missing = approved.warnings.filter((warning) => !approval.acknowledged?.includes(warning.id));
|
|
175
|
+
if (missing.length > 0) {
|
|
176
|
+
throw rejected("CLOUD_ACKNOWLEDGMENT_REQUIRED", `You must acknowledge: ${missing.map((warning) => warning.text).join(" ")}`, "Re-run and explicitly accept the risk (--accept-lifetime-risk, and --accept-storage-charges for persistent caching), or answer the prompts.", { missing: missing.map((warning) => warning.id) });
|
|
177
|
+
}
|
|
178
|
+
const approvedAt = Date.parse(approval.approvedAt);
|
|
179
|
+
if (!Number.isFinite(approvedAt) || approvedAt > now.getTime() + 60_000 || now.getTime() - approvedAt > APPROVAL_MAX_AGE_MS) {
|
|
180
|
+
throw rejected("CLOUD_APPROVAL_EXPIRED", "The approval has expired.", "Review and approve a fresh quote.");
|
|
181
|
+
}
|
|
182
|
+
if (fresh.id !== approved.id) {
|
|
183
|
+
throw rejected("CLOUD_REQUOTE_REQUIRED", "Inputs, model, GPU, location or limits changed since approval.", "Review and approve the new quote.", { approvedGpu: approved.gpu.id, approvedDatacenter: approved.datacenterId, freshGpu: fresh.gpu.id, freshDatacenter: fresh.datacenterId });
|
|
184
|
+
}
|
|
185
|
+
const rate = (quote) => quote.gpu.priceUsdPerHour + (quote.cpu?.priceUsdPerHour ?? 0);
|
|
186
|
+
if (rate(fresh) > rate(approved) * (1 + PRICE_TOLERANCE)) {
|
|
187
|
+
throw rejected("CLOUD_REQUOTE_REQUIRED", `The hourly price rose from $${round(rate(approved))} to $${round(rate(fresh))} since approval.`, "Review and approve the new quote.", { approvedRate: rate(approved), freshRate: rate(fresh) });
|
|
188
|
+
}
|
|
189
|
+
assertStorageAndCoverageUnchanged(approved, fresh);
|
|
190
|
+
}
|
|
191
|
+
/** What the budget does and does not cover, independent of the wording of the disclosures. */
|
|
192
|
+
function coverageShape(quote) {
|
|
193
|
+
return {
|
|
194
|
+
diskGb: quote.cost.temporaryStorage.diskGb,
|
|
195
|
+
machines: quote.cost.temporaryStorage.machines,
|
|
196
|
+
diskCounted: quote.cost.temporaryStorage.worstCaseUsd !== null,
|
|
197
|
+
recurring: quote.cost.recurringStorage.recurring,
|
|
198
|
+
volumeGb: quote.cost.recurringStorage.volumeGb,
|
|
199
|
+
volumeRateKnown: quote.cost.recurringStorage.usdPerMonth !== null,
|
|
200
|
+
unknownCount: quote.budgetCoverage.unknown.length,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Approval covers what the user saw: the same costs counted, the same costs unknown, and storage
|
|
205
|
+
* amounts no more than a tolerance higher. An unknown rate becoming known (or the reverse) changes
|
|
206
|
+
* what the budget covers, so it needs a fresh approval even if the new figure looks small.
|
|
207
|
+
*/
|
|
208
|
+
function assertStorageAndCoverageUnchanged(approved, fresh) {
|
|
209
|
+
const before = coverageShape(approved);
|
|
210
|
+
const after = coverageShape(fresh);
|
|
211
|
+
const changed = Object.keys(before).filter((key) => before[key] !== after[key]);
|
|
212
|
+
if (changed.length > 0) {
|
|
213
|
+
throw rejected("CLOUD_REQUOTE_REQUIRED", `Storage cost coverage changed since approval (${changed.join(", ")}): costs that were ${before.diskCounted && after.diskCounted ? "" : "unknown or "}uncounted may now be known, or the reverse.`, "Review the new quote, including what the budget covers, and approve it again.", { changed, approved: before, fresh: after });
|
|
214
|
+
}
|
|
215
|
+
const risen = (from, to, floor) => from !== null && to !== null && to > from * (1 + PRICE_TOLERANCE) && to - from >= floor;
|
|
216
|
+
if (risen(approved.cost.temporaryStorage.worstCaseUsd, fresh.cost.temporaryStorage.worstCaseUsd, STORAGE_NOISE_FLOOR_USD)) {
|
|
217
|
+
throw rejected("CLOUD_REQUOTE_REQUIRED", `Container disk cost rose from $${approved.cost.temporaryStorage.worstCaseUsd} to $${fresh.cost.temporaryStorage.worstCaseUsd} since approval.`, "Review and approve the new quote.", { approved: approved.cost.temporaryStorage.worstCaseUsd, fresh: fresh.cost.temporaryStorage.worstCaseUsd });
|
|
218
|
+
}
|
|
219
|
+
if (risen(approved.cost.recurringStorage.usdPerMonth, fresh.cost.recurringStorage.usdPerMonth, RECURRING_NOISE_FLOOR_USD_PER_MONTH)) {
|
|
220
|
+
throw rejected("CLOUD_REQUOTE_REQUIRED", `Recurring volume storage rose from $${approved.cost.recurringStorage.usdPerMonth}/month to $${fresh.cost.recurringStorage.usdPerMonth}/month since approval.`, "Review and approve the new quote.", { approved: approved.cost.recurringStorage.usdPerMonth, fresh: fresh.cost.recurringStorage.usdPerMonth });
|
|
221
|
+
}
|
|
222
|
+
if (risen(approved.cost.worstCaseBudgetedUsd, fresh.cost.worstCaseBudgetedUsd, STORAGE_NOISE_FLOOR_USD)) {
|
|
223
|
+
throw rejected("CLOUD_REQUOTE_REQUIRED", `The worst-case cost counted against the budget rose from $${approved.cost.worstCaseBudgetedUsd} to $${fresh.cost.worstCaseBudgetedUsd} since approval.`, "Review and approve the new quote.", { approved: approved.cost.worstCaseBudgetedUsd, fresh: fresh.cost.worstCaseBudgetedUsd });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|