@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.
@@ -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 { callOrFail, failCall, requireProfile, walkPages } from "@/commands/management";
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
- // Its key's secret is `<name>-api-key-<digest>`, within Cortex's 120 characters.
52
- MODEL_PROVIDER_NAME: z.string().min(1).max(103),
53
- MODEL_PROVIDER_URL: z.string().superRefine(function (value, context) {
54
- const problem = checkBaseUrl(value);
55
- if (problem) context.addIssue({ code: "custom", message: problem });
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(200),
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 and applies
98
- * nothing, since Cortex publishes no agent without a model.
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("--yes", "Never ask; leave out what .env does not hold");
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 file = findManifest(DEFAULT_MANIFEST);
113
- const interactive = process.stdin.isTTY === true && options.yes !== true;
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
- if (server.modelProvider === null) {
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
- file,
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 for cortex setup",
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 base and a short digest of the value.
247
- * A name then holds one value only, so a secret that exists is the value
248
- * .env holds, and a changed key gets a secret of its own while every row
249
- * presenting the old one keeps it.
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(base: string, value: string) {
252
- return `${base}-${createHash("sha256").update(value).digest("hex").slice(0, 8)}`;
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 ends the run.
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
- const secretName = secretFor(`${provider.name}-api-key`, provider.apiKey);
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: [provider.model],
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
- // Added, never replaced: a model a server still runs on stays offered.
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(`${name}-server-key`, project.CORTEX_SERVER_KEY);
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
- /** The outer list of a derived table: `*` keeps every inner value, names keep those; the RANK() itself is never answered. */
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
- if (taken && !same(operandOf(taken), operandOf(key))) {
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
  );