@m6d/cortex-cli 0.15.0 → 0.17.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/README.md +45 -28
- package/package.json +3 -13
- package/src/commands/apply.ts +61 -38
- package/src/commands/management.ts +64 -19
- package/src/commands/new.ts +8 -10
- package/src/commands/setup.ts +83 -38
- package/src/contracts/datasets/dataset-sql.ts +36 -2
- package/src/contracts/datasets/datasets.ts +84 -182
- package/src/contracts/egress-policy/scrub.ts +11 -2
- package/src/contracts/errors/index.ts +7 -0
- package/src/contracts/json/index.ts +40 -17
- package/src/contracts/manifest/index.ts +601 -168
- package/src/contracts/server/index.ts +44 -53
- package/src/contracts/triggers/index.ts +13 -9
- package/src/contracts/wire/index.ts +149 -27
- package/src/management/client.ts +79 -117
- package/src/management/profiles.ts +11 -22
- package/src/scaffold/files.ts +13 -11
- package/src/ui/prompts.ts +30 -9
- package/src/contracts/charts/chart-spec.ts +0 -217
- package/src/contracts/charts/index.ts +0 -8
- package/src/contracts/datasets/datasets.fixtures.json +0 -2849
- package/src/contracts/markdown/index.ts +0 -47
- package/src/contracts/pattern/index.ts +0 -276
- package/src/contracts/server/reserved-names.fixtures.json +0 -16
package/src/commands/setup.ts
CHANGED
|
@@ -12,15 +12,23 @@ import {
|
|
|
12
12
|
normalizeHost,
|
|
13
13
|
scrubCredentialStrings,
|
|
14
14
|
} from "@cortex/contracts/egress-policy";
|
|
15
|
+
import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH, SETTING_BOUNDS } from "@cortex/contracts/manifest";
|
|
15
16
|
import { z } from "zod";
|
|
16
17
|
|
|
17
18
|
import {
|
|
18
19
|
applyManifest,
|
|
19
20
|
DEFAULT_MANIFEST,
|
|
20
21
|
findManifest,
|
|
22
|
+
loadManifest,
|
|
21
23
|
printTriggerSecrets,
|
|
22
24
|
} from "@/commands/apply";
|
|
23
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
callOrFail,
|
|
27
|
+
confirmDestructive,
|
|
28
|
+
failCall,
|
|
29
|
+
requireProfile,
|
|
30
|
+
walkPages,
|
|
31
|
+
} from "@/commands/management";
|
|
24
32
|
import { callManagement, ManagementError, UnreachableError } from "@/management/client";
|
|
25
33
|
import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
|
|
26
34
|
import * as prompts from "@/ui/prompts";
|
|
@@ -34,7 +42,7 @@ const POLL_MS = 2_000;
|
|
|
34
42
|
|
|
35
43
|
/** The backend as `cortex new` writes it into .env, which Bun loads from the directory it runs in. */
|
|
36
44
|
const projectSchema = z.object({
|
|
37
|
-
CORTEX_SERVER_NAME: z.string().min(1),
|
|
45
|
+
CORTEX_SERVER_NAME: z.string().min(1).max(NAME_MAX_LENGTH),
|
|
38
46
|
CORTEX_SERVER_URL: z.url(),
|
|
39
47
|
CORTEX_SERVER_KEY: z.string().min(1),
|
|
40
48
|
});
|
|
@@ -48,14 +56,16 @@ const ENV_SAFE = /^[^\s$#'"`\\]+$/;
|
|
|
48
56
|
|
|
49
57
|
/** The model provider in .env: all four values, or none. */
|
|
50
58
|
const providerFields = z.object({
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
MODEL_PROVIDER_NAME: z.string().min(1).max(NAME_MAX_LENGTH),
|
|
60
|
+
MODEL_PROVIDER_URL: z
|
|
61
|
+
.string()
|
|
62
|
+
.max(MODEL_PROVIDER_LIMITS.urlLength)
|
|
63
|
+
.superRefine(function (value, context) {
|
|
64
|
+
const problem = checkBaseUrl(value);
|
|
65
|
+
if (problem) context.addIssue({ code: "custom", message: problem });
|
|
66
|
+
}),
|
|
57
67
|
MODEL_PROVIDER_API_KEY: z.string().min(1),
|
|
58
|
-
MODEL_PROVIDER_MODEL: z.string().min(1).max(
|
|
68
|
+
MODEL_PROVIDER_MODEL: z.string().min(1).max(MODEL_PROVIDER_LIMITS.modelLength),
|
|
59
69
|
});
|
|
60
70
|
const providerEnvSchema = providerFields.transform((env) => ({
|
|
61
71
|
name: env.MODEL_PROVIDER_NAME,
|
|
@@ -81,6 +91,8 @@ const serverSchema = namedSchema.extend({
|
|
|
81
91
|
modelProvider: z.string().nullable(),
|
|
82
92
|
modelName: z.string().nullable(),
|
|
83
93
|
modelEffort: z.string().nullable(),
|
|
94
|
+
signInChecksPerMinute: z.number(),
|
|
95
|
+
triggerChecksPerMinute: z.number(),
|
|
84
96
|
});
|
|
85
97
|
const chatSchema = z.object({ choices: z.array(z.object({ message: z.object({}) })).min(1) });
|
|
86
98
|
const embeddingsSchema = z.object({
|
|
@@ -94,8 +106,12 @@ const embeddingsSchema = z.object({
|
|
|
94
106
|
* creates what is missing and keeps what exists, so it runs again on every
|
|
95
107
|
* `bun run dev`. A provider or server under .env's name must call .env's URL,
|
|
96
108
|
* or setup leaves it alone and stops, so no key reaches a URL .env did not name. With no model provider in .env it asks for
|
|
97
|
-
* one on a terminal, and under `--yes` registers the server
|
|
98
|
-
*
|
|
109
|
+
* one on a terminal, and under `--yes` registers the server without a model
|
|
110
|
+
* and applies all the same, since an agent may name its own; when Cortex
|
|
111
|
+
* refuses an agent for want of one, setup says so and applies nothing. The
|
|
112
|
+
* apply deletes what the manifest no longer lists, so setup checks the
|
|
113
|
+
* manifest, then waits for a yes on a terminal or `--yes`, before it asks or
|
|
114
|
+
* writes anything else, as `cortex apply` does.
|
|
99
115
|
*/
|
|
100
116
|
export function registerSetup(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
101
117
|
const command = program
|
|
@@ -103,19 +119,26 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
|
|
|
103
119
|
.helpGroup("Project:")
|
|
104
120
|
.description("Register this project's backend and model in Cortex, then apply its manifest")
|
|
105
121
|
.option("--wait", "Wait for Cortex to come up first")
|
|
106
|
-
.option(
|
|
122
|
+
.option(
|
|
123
|
+
"--yes",
|
|
124
|
+
"Never ask: leave out what .env does not hold, and apply without confirming",
|
|
125
|
+
);
|
|
107
126
|
|
|
108
127
|
command.action(async function (options) {
|
|
109
128
|
const { verbose = false, profile: profileName } = program.opts();
|
|
110
129
|
const project = readProject();
|
|
111
130
|
const profile = requireProfile(profileName);
|
|
112
|
-
const
|
|
113
|
-
const interactive =
|
|
131
|
+
const manifest = await loadManifest(findManifest(DEFAULT_MANIFEST), verbose);
|
|
132
|
+
const interactive = prompts.canAsk() && options.yes !== true;
|
|
114
133
|
if (profileName === undefined) requireEnvCortex(profile.url);
|
|
115
134
|
|
|
116
135
|
report.header("cortex setup");
|
|
117
136
|
await waitForCortex(profile, options.wait === true);
|
|
118
137
|
report.input("Cortex", profile.url);
|
|
138
|
+
await confirmDestructive(
|
|
139
|
+
`Applying deletes what the manifest created on ${profile.url} and no longer lists.`,
|
|
140
|
+
options.yes,
|
|
141
|
+
);
|
|
119
142
|
|
|
120
143
|
const provider = readProvider() ?? (interactive ? await askAndSave() : undefined);
|
|
121
144
|
if (provider) await requireReachable(provider.baseUrl);
|
|
@@ -124,22 +147,26 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
|
|
|
124
147
|
const server = await ensureServer(profile, project, provider);
|
|
125
148
|
await syncServer(profile, server.id, project.CORTEX_SERVER_URL);
|
|
126
149
|
|
|
127
|
-
|
|
128
|
-
report.footer("no model yet, so nothing applied");
|
|
129
|
-
report.note("Run `bun cortex setup` in a terminal to add a model provider.");
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const { changes, triggerSecrets } = await applyManifest({
|
|
150
|
+
const applied = await applyManifest({
|
|
134
151
|
profile,
|
|
135
|
-
|
|
152
|
+
manifest,
|
|
136
153
|
dryRun: false,
|
|
137
|
-
verbose,
|
|
138
154
|
interactive,
|
|
155
|
+
command: "bun cortex apply --yes",
|
|
156
|
+
}).catch(function (error: unknown) {
|
|
157
|
+
if (error instanceof ManagementError && error.message === "chat_model_required") {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
return failCall(profile, error);
|
|
139
161
|
});
|
|
162
|
+
if (!applied) {
|
|
163
|
+
report.footer("no model yet, so nothing applied");
|
|
164
|
+
report.note("Run `bun cortex setup` in a terminal to add a model provider.");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
140
167
|
report.footer(`live · chat at ${profile.url}/playground`);
|
|
141
|
-
printTriggerSecrets(triggerSecrets);
|
|
142
|
-
if (Object.values(changes.create).flat().length > 0) printSignIn(profile.url);
|
|
168
|
+
printTriggerSecrets(applied.triggerSecrets);
|
|
169
|
+
if (Object.values(applied.changes.create).flat().length > 0) printSignIn(profile.url);
|
|
143
170
|
});
|
|
144
171
|
}
|
|
145
172
|
|
|
@@ -147,8 +174,9 @@ function readProject() {
|
|
|
147
174
|
const parsed = projectSchema.safeParse(process.env);
|
|
148
175
|
if (parsed.success) return parsed.data;
|
|
149
176
|
return report.fail({
|
|
150
|
-
what: "No project here
|
|
177
|
+
what: "No project here cortex setup can register",
|
|
151
178
|
blocks: [
|
|
179
|
+
z.prettifyError(parsed.error),
|
|
152
180
|
"It reads CORTEX_SERVER_NAME, CORTEX_SERVER_URL and CORTEX_SERVER_KEY from\n.env, which `cortex new` writes. Run it from that project's root.",
|
|
153
181
|
],
|
|
154
182
|
});
|
|
@@ -243,13 +271,15 @@ async function findByName<TItem extends typeof namedSchema>(
|
|
|
243
271
|
}
|
|
244
272
|
|
|
245
273
|
/**
|
|
246
|
-
* A secret's name for one value: its
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
274
|
+
* A secret's name for one value: its owner's name, what it is to the owner,
|
|
275
|
+
* and a short digest of the value, the owner's name cut so the whole is a
|
|
276
|
+
* name Cortex holds. A name then holds one value only, so a secret that
|
|
277
|
+
* exists is the value .env holds, and a changed key gets a secret of its own
|
|
278
|
+
* while every row presenting the old one keeps it.
|
|
250
279
|
*/
|
|
251
|
-
function secretFor(
|
|
252
|
-
|
|
280
|
+
function secretFor(owner: string, role: "api-key" | "server-key", value: string) {
|
|
281
|
+
const suffix = `-${role}-${createHash("sha256").update(value).digest("hex").slice(0, 8)}`;
|
|
282
|
+
return `${owner.slice(0, NAME_MAX_LENGTH - suffix.length)}${suffix}`;
|
|
253
283
|
}
|
|
254
284
|
|
|
255
285
|
async function ensureSecret(profile: Profile, name: string, value: string) {
|
|
@@ -272,7 +302,9 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
|
|
|
272
302
|
|
|
273
303
|
/**
|
|
274
304
|
* The provider, on its key's secret. One that exists takes a changed key and
|
|
275
|
-
* the .env model as well; one that calls another URL
|
|
305
|
+
* the .env model as well; one that calls another URL, or lists as many
|
|
306
|
+
* models as Cortex allows without the .env one, ends the run before any
|
|
307
|
+
* write.
|
|
276
308
|
*/
|
|
277
309
|
async function ensureProvider(profile: Profile, provider: Provider) {
|
|
278
310
|
const found = await findByName(profile, "model-providers", provider.name, providerRowSchema);
|
|
@@ -284,7 +316,17 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
284
316
|
"MODEL_PROVIDER_NAME",
|
|
285
317
|
);
|
|
286
318
|
}
|
|
287
|
-
|
|
319
|
+
// Added, never replaced: a model a server still runs on stays offered.
|
|
320
|
+
const models = [...new Set([...(found?.models ?? []), provider.model])];
|
|
321
|
+
if (models.length > MODEL_PROVIDER_LIMITS.models) {
|
|
322
|
+
report.fail({
|
|
323
|
+
what: `Provider ${provider.name} in Cortex lists ${MODEL_PROVIDER_LIMITS.models} models, not ${provider.model}`,
|
|
324
|
+
blocks: [
|
|
325
|
+
"Cortex lists no more on one provider. Remove one in the console, or set\nMODEL_PROVIDER_MODEL in .env to a model it lists.",
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const secretName = secretFor(provider.name, "api-key", provider.apiKey);
|
|
288
330
|
await ensureSecret(profile, secretName, provider.apiKey);
|
|
289
331
|
|
|
290
332
|
if (!found) {
|
|
@@ -292,7 +334,7 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
292
334
|
name: provider.name,
|
|
293
335
|
baseUrl: provider.baseUrl,
|
|
294
336
|
secretName,
|
|
295
|
-
models
|
|
337
|
+
models,
|
|
296
338
|
efforts: [],
|
|
297
339
|
});
|
|
298
340
|
report.input("Provider", `${provider.name} · created`);
|
|
@@ -306,8 +348,7 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
306
348
|
id: found.id,
|
|
307
349
|
baseUrl: found.baseUrl,
|
|
308
350
|
secretName,
|
|
309
|
-
|
|
310
|
-
models: [...new Set([...found.models, provider.model])],
|
|
351
|
+
models,
|
|
311
352
|
efforts: found.efforts,
|
|
312
353
|
});
|
|
313
354
|
report.input("Provider", `${provider.name} · updated`);
|
|
@@ -333,7 +374,7 @@ async function ensureServer(
|
|
|
333
374
|
if (server && server.url !== url) {
|
|
334
375
|
failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
|
|
335
376
|
}
|
|
336
|
-
const keySecretName = secretFor(
|
|
377
|
+
const keySecretName = secretFor(name, "server-key", project.CORTEX_SERVER_KEY);
|
|
337
378
|
await ensureSecret(profile, keySecretName, project.CORTEX_SERVER_KEY);
|
|
338
379
|
|
|
339
380
|
const model = {
|
|
@@ -349,6 +390,8 @@ async function ensureServer(
|
|
|
349
390
|
url,
|
|
350
391
|
keySecretName,
|
|
351
392
|
...model,
|
|
393
|
+
signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
|
|
394
|
+
triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
|
|
352
395
|
}),
|
|
353
396
|
);
|
|
354
397
|
report.input("Server", `${name} · created`);
|
|
@@ -374,6 +417,8 @@ async function ensureServer(
|
|
|
374
417
|
modelName: server.modelName,
|
|
375
418
|
modelEffort: server.modelEffort,
|
|
376
419
|
}),
|
|
420
|
+
signInChecksPerMinute: server.signInChecksPerMinute,
|
|
421
|
+
triggerChecksPerMinute: server.triggerChecksPerMinute,
|
|
377
422
|
}),
|
|
378
423
|
);
|
|
379
424
|
report.input("Server", `${name} · updated`);
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import {
|
|
19
19
|
DATASET_BUCKETS,
|
|
20
20
|
DATASET_DATE_PARTS,
|
|
21
|
+
DATASET_REQUEST_MAX_BYTES,
|
|
21
22
|
describeOperand,
|
|
22
23
|
groupKeyName,
|
|
23
24
|
unknownColumnError,
|
|
@@ -278,6 +279,15 @@ const WINDOW_PLACE =
|
|
|
278
279
|
"RANK() goes in QUALIFY (QUALIFY RANK() OVER (PARTITION BY a ORDER BY b DESC) <= n) or in a derived table filtered on its alias (FROM (SELECT ..., RANK() OVER (...) AS rn FROM ...) WHERE rn <= n).";
|
|
279
280
|
const INTERVAL_PLACE =
|
|
280
281
|
"INTERVAL only shifts CURRENT_DATE (CURRENT_DATE - INTERVAL '7 days'); compare the column against a date instead.";
|
|
282
|
+
const TOO_LARGE =
|
|
283
|
+
"The statement grows too large to send: a simple CASE repeats its subject in every WHEN, so a CASE nested in that subject multiplies. Compare a column instead, or ask in smaller queries.";
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The most tokens a statement may read, a simple CASE's subject counted again
|
|
287
|
+
* for every WHEN that repeats it. A token writes about a byte or more of the
|
|
288
|
+
* wire query, so a statement past this could never be sent.
|
|
289
|
+
*/
|
|
290
|
+
const EXPANDED_TOKENS_MAX = DATASET_REQUEST_MAX_BYTES;
|
|
281
291
|
|
|
282
292
|
const REFUSED_CLAUSES = {
|
|
283
293
|
UNION: "UNION is not supported: ask each dataset in its own query.",
|
|
@@ -402,6 +412,8 @@ function datePartTerm(unit: DatasetDatePart, of: Term) {
|
|
|
402
412
|
|
|
403
413
|
class Parser {
|
|
404
414
|
private position = 0;
|
|
415
|
+
/** Tokens read so far, a simple CASE's subject counted once per WHEN: how large the statement grows once written out. */
|
|
416
|
+
private expanded = 0;
|
|
405
417
|
|
|
406
418
|
public constructor(
|
|
407
419
|
private readonly tokens: Token[],
|
|
@@ -769,11 +781,15 @@ class Parser {
|
|
|
769
781
|
*/
|
|
770
782
|
private caseTerm() {
|
|
771
783
|
this.take();
|
|
784
|
+
const start = this.expanded;
|
|
772
785
|
const subject = this.peekWord("WHEN")
|
|
773
786
|
? undefined
|
|
774
787
|
: this.term("CASE is followed by WHEN, or by a value to compare.");
|
|
788
|
+
const subjectSize = this.expanded - start;
|
|
775
789
|
const branches: { when: Predicate[]; then: Term }[] = [];
|
|
776
790
|
while (this.optionalWord("WHEN")) {
|
|
791
|
+
// The wire has no simple CASE: every WHEN after the first writes the subject out again.
|
|
792
|
+
if (branches.length > 0) this.expand(subjectSize);
|
|
777
793
|
const when = subject ? [this.simpleCaseMatch(subject)] : this.predicates();
|
|
778
794
|
this.expectWord("THEN", "CASE WHEN condition needs THEN value.");
|
|
779
795
|
if (this.peekWord("NULL")) {
|
|
@@ -1230,8 +1246,14 @@ class Parser {
|
|
|
1230
1246
|
private take() {
|
|
1231
1247
|
const token = this.peek();
|
|
1232
1248
|
this.position += 1;
|
|
1249
|
+
this.expand(1);
|
|
1233
1250
|
return token;
|
|
1234
1251
|
}
|
|
1252
|
+
/** Counts `tokens` toward the statement's written-out size, refused the moment it passes what a request carries. */
|
|
1253
|
+
private expand(tokens: number) {
|
|
1254
|
+
this.expanded += tokens;
|
|
1255
|
+
if (this.expanded > EXPANDED_TOKENS_MAX) throw new DatasetSqlError(TOO_LARGE);
|
|
1256
|
+
}
|
|
1235
1257
|
private peekWord(upper: string) {
|
|
1236
1258
|
const token = this.peek();
|
|
1237
1259
|
return token.kind === "word" && token.upper === upper;
|
|
@@ -1334,11 +1356,16 @@ function unqualified(ref: string, table: string | undefined) {
|
|
|
1334
1356
|
return table !== undefined && ref.startsWith(`${table}.`) ? ref.slice(table.length + 1) : ref;
|
|
1335
1357
|
}
|
|
1336
1358
|
|
|
1337
|
-
/**
|
|
1359
|
+
/**
|
|
1360
|
+
* The outer list of a derived table: `*` keeps every inner value, names keep
|
|
1361
|
+
* those, each once since every listing writes the value out again; the
|
|
1362
|
+
* RANK() itself is never answered.
|
|
1363
|
+
*/
|
|
1338
1364
|
function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | undefined) {
|
|
1339
1365
|
const values = inner.filter((item) => item.kind !== "rankWindow");
|
|
1340
1366
|
if (outer.some((item) => item.kind === "star")) return values;
|
|
1341
1367
|
const windowNames = inner.flatMap((item) => (item.kind === "rankWindow" ? [item.alias] : []));
|
|
1368
|
+
const listed = new Set<SelectItem>();
|
|
1342
1369
|
const kept = outer.flatMap((item) => {
|
|
1343
1370
|
const name =
|
|
1344
1371
|
item.kind === "column" && item.alias === undefined
|
|
@@ -1352,6 +1379,12 @@ function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | unde
|
|
|
1352
1379
|
"The outer SELECT of a derived table lists names the inner SELECT answers, or *.",
|
|
1353
1380
|
);
|
|
1354
1381
|
}
|
|
1382
|
+
if (listed.has(found)) {
|
|
1383
|
+
throw new DatasetSqlError(
|
|
1384
|
+
`The outer SELECT lists "${name}" twice; list each value of the derived table once.`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
listed.add(found);
|
|
1355
1388
|
return [found];
|
|
1356
1389
|
});
|
|
1357
1390
|
const unnamed = values.flatMap((value) =>
|
|
@@ -2082,7 +2115,8 @@ function resolveGroupKeys(
|
|
|
2082
2115
|
key = twin ?? { expr, as: lastSegment(column ?? "") };
|
|
2083
2116
|
}
|
|
2084
2117
|
const taken = keys.get(groupKeyName(key));
|
|
2085
|
-
|
|
2118
|
+
// A name grouped again is the very same key; only a different one is written out to compare.
|
|
2119
|
+
if (taken && taken !== key && !same(operandOf(taken), operandOf(key))) {
|
|
2086
2120
|
throw new DatasetSqlError(
|
|
2087
2121
|
`Two group keys would answer as "${groupKeyName(key)}"; name each value: ${describeTerm(group)} AS name.`,
|
|
2088
2122
|
);
|