@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.
- package/LICENSE +202 -0
- package/README.md +59 -2
- package/dist/budget-FIRNSN67.mjs +132 -0
- package/dist/chunk-2FB2T3UI.mjs +122 -0
- package/dist/chunk-2NVDAK5J.mjs +76 -0
- package/dist/chunk-42ERHAJD.mjs +79 -0
- package/dist/chunk-4447IUTF.mjs +7 -0
- package/dist/chunk-5TVD3ZAI.mjs +69 -0
- package/dist/chunk-7NQHBXVZ.mjs +104 -0
- package/dist/chunk-BNP63KB2.mjs +42 -0
- package/dist/chunk-FGLMW5IY.mjs +575 -0
- package/dist/chunk-GUZNI3NS.mjs +267 -0
- package/dist/chunk-HQDQTXRX.mjs +57 -0
- package/dist/chunk-ICJVJSTC.mjs +42 -0
- package/dist/chunk-JZPUTXM7.mjs +236 -0
- package/dist/chunk-LALZUKEC.mjs +137 -0
- package/dist/chunk-N3YKWKOA.mjs +9 -0
- package/dist/chunk-O7TNRKSA.mjs +2 -0
- package/dist/chunk-RT4R2W6H.mjs +21 -0
- package/dist/chunk-SYTYOTO4.mjs +18 -0
- package/dist/chunk-T3ICD43U.mjs +15 -0
- package/dist/chunk-THEXUHQC.mjs +98 -0
- package/dist/chunk-UNXM6FSM.mjs +83 -0
- package/dist/chunk-WS6NFRIU.mjs +66 -0
- package/dist/chunk-XOH3XKM3.mjs +126 -0
- package/dist/compress-3DWG35BY.mjs +49 -0
- package/dist/conformance-NPFDYCQL.mjs +922 -0
- package/dist/cruise.mjs +693 -0
- package/dist/fallback-P5KK55M4.mjs +139 -0
- package/dist/keys-FWPQPMTW.mjs +127 -0
- package/dist/ledger-XBGWXY6C.mjs +97 -0
- package/dist/member-ZBV4YG2M.mjs +74 -0
- package/dist/report-VRYJ3W5L.mjs +414 -0
- package/dist/service-key-N2RZ7FMZ.mjs +59 -0
- package/dist/smoke-ZNYX6RO5.mjs +105 -0
- package/dist/sweep-SZX7OIDK.mjs +33 -0
- package/dist/tenant-4YDGI23Q.mjs +122 -0
- package/dist/tier-4L3SPLFP.mjs +116 -0
- package/package.json +38 -4
- package/templates/docker-compose.yml +66 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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=
|