@bytesbrains/cruise 0.0.0-stage → 0.12.0

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.
Files changed (41) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +59 -2
  3. package/dist/budget-FIRNSN67.mjs +132 -0
  4. package/dist/chunk-2FB2T3UI.mjs +122 -0
  5. package/dist/chunk-2NVDAK5J.mjs +76 -0
  6. package/dist/chunk-42ERHAJD.mjs +79 -0
  7. package/dist/chunk-4447IUTF.mjs +7 -0
  8. package/dist/chunk-5TVD3ZAI.mjs +69 -0
  9. package/dist/chunk-7NQHBXVZ.mjs +104 -0
  10. package/dist/chunk-BNP63KB2.mjs +42 -0
  11. package/dist/chunk-FGLMW5IY.mjs +575 -0
  12. package/dist/chunk-GUZNI3NS.mjs +267 -0
  13. package/dist/chunk-HQDQTXRX.mjs +57 -0
  14. package/dist/chunk-ICJVJSTC.mjs +42 -0
  15. package/dist/chunk-JZPUTXM7.mjs +236 -0
  16. package/dist/chunk-LALZUKEC.mjs +137 -0
  17. package/dist/chunk-N3YKWKOA.mjs +9 -0
  18. package/dist/chunk-O7TNRKSA.mjs +2 -0
  19. package/dist/chunk-RT4R2W6H.mjs +21 -0
  20. package/dist/chunk-SYTYOTO4.mjs +18 -0
  21. package/dist/chunk-T3ICD43U.mjs +15 -0
  22. package/dist/chunk-THEXUHQC.mjs +98 -0
  23. package/dist/chunk-UNXM6FSM.mjs +83 -0
  24. package/dist/chunk-WS6NFRIU.mjs +66 -0
  25. package/dist/chunk-XOH3XKM3.mjs +126 -0
  26. package/dist/compress-3DWG35BY.mjs +49 -0
  27. package/dist/conformance-NPFDYCQL.mjs +922 -0
  28. package/dist/cruise.mjs +693 -0
  29. package/dist/fallback-P5KK55M4.mjs +139 -0
  30. package/dist/keys-FWPQPMTW.mjs +127 -0
  31. package/dist/ledger-XBGWXY6C.mjs +97 -0
  32. package/dist/member-ZBV4YG2M.mjs +74 -0
  33. package/dist/report-VRYJ3W5L.mjs +414 -0
  34. package/dist/service-key-N2RZ7FMZ.mjs +59 -0
  35. package/dist/smoke-ZNYX6RO5.mjs +105 -0
  36. package/dist/sweep-SZX7OIDK.mjs +33 -0
  37. package/dist/tenant-4YDGI23Q.mjs +122 -0
  38. package/dist/tier-4L3SPLFP.mjs +116 -0
  39. package/package.json +38 -4
  40. package/templates/docker-compose.yml +66 -0
  41. package/templates/env.example +44 -0
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+ import {
4
+ assertGateway,
5
+ listTenants,
6
+ prepaidOn,
7
+ prepaySignups,
8
+ provisionTenant,
9
+ unprepaidSignups
10
+ } from "./chunk-GUZNI3NS.mjs";
11
+ import "./chunk-N3YKWKOA.mjs";
12
+ import "./chunk-2NVDAK5J.mjs";
13
+ import "./chunk-FGLMW5IY.mjs";
14
+ import "./chunk-4447IUTF.mjs";
15
+ import "./chunk-XOH3XKM3.mjs";
16
+ import "./chunk-42ERHAJD.mjs";
17
+ import "./chunk-BNP63KB2.mjs";
18
+ import "./chunk-ICJVJSTC.mjs";
19
+ import "./chunk-UNXM6FSM.mjs";
20
+ import "./chunk-SYTYOTO4.mjs";
21
+ import "./chunk-O7TNRKSA.mjs";
22
+ import "./chunk-T3ICD43U.mjs";
23
+ import "./chunk-RT4R2W6H.mjs";
24
+ import {
25
+ formatUsd,
26
+ usdToMicros
27
+ } from "./chunk-THEXUHQC.mjs";
28
+ import "./chunk-LALZUKEC.mjs";
29
+ import "./chunk-5TVD3ZAI.mjs";
30
+ import {
31
+ connect,
32
+ environmentOf,
33
+ optionalNumber,
34
+ parseArgs,
35
+ requireString,
36
+ run
37
+ } from "./chunk-7NQHBXVZ.mjs";
38
+ import "./chunk-JZPUTXM7.mjs";
39
+
40
+ // scripts/tenant.ts
41
+ function describe(tenant) {
42
+ console.log(`${tenant.id} "${tenant.name}" tier ${tenant.tier} ${tenant.billable ? "billable" : "not billable"}`);
43
+ for (const project of tenant.projects) {
44
+ const soft = project.softMicros === null ? "\u2014" : `$${formatUsd(project.softMicros)}`;
45
+ const hard = project.hardMicros === null ? "UNCAPPED" : `$${formatUsd(project.hardMicros)}`;
46
+ console.log(` project ${project.id} ${project.gateway} soft ${soft} hard ${hard} per ${project.period ?? "\u2014"}`);
47
+ if (project.hardMicros === null) console.log(` warning: ${project.id} has no hard cap and is serving uncapped.`);
48
+ }
49
+ for (const member of tenant.members) console.log(` ${member.role.padEnd(6)} ${member.uid}`);
50
+ if (tenant.members.length === 0) console.log(` warning: no members. Nobody can reach /admin for this tenant.`);
51
+ }
52
+ run(async () => {
53
+ const [command, ...rest] = process.argv.slice(2);
54
+ const args = parseArgs(rest);
55
+ const environment = environmentOf(args);
56
+ const db = connect(environment);
57
+ switch (command) {
58
+ case "provision": {
59
+ const hard = optionalNumber(args, "hard");
60
+ const soft = optionalNumber(args, "soft");
61
+ const { created, tenant, ownerConflict } = await provisionTenant(
62
+ db,
63
+ {
64
+ id: requireString(args, "id"),
65
+ name: requireString(args, "name"),
66
+ ownerUid: requireString(args, "uid"),
67
+ // Spread rather than `undefined`, because an absent flag must not be
68
+ // the same value as one that was given — `exactOptionalPropertyTypes`.
69
+ ...typeof args.gateway === "string" ? { gateway: assertGateway(args.gateway) } : {},
70
+ ...typeof args.tier === "string" ? { tier: args.tier } : {},
71
+ ...hard === null ? {} : { hardMicros: usdToMicros(hard) },
72
+ ...soft === null ? {} : { softMicros: usdToMicros(soft) }
73
+ },
74
+ requireString(args, "actor")
75
+ );
76
+ console.log(created ? `provisioned ${tenant.id}` : `${tenant.id} already exists \u2014 nothing changed`);
77
+ describe(tenant);
78
+ if (ownerConflict !== void 0) {
79
+ console.log(
80
+ ` note: ${ownerConflict.given} was granted nothing \u2014 ${tenant.id} is owned by ${ownerConflict.owners.join(", ") || "nobody"}. Grant it with: npm run member:grant`
81
+ );
82
+ }
83
+ if (created) {
84
+ console.log(`next: npm run key:issue -- --project ${tenant.projects[0]?.id} --name "<what uses it>"`);
85
+ }
86
+ break;
87
+ }
88
+ case "list": {
89
+ const tenants = await listTenants(db);
90
+ if (tenants.length === 0) {
91
+ console.log("no tenants.");
92
+ break;
93
+ }
94
+ for (const tenant of tenants) describe(tenant);
95
+ break;
96
+ }
97
+ case "prepay": {
98
+ const line = (t) => ` ${t.id} no wallet \u2014 serving without credit ${t.billable ? "billable" : "not billable"}`;
99
+ if (!prepaidOn(environment)) {
100
+ console.log(`signup on ${environment} is not prepaid, by design \u2014 nothing to do.`);
101
+ break;
102
+ }
103
+ if (args.apply !== true) {
104
+ const pending = await unprepaidSignups(db);
105
+ if (pending.length === 0) console.log("every signup tenant is prepaid \u2014 nothing to do.");
106
+ for (const tenant of pending) console.log(line(tenant));
107
+ if (pending.length > 0) console.log(`
108
+ ${pending.length} to change. Re-run with --apply --actor <you> to write them.`);
109
+ break;
110
+ }
111
+ const done = await prepaySignups(db, environment, requireString(args, "actor"));
112
+ for (const tenant of done) {
113
+ console.log(` ${tenant.id} wallet written at $0 ${tenant.billable ? "billable already" : "billable set"}`);
114
+ }
115
+ console.log(done.length === 0 ? "every signup tenant is prepaid \u2014 nothing to do." : `${done.length} prepaid.`);
116
+ break;
117
+ }
118
+ default:
119
+ console.error("usage: provision | list | prepay (see scripts/tenant.ts)");
120
+ process.exitCode = 1;
121
+ }
122
+ });
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+ import {
4
+ connect,
5
+ environmentOf,
6
+ parseArgs,
7
+ requireString,
8
+ run
9
+ } from "./chunk-7NQHBXVZ.mjs";
10
+ import "./chunk-JZPUTXM7.mjs";
11
+
12
+ // src/tiers.ts
13
+ var TierRefused = class extends Error {
14
+ constructor(code, message, over = []) {
15
+ super(message);
16
+ this.code = code;
17
+ this.over = over;
18
+ this.name = "TierRefused";
19
+ }
20
+ code;
21
+ over;
22
+ };
23
+ async function usageOf(db, tenantId) {
24
+ const row = await db.prepare(
25
+ `SELECT (SELECT count(*) FROM projects WHERE tenant_id = ?1) AS projects,
26
+ (SELECT count(*) FROM lanes WHERE tenant_id = ?1 AND archived_at IS NULL) AS lanes,
27
+ COALESCE((SELECT max(n) FROM (
28
+ SELECT count(*) AS n FROM lane_members lm
29
+ JOIN lanes l ON l.id = lm.lane_id
30
+ WHERE l.tenant_id = ?1 AND l.archived_at IS NULL
31
+ GROUP BY lm.lane_id)), 0) AS members`
32
+ ).bind(tenantId).first();
33
+ return row ?? { projects: 0, lanes: 0, members: 0 };
34
+ }
35
+ async function setTier(db, tenantId, tier, actor, via) {
36
+ const tenant = await db.prepare(`SELECT tier FROM tenants WHERE id = ?`).bind(tenantId).first();
37
+ if (tenant === null) throw new TierRefused("tenant_unknown", `No such tenant: ${tenantId}`);
38
+ const target = await db.prepare(`SELECT id, lanes, members_per_lane, projects FROM tiers WHERE id = ?`).bind(tier).first();
39
+ if (target === null) throw new TierRefused("tier_unknown", `No such tier: ${tier}`);
40
+ const usage = await usageOf(db, tenantId);
41
+ const over = [];
42
+ if (target.projects !== null && usage.projects > target.projects) {
43
+ over.push({ limit: "projects", used: usage.projects, allowed: target.projects });
44
+ }
45
+ if (target.lanes !== null && usage.lanes > target.lanes) {
46
+ over.push({ limit: "lanes", used: usage.lanes, allowed: target.lanes });
47
+ }
48
+ if (target.members_per_lane !== null && usage.members > target.members_per_lane) {
49
+ over.push({ limit: "members_per_lane", used: usage.members, allowed: target.members_per_lane });
50
+ }
51
+ if (over.length > 0) {
52
+ const said = over.map(
53
+ (o) => o.limit === "members_per_lane" ? `a lane with ${o.used} members against a limit of ${o.allowed}` : `${o.used} ${o.limit} against a limit of ${o.allowed}`
54
+ );
55
+ throw new TierRefused(
56
+ "tier_quota_exceeded",
57
+ `'${tenantId}' does not fit '${tier}': ${said.join("; ")}. Quotas are checked at the write, so this would leave the tenant in violation without failing anything.`,
58
+ over
59
+ );
60
+ }
61
+ await db.batch([
62
+ db.prepare(`UPDATE tenants SET tier = ?2 WHERE id = ?1`).bind(tenantId, tier),
63
+ db.prepare(
64
+ `INSERT INTO audit_events (id, actor, action, subject, detail, tenant_id)
65
+ VALUES (?1, ?2, 'tenant.tier', ?3, ?4, ?3)`
66
+ ).bind(
67
+ crypto.randomUUID(),
68
+ actor,
69
+ tenantId,
70
+ JSON.stringify({
71
+ from: tenant.tier,
72
+ to: tier,
73
+ usage_at_change: usage,
74
+ ...via === void 0 ? {} : { via }
75
+ })
76
+ )
77
+ ]);
78
+ return { from: tenant.tier, to: tier };
79
+ }
80
+
81
+ // scripts/tier.ts
82
+ run(async () => {
83
+ const [command, ...rest] = process.argv.slice(2);
84
+ const args = parseArgs(rest);
85
+ const db = connect(environmentOf(args));
86
+ switch (command) {
87
+ case "set": {
88
+ const tenant = requireString(args, "tenant");
89
+ const tier = requireString(args, "tier");
90
+ const actor = requireString(args, "actor");
91
+ const { from, to } = await setTier(db, tenant, tier, actor);
92
+ console.log(from === to ? `${tenant} was already on '${to}'.` : `${tenant}: '${from}' \u2192 '${to}'.`);
93
+ return;
94
+ }
95
+ case "show":
96
+ default: {
97
+ const { results } = await db.prepare(
98
+ `SELECT n.id, n.tier,
99
+ COALESCE(t.projects, -1) AS max_projects,
100
+ COALESCE(t.lanes, -1) AS max_lanes,
101
+ (SELECT count(*) FROM projects p WHERE p.tenant_id = n.id) AS projects,
102
+ (SELECT count(*) FROM lanes l WHERE l.tenant_id = n.id AND l.archived_at IS NULL) AS lanes
103
+ FROM tenants n LEFT JOIN tiers t ON t.id = n.tier
104
+ ORDER BY n.id`
105
+ ).all();
106
+ const cap = (n) => n < 0 ? "\u221E" : String(n);
107
+ console.log("tenant tier projects lanes");
108
+ for (const r of results) {
109
+ console.log(
110
+ `${r.id.padEnd(16)} ${r.tier.padEnd(12)} ${`${r.projects}/${cap(r.max_projects)}`.padEnd(13)} ${r.lanes}/${cap(r.max_lanes)}`
111
+ );
112
+ }
113
+ return;
114
+ }
115
+ }
116
+ });
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@bytesbrains/cruise",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.12.0",
4
+ "description": "Run Cruise — one OpenAI-compatible endpoint in front of every model provider, with budgets that stop — in Docker or on your own Cloudflare account.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "cruise": "dist/cruise.mjs"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://gitlab.com/bytesbrains-oss/cruise.git",
22
+ "directory": "packages/cli"
23
+ },
24
+ "homepage": "https://gitlab.com/bytesbrains-oss/cruise",
25
+ "keywords": [
26
+ "llm",
27
+ "openai",
28
+ "gateway",
29
+ "ai",
30
+ "budget",
31
+ "cloudflare",
32
+ "docker"
33
+ ],
34
+ "cruise": {
35
+ "wrangler": "4.135.0"
36
+ },
37
+ "dependencies": {
38
+ "@bytesbrains/cruise-worker": "0.12.0"
39
+ }
40
+ }
@@ -0,0 +1,66 @@
1
+ # Cruise, self-hosted. docs/self-hosting.md is the manual.
2
+ #
3
+ # cp .env.example .env # add at least one provider key
4
+ # docker compose up -d
5
+ # docker compose logs cruise | grep -A12 "Cruise is set up" # your first key, shown once
6
+ #
7
+ # Then any OpenAI SDK, base_url http://localhost:8787/v1.
8
+ #
9
+ # With a domain and HTTPS for other machines: `docker compose --profile https up -d`
10
+ # after setting CRUISE_DOMAIN in .env. Caddy gets a certificate and forwards to
11
+ # Cruise, which is then told to trust the proxy's X-Forwarded-* headers.
12
+
13
+ name: cruise
14
+
15
+ services:
16
+ cruise:
17
+ image: ${CRUISE_IMAGE:-registry.gitlab.com/bytesbrains-oss/cruise:latest}
18
+ restart: unless-stopped
19
+ env_file:
20
+ - path: .env
21
+ required: false
22
+ environment:
23
+ CRUISE_TRUST_PROXY: ${CRUISE_TRUST_PROXY:-0}
24
+ # Inside the container the listener is always 8787: CRUISE_PORT in .env
25
+ # moves the published port below, and must not move this one with it.
26
+ CRUISE_PORT: "8787"
27
+ # What the first-boot banner prints in its base_url.
28
+ CRUISE_PUBLIC_PORT: ${CRUISE_PORT:-8787}
29
+ # **Loopback only by default.** Plain HTTP is accepted only for requests
30
+ # addressed to localhost (selfhost/front.ts), and publishing on 127.0.0.1
31
+ # means nothing off this machine can connect at all. For other machines, use
32
+ # the `https` profile below rather than widening this.
33
+ ports:
34
+ - "127.0.0.1:${CRUISE_PORT:-8787}:8787"
35
+ volumes:
36
+ - cruise-data:/data
37
+ # Long enough for the drain in selfhost/server.ts: a stream in flight during
38
+ # `docker compose down` finishes rather than being cut.
39
+ stop_grace_period: 40s
40
+ read_only: true
41
+ tmpfs:
42
+ - /tmp
43
+ security_opt:
44
+ - no-new-privileges:true
45
+ cap_drop:
46
+ - ALL
47
+
48
+ caddy:
49
+ profiles: ["https"]
50
+ image: caddy:2
51
+ restart: unless-stopped
52
+ depends_on:
53
+ cruise:
54
+ condition: service_healthy
55
+ # Interpolated even when this profile is off, so it cannot be `:?`-required:
56
+ # that would stop `docker compose up` for everyone without a domain.
57
+ command: caddy reverse-proxy --from ${CRUISE_DOMAIN:-localhost} --to cruise:8787
58
+ ports:
59
+ - "80:80"
60
+ - "443:443"
61
+ volumes:
62
+ - caddy-data:/data
63
+
64
+ volumes:
65
+ cruise-data:
66
+ caddy-data:
@@ -0,0 +1,44 @@
1
+ # Self-hosted Cruise. Copy to .env, which git ignores, and fill in what you use.
2
+ # docs/self-hosting.md explains each setting.
3
+
4
+ # ── Providers ────────────────────────────────────────────────────────────────
5
+ # One key per provider you want to route to. A provider with no key here is
6
+ # simply not reachable; nothing else needs configuring.
7
+ PROVIDER_KEY_OPENAI=
8
+ PROVIDER_KEY_ANTHROPIC=
9
+ PROVIDER_KEY_GOOGLE_AI_STUDIO=
10
+ PROVIDER_KEY_DEEPSEEK=
11
+ PROVIDER_KEY_MISTRAL=
12
+ PROVIDER_KEY_GROQ=
13
+ # xAI. The variable is named after the upstream's id, `grok`.
14
+ PROVIDER_KEY_GROK=
15
+ PROVIDER_KEY_OPENROUTER=
16
+ PROVIDER_KEY_MOONSHOT=
17
+ PROVIDER_KEY_PERPLEXITY=
18
+ PROVIDER_KEY_ELEVENLABS=
19
+
20
+ # ── First boot ───────────────────────────────────────────────────────────────
21
+ # The tenant created when the database is empty, and its project's daily cap.
22
+ # Ignored once a tenant exists.
23
+ CRUISE_TENANT=local
24
+ CRUISE_TENANT_NAME=Local
25
+ CRUISE_DAILY_CAP_USD=5
26
+
27
+ # ── Network ──────────────────────────────────────────────────────────────────
28
+ # Published on 127.0.0.1 only (docker-compose.yml). For other machines, run the
29
+ # `https` profile with a domain that points here, and trust the proxy:
30
+ # CRUISE_DOMAIN=cruise.example.com
31
+ # CRUISE_TRUST_PROXY=1
32
+ CRUISE_PORT=8787
33
+ CRUISE_DOMAIN=
34
+ CRUISE_TRUST_PROXY=0
35
+
36
+ # ── Operations ───────────────────────────────────────────────────────────────
37
+ # Backups land in the data volume under backups/. 0 disables a job.
38
+ CRUISE_BACKUP_INTERVAL_HOURS=24
39
+ CRUISE_BACKUP_KEEP=7
40
+ CRUISE_SWEEP_INTERVAL_HOURS=24
41
+ CRUISE_SHUTDOWN_GRACE_SECONDS=30
42
+
43
+ # Optional: error events to your own Sentry project.
44
+ SENTRY_DSN=