@m6d/cortex-cli 0.12.1 → 0.14.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 +193 -128
- package/package.json +16 -5
- package/src/cli.ts +19 -12
- package/src/commands/apply.ts +186 -0
- package/src/commands/{cc.ts → management.ts} +173 -112
- package/src/commands/new.ts +83 -84
- package/src/commands/setup.ts +610 -0
- package/src/contracts/charts/chart-spec.ts +1 -1
- package/src/contracts/client-tools/index.ts +3 -3
- package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
- package/src/contracts/datasets/index.ts +3 -0
- package/src/contracts/egress-policy/index.ts +16 -0
- package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
- package/src/contracts/{egress → egress-policy}/url.ts +22 -9
- package/src/contracts/errors/index.ts +38 -0
- package/src/contracts/json/index.ts +5 -0
- package/src/contracts/manifest/index.ts +973 -0
- package/src/contracts/manifest-file/index.ts +16 -0
- package/src/contracts/markdown/index.ts +1 -1
- package/src/contracts/pattern/index.ts +276 -0
- package/src/contracts/server/index.ts +349 -0
- package/src/contracts/server/reserved-names.fixtures.json +18 -0
- package/src/contracts/triggers/index.ts +112 -0
- package/src/contracts/wire/index.ts +67 -33
- package/src/{cc → management}/client.ts +33 -7
- package/src/{cc → management}/flags.ts +2 -1
- package/src/{cc → management}/profiles.ts +10 -8
- package/src/scaffold/files.ts +448 -284
- package/src/scaffold/ports.ts +12 -24
- package/src/scaffold/templates/cspell.json.txt +41 -0
- package/src/scaffold/templates/datasets.ts.txt +52 -0
- package/src/scaffold/templates/dockerignore.txt +6 -0
- package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
- package/src/scaffold/templates/gitignore.txt +1 -1
- package/src/scaffold/templates/server.ts.txt +40 -0
- package/src/scaffold/templates/setup.ts.txt +44 -0
- package/src/ui/prompts.ts +24 -38
- package/src/ui/report.ts +5 -15
- package/tsconfig.json +2 -3
- package/src/commands/graph.ts +0 -228
- package/src/commands/swagger.ts +0 -247
- package/src/config/load.ts +0 -122
- package/src/config/validate.ts +0 -46
- package/src/contracts/credentials/index.ts +0 -183
- package/src/contracts/egress/index.ts +0 -120
- package/src/contracts/graph/clients/embed.ts +0 -62
- package/src/contracts/graph/clients/neo4j.ts +0 -85
- package/src/contracts/graph/helpers.ts +0 -55
- package/src/contracts/graph/index.ts +0 -30
- package/src/contracts/graph/schema.ts +0 -65
- package/src/contracts/graph/types.ts +0 -125
- package/src/contracts/rich-text/index.ts +0 -220
- package/src/contracts/runtime/api.ts +0 -345
- package/src/contracts/runtime/auth.ts +0 -41
- package/src/contracts/runtime/grammar.ts +0 -169
- package/src/contracts/runtime/index.ts +0 -15
- package/src/contracts/runtime/models.ts +0 -50
- package/src/contracts/runtime/signature.ts +0 -255
- package/src/contracts/runtime/sync.ts +0 -196
- package/src/contracts/runtime/triggers.ts +0 -267
- package/src/graph/expand-domains.ts +0 -286
- package/src/graph/generate-cypher.ts +0 -201
- package/src/graph/seed.ts +0 -199
- package/src/graph/validate.ts +0 -78
- package/src/scaffold/features.ts +0 -190
- package/src/scaffold/templates/domains-README.md.txt +0 -16
- package/src/scaffold/templates/index.ts.txt +0 -18
- package/src/swagger/extract-endpoints.ts +0 -269
- package/src/swagger/generated-block.ts +0 -74
- package/src/swagger/openapi-schema.ts +0 -168
- package/src/swagger/parse-response.ts +0 -88
- package/src/swagger/types.ts +0 -25
- /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
- /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
- /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
- /package/src/{cc → management}/output.ts +0 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
import type { Command } from "@commander-js/extra-typings";
|
|
6
|
+
import {
|
|
7
|
+
canCarrySecret,
|
|
8
|
+
credentialForms,
|
|
9
|
+
egressAllowedHosts,
|
|
10
|
+
isEgressAllowedHost,
|
|
11
|
+
isPrivateAddress,
|
|
12
|
+
normalizeHost,
|
|
13
|
+
scrubCredentialStrings,
|
|
14
|
+
} from "@cortex/contracts/egress-policy";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
applyManifest,
|
|
19
|
+
DEFAULT_MANIFEST,
|
|
20
|
+
findManifest,
|
|
21
|
+
printTriggerSecrets,
|
|
22
|
+
} from "@/commands/apply";
|
|
23
|
+
import { callOrFail, failCall, requireProfile, walkPages } from "@/commands/management";
|
|
24
|
+
import { callManagement, ManagementError, UnreachableError } from "@/management/client";
|
|
25
|
+
import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
|
|
26
|
+
import * as prompts from "@/ui/prompts";
|
|
27
|
+
import * as report from "@/ui/report";
|
|
28
|
+
|
|
29
|
+
/** Long enough for a first `docker compose up` to pull the image and migrate. */
|
|
30
|
+
const CORTEX_WAIT_MS = 10 * 60_000;
|
|
31
|
+
/** How long Cortex gets to reach a backend that is still starting. */
|
|
32
|
+
const SYNC_WAIT_MS = 60_000;
|
|
33
|
+
const POLL_MS = 2_000;
|
|
34
|
+
|
|
35
|
+
/** The backend as `cortex new` writes it into .env, which Bun loads from the directory it runs in. */
|
|
36
|
+
const projectSchema = z.object({
|
|
37
|
+
CORTEX_SERVER_NAME: z.string().min(1),
|
|
38
|
+
CORTEX_SERVER_URL: z.url(),
|
|
39
|
+
CORTEX_SERVER_KEY: z.string().min(1),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What .env keeps as typed: Bun and Compose expand `$`, cut at `#`, and strip
|
|
44
|
+
* quotes and spaces. Checked on answers before they are written; a value
|
|
45
|
+
* edited into .env by hand reaches setup already parsed, past checking.
|
|
46
|
+
*/
|
|
47
|
+
const ENV_SAFE = /^[^\s$#'"`\\]+$/;
|
|
48
|
+
|
|
49
|
+
/** The model provider in .env: all four values, or none. */
|
|
50
|
+
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
|
+
}),
|
|
57
|
+
MODEL_PROVIDER_API_KEY: z.string().min(1),
|
|
58
|
+
MODEL_PROVIDER_MODEL: z.string().min(1).max(200),
|
|
59
|
+
});
|
|
60
|
+
const providerEnvSchema = providerFields.transform((env) => ({
|
|
61
|
+
name: env.MODEL_PROVIDER_NAME,
|
|
62
|
+
baseUrl: env.MODEL_PROVIDER_URL,
|
|
63
|
+
apiKey: env.MODEL_PROVIDER_API_KEY,
|
|
64
|
+
model: env.MODEL_PROVIDER_MODEL,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
type Provider = z.output<typeof providerEnvSchema>;
|
|
68
|
+
type Profile = ReturnType<typeof requireProfile>;
|
|
69
|
+
|
|
70
|
+
const namedSchema = z.object({ id: z.string(), name: z.string() });
|
|
71
|
+
const providerRowSchema = namedSchema.extend({
|
|
72
|
+
baseUrl: z.string(),
|
|
73
|
+
secretName: z.string(),
|
|
74
|
+
models: z.array(z.string()),
|
|
75
|
+
efforts: z.array(z.string()),
|
|
76
|
+
});
|
|
77
|
+
const serverSchema = namedSchema.extend({
|
|
78
|
+
description: z.string().nullable(),
|
|
79
|
+
url: z.string().nullable(),
|
|
80
|
+
keySecretName: z.string().nullable(),
|
|
81
|
+
modelProvider: z.string().nullable(),
|
|
82
|
+
modelName: z.string().nullable(),
|
|
83
|
+
modelEffort: z.string().nullable(),
|
|
84
|
+
});
|
|
85
|
+
const chatSchema = z.object({ choices: z.array(z.object({ message: z.object({}) })).min(1) });
|
|
86
|
+
const embeddingsSchema = z.object({
|
|
87
|
+
data: z.array(z.object({ embedding: z.array(z.number()).length(EMBEDDING.dimension) })).min(1),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `cortex setup`: registers this project's backend in its Cortex and applies
|
|
92
|
+
* its manifest, in the one order Cortex accepts: the model provider and its
|
|
93
|
+
* key, the server and its key, its first sync, then the apply. Every step
|
|
94
|
+
* creates what is missing and keeps what exists, so it runs again on every
|
|
95
|
+
* `bun run dev`. A provider or server under .env's name must call .env's URL,
|
|
96
|
+
* 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.
|
|
99
|
+
*/
|
|
100
|
+
export function registerSetup(program: Command<[], { verbose?: true; profile?: string }>) {
|
|
101
|
+
const command = program
|
|
102
|
+
.command("setup")
|
|
103
|
+
.helpGroup("Project:")
|
|
104
|
+
.description("Register this project's backend and model in Cortex, then apply its manifest")
|
|
105
|
+
.option("--wait", "Wait for Cortex to come up first")
|
|
106
|
+
.option("--yes", "Never ask; leave out what .env does not hold");
|
|
107
|
+
|
|
108
|
+
command.action(async function (options) {
|
|
109
|
+
const { verbose = false, profile: profileName } = program.opts();
|
|
110
|
+
const project = readProject();
|
|
111
|
+
const profile = requireProfile(profileName);
|
|
112
|
+
const file = findManifest(DEFAULT_MANIFEST);
|
|
113
|
+
const interactive = process.stdin.isTTY === true && options.yes !== true;
|
|
114
|
+
if (profileName === undefined) requireEnvCortex(profile.url);
|
|
115
|
+
|
|
116
|
+
report.header("cortex setup");
|
|
117
|
+
await waitForCortex(profile, options.wait === true);
|
|
118
|
+
report.input("Cortex", profile.url);
|
|
119
|
+
|
|
120
|
+
const provider = readProvider() ?? (interactive ? await askAndSave() : undefined);
|
|
121
|
+
if (provider) await requireReachable(provider.baseUrl);
|
|
122
|
+
|
|
123
|
+
if (provider) await ensureProvider(profile, provider);
|
|
124
|
+
const server = await ensureServer(profile, project, provider);
|
|
125
|
+
await syncServer(profile, server.id, project.CORTEX_SERVER_URL);
|
|
126
|
+
|
|
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({
|
|
134
|
+
profile,
|
|
135
|
+
file,
|
|
136
|
+
dryRun: false,
|
|
137
|
+
verbose,
|
|
138
|
+
});
|
|
139
|
+
report.footer(`live · chat at ${profile.url}/playground`);
|
|
140
|
+
printTriggerSecrets(triggerSecrets);
|
|
141
|
+
if (Object.values(changes.create).flat().length > 0) printSignIn(profile.url);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readProject() {
|
|
146
|
+
const parsed = projectSchema.safeParse(process.env);
|
|
147
|
+
if (parsed.success) return parsed.data;
|
|
148
|
+
return report.fail({
|
|
149
|
+
what: "No project here for cortex setup",
|
|
150
|
+
blocks: [
|
|
151
|
+
"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.",
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The Cortex .env names is the one setup sets up, unless `-p` picks another.
|
|
158
|
+
* Bun lets a CORTEX_URL exported in the shell win over .env, and one left
|
|
159
|
+
* there for another deployment must not get this project's secrets. .env is
|
|
160
|
+
* read by a child Bun given this process's environment without the pair, so
|
|
161
|
+
* it picks the same files and expands `${VAR}` from the same values.
|
|
162
|
+
*/
|
|
163
|
+
function requireEnvCortex(url: string) {
|
|
164
|
+
if (!existsSync(".env")) return;
|
|
165
|
+
const child = Bun.spawnSync(
|
|
166
|
+
[process.execPath, "-e", "process.stdout.write(process.env.CORTEX_URL ?? '')"],
|
|
167
|
+
{
|
|
168
|
+
env: Object.fromEntries(
|
|
169
|
+
Object.entries(process.env).filter(
|
|
170
|
+
([name]) => name !== "CORTEX_URL" && name !== "CORTEX_KEY",
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const named = child.stdout.toString().replace(/\/+$/, "");
|
|
176
|
+
if (named === "" || named === url) return;
|
|
177
|
+
report.fail({
|
|
178
|
+
what: `CORTEX_URL in your shell (${url}) wins over .env's (${named})`,
|
|
179
|
+
blocks: [
|
|
180
|
+
"cortex setup sets up the Cortex .env names. Unset the shell's pair, or pick a\nsaved profile with -p:",
|
|
181
|
+
" unset CORTEX_URL CORTEX_KEY",
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The model provider in .env, while every value is blank none; a partial or invalid one ends the run. */
|
|
187
|
+
function readProvider() {
|
|
188
|
+
if (providerFields.keyof().options.every((key) => !process.env[key])) return undefined;
|
|
189
|
+
const parsed = providerEnvSchema.safeParse(process.env);
|
|
190
|
+
if (parsed.success) return parsed.data;
|
|
191
|
+
return report.fail({
|
|
192
|
+
what: "MODEL_PROVIDER_* in .env is not a model provider setup can use",
|
|
193
|
+
blocks: [z.prettifyError(parsed.error), "Fill in all four values, or clear all four."],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Asks until Cortex answers, under `wait`; once otherwise. A refusal ends the
|
|
199
|
+
* run at once: only a Cortex that is not up yet is worth waiting for. The
|
|
200
|
+
* last failure ends it through `failCall`'s grammar.
|
|
201
|
+
*/
|
|
202
|
+
async function waitForCortex(profile: Profile, wait: boolean) {
|
|
203
|
+
const deadline = Date.now() + (wait ? CORTEX_WAIT_MS : 0);
|
|
204
|
+
let announced = false;
|
|
205
|
+
for (;;) {
|
|
206
|
+
try {
|
|
207
|
+
await callManagement(profile, "get", "/secrets/list", { pageSize: 1 });
|
|
208
|
+
return;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const starting =
|
|
211
|
+
error instanceof UnreachableError ||
|
|
212
|
+
(error instanceof ManagementError && error.status >= 500);
|
|
213
|
+
if (!starting || Date.now() >= deadline) failCall(profile, error);
|
|
214
|
+
if (!announced) {
|
|
215
|
+
report.row("", `Waiting for ${profile.url}. A first start pulls the image.`);
|
|
216
|
+
announced = true;
|
|
217
|
+
}
|
|
218
|
+
await Bun.sleep(POLL_MS);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function call(profile: Profile, method: "get" | "post", path: string, input: unknown) {
|
|
224
|
+
return callOrFail(profile, () => callManagement(profile, method, path, input));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The entry named exactly `name`, across every page; a listing's search matches any part of it. */
|
|
228
|
+
async function findByName<TItem extends typeof namedSchema>(
|
|
229
|
+
profile: Profile,
|
|
230
|
+
router: string,
|
|
231
|
+
name: string,
|
|
232
|
+
item: TItem,
|
|
233
|
+
) {
|
|
234
|
+
const { items } = z
|
|
235
|
+
.object({ items: z.array(item) })
|
|
236
|
+
.parse(
|
|
237
|
+
await callOrFail(profile, () =>
|
|
238
|
+
walkPages(profile, "get", `/${router}/list`, { q: name, pageSize: 100 }),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
return items.find((entry) => entry.name === name);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A secret's name for one value: its base and a short digest of the value.
|
|
246
|
+
* A name then holds one value only, so a secret that exists is the value
|
|
247
|
+
* .env holds, and a changed key gets a secret of its own while every row
|
|
248
|
+
* presenting the old one keeps it.
|
|
249
|
+
*/
|
|
250
|
+
function secretFor(base: string, value: string) {
|
|
251
|
+
return `${base}-${createHash("sha256").update(value).digest("hex").slice(0, 8)}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function ensureSecret(profile: Profile, name: string, value: string) {
|
|
255
|
+
const found = await findByName(profile, "secrets", name, namedSchema);
|
|
256
|
+
if (!found) await call(profile, "post", "/secrets/set", { name, value });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A row under the name .env gives that calls another URL belongs to
|
|
261
|
+
* something else: setup changes none of it and sends it no key.
|
|
262
|
+
*/
|
|
263
|
+
function failCallsElsewhere(what: string, calls: string | null, wanted: string, rename: string) {
|
|
264
|
+
return report.fail({
|
|
265
|
+
what: `${what} in Cortex calls ${calls ?? "no URL"}, not .env's ${wanted}`,
|
|
266
|
+
blocks: [
|
|
267
|
+
`It belongs to something else, so setup leaves it alone. Give this project's\nanother ${rename} in .env.`,
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The provider, on its key's secret. One that exists takes a changed key and
|
|
274
|
+
* the .env model as well; one that calls another URL ends the run.
|
|
275
|
+
*/
|
|
276
|
+
async function ensureProvider(profile: Profile, provider: Provider) {
|
|
277
|
+
const found = await findByName(profile, "model-providers", provider.name, providerRowSchema);
|
|
278
|
+
if (found && found.baseUrl !== provider.baseUrl) {
|
|
279
|
+
failCallsElsewhere(
|
|
280
|
+
`Provider ${provider.name}`,
|
|
281
|
+
found.baseUrl,
|
|
282
|
+
provider.baseUrl,
|
|
283
|
+
"MODEL_PROVIDER_NAME",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const secretName = secretFor(`${provider.name}-api-key`, provider.apiKey);
|
|
287
|
+
await ensureSecret(profile, secretName, provider.apiKey);
|
|
288
|
+
|
|
289
|
+
if (!found) {
|
|
290
|
+
await call(profile, "post", "/model-providers/create", {
|
|
291
|
+
name: provider.name,
|
|
292
|
+
baseUrl: provider.baseUrl,
|
|
293
|
+
secretName,
|
|
294
|
+
models: [provider.model],
|
|
295
|
+
efforts: [],
|
|
296
|
+
});
|
|
297
|
+
report.input("Provider", `${provider.name} · created`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (found.secretName === secretName && found.models.includes(provider.model)) {
|
|
301
|
+
report.input("Provider", `${provider.name} · kept`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await call(profile, "post", "/model-providers/update", {
|
|
305
|
+
id: found.id,
|
|
306
|
+
baseUrl: found.baseUrl,
|
|
307
|
+
secretName,
|
|
308
|
+
// Added, never replaced: a model a server still runs on stays offered.
|
|
309
|
+
models: [...new Set([...found.models, provider.model])],
|
|
310
|
+
efforts: found.efforts,
|
|
311
|
+
});
|
|
312
|
+
report.input("Provider", `${provider.name} · updated`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The server, on its key's secret and created on the provider's model. One
|
|
317
|
+
* that exists takes a changed key, and the provider's model when it has
|
|
318
|
+
* none, which is how a provider added later reaches its agents; one that
|
|
319
|
+
* calls another URL ends the run.
|
|
320
|
+
*/
|
|
321
|
+
async function ensureServer(
|
|
322
|
+
profile: Profile,
|
|
323
|
+
project: z.output<typeof projectSchema>,
|
|
324
|
+
provider: Provider | undefined,
|
|
325
|
+
) {
|
|
326
|
+
const name = project.CORTEX_SERVER_NAME;
|
|
327
|
+
const url = `${project.CORTEX_SERVER_URL}/cortex`;
|
|
328
|
+
const found = await findByName(profile, "servers", name, namedSchema);
|
|
329
|
+
const server = found
|
|
330
|
+
? serverSchema.parse(await call(profile, "get", "/servers/get", { id: found.id }))
|
|
331
|
+
: undefined;
|
|
332
|
+
if (server && server.url !== url) {
|
|
333
|
+
failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
|
|
334
|
+
}
|
|
335
|
+
const keySecretName = secretFor(`${name}-server-key`, project.CORTEX_SERVER_KEY);
|
|
336
|
+
await ensureSecret(profile, keySecretName, project.CORTEX_SERVER_KEY);
|
|
337
|
+
|
|
338
|
+
const model = {
|
|
339
|
+
modelProvider: provider?.name ?? null,
|
|
340
|
+
modelName: provider?.model ?? null,
|
|
341
|
+
modelEffort: null,
|
|
342
|
+
};
|
|
343
|
+
if (!server) {
|
|
344
|
+
const created = serverSchema.parse(
|
|
345
|
+
await call(profile, "post", "/servers/create", {
|
|
346
|
+
name,
|
|
347
|
+
description: "",
|
|
348
|
+
url,
|
|
349
|
+
keySecretName,
|
|
350
|
+
...model,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
report.input("Server", `${name} · created`);
|
|
354
|
+
return created;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const takesModel = server.modelProvider === null && provider !== undefined;
|
|
358
|
+
if (server.keySecretName === keySecretName && !takesModel) {
|
|
359
|
+
report.input("Server", `${name} · kept`);
|
|
360
|
+
return server;
|
|
361
|
+
}
|
|
362
|
+
const updated = serverSchema.parse(
|
|
363
|
+
await call(profile, "post", "/servers/update", {
|
|
364
|
+
id: server.id,
|
|
365
|
+
name,
|
|
366
|
+
description: server.description ?? "",
|
|
367
|
+
url,
|
|
368
|
+
keySecretName,
|
|
369
|
+
...(takesModel
|
|
370
|
+
? model
|
|
371
|
+
: {
|
|
372
|
+
modelProvider: server.modelProvider,
|
|
373
|
+
modelName: server.modelName,
|
|
374
|
+
modelEffort: server.modelEffort,
|
|
375
|
+
}),
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
report.input("Server", `${name} · updated`);
|
|
379
|
+
return updated;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Reads the backend's manifest into Cortex, which the apply needs: the
|
|
384
|
+
* agent's prompt reads a variable only a synced server declares. A backend
|
|
385
|
+
* Cortex cannot reach yet is asked again until `SYNC_WAIT_MS` passes.
|
|
386
|
+
*/
|
|
387
|
+
async function syncServer(profile: Profile, id: string, url: string) {
|
|
388
|
+
const deadline = Date.now() + SYNC_WAIT_MS;
|
|
389
|
+
for (;;) {
|
|
390
|
+
try {
|
|
391
|
+
await callManagement(profile, "post", "/servers/sync", { id });
|
|
392
|
+
report.input("Synced", `${url}/cortex`);
|
|
393
|
+
return;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
// Only a backend Cortex cannot reach yet is worth asking again; a
|
|
396
|
+
// refused manifest or blocked host fails the same way next time.
|
|
397
|
+
const starting =
|
|
398
|
+
error instanceof ManagementError && error.message === "upstream_failed";
|
|
399
|
+
if (starting && Date.now() < deadline) {
|
|
400
|
+
await Bun.sleep(POLL_MS);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (!starting) failCall(profile, error);
|
|
404
|
+
report.fail({
|
|
405
|
+
what: `Cortex could not reach ${url}/cortex`,
|
|
406
|
+
error,
|
|
407
|
+
blocks: [
|
|
408
|
+
"Nothing was applied. Check that the backend is running (`bun run dev:server`),\nthen run setup again.",
|
|
409
|
+
],
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Asked on a terminal when .env names no provider, then kept in .env for every later run. */
|
|
416
|
+
async function askAndSave() {
|
|
417
|
+
const { provider, env } = await askProvider();
|
|
418
|
+
if (existsSync(".env")) writeFileSync(".env", withEnvValues(readFileSync(".env", "utf8"), env));
|
|
419
|
+
if ("EMBEDDING_API_KEY" in env) {
|
|
420
|
+
report.note("Restart cortex in `bun run dev` so Cortex embeds with this provider too.");
|
|
421
|
+
}
|
|
422
|
+
return provider;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The model provider's details, asked one at a time, then checked with one
|
|
427
|
+
* chat message to the model. A provider that fails the check is kept only if
|
|
428
|
+
* the user says so; otherwise it is asked again. Returns the provider and the
|
|
429
|
+
* .env lines that hold it, the embedding settings too when the provider
|
|
430
|
+
* serves the stack's embedding model.
|
|
431
|
+
*/
|
|
432
|
+
export async function askProvider() {
|
|
433
|
+
for (;;) {
|
|
434
|
+
const name = (
|
|
435
|
+
await prompts.askValue("Model provider name", "openai", issue("MODEL_PROVIDER_NAME"))
|
|
436
|
+
).trim();
|
|
437
|
+
const baseUrl = (
|
|
438
|
+
await prompts.askValue(
|
|
439
|
+
"Base URL",
|
|
440
|
+
"Any OpenAI-compatible URL, e.g. https://api.openai.com/v1",
|
|
441
|
+
issue("MODEL_PROVIDER_URL"),
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
.trim()
|
|
445
|
+
.replace(/\/+$/, "");
|
|
446
|
+
const apiKey = await prompts.askSecret("API key", issue("MODEL_PROVIDER_API_KEY"));
|
|
447
|
+
const model = (
|
|
448
|
+
await prompts.askValue("Model", "gpt-5-mini", issue("MODEL_PROVIDER_MODEL"))
|
|
449
|
+
).trim();
|
|
450
|
+
const provider = { name, baseUrl, apiKey, model };
|
|
451
|
+
|
|
452
|
+
const { host } = new URL(baseUrl);
|
|
453
|
+
const check = checkProvider(provider);
|
|
454
|
+
await prompts.runSteps([
|
|
455
|
+
{
|
|
456
|
+
title: `Checking ${host}`,
|
|
457
|
+
run: async function () {
|
|
458
|
+
const { chat, embeddings } = await check;
|
|
459
|
+
if (!chat.ok) return undefined;
|
|
460
|
+
return `Checked ${host} · ${model} replied · ${embeddings ? "embeddings work" : "no embeddings"}`;
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
]);
|
|
464
|
+
const { chat, embeddings } = await check;
|
|
465
|
+
|
|
466
|
+
if (!chat.ok) {
|
|
467
|
+
prompts.log.warn(`${model} at ${host} did not answer: ${chat.reason}`);
|
|
468
|
+
if (!(await prompts.askConfirm("Use these details anyway?"))) continue;
|
|
469
|
+
} else if (!embeddings) {
|
|
470
|
+
prompts.log.info(
|
|
471
|
+
"Tool search and knowledge stay off until EMBEDDING_* in .env is set.",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
provider,
|
|
477
|
+
env: {
|
|
478
|
+
MODEL_PROVIDER_NAME: name,
|
|
479
|
+
MODEL_PROVIDER_URL: baseUrl,
|
|
480
|
+
MODEL_PROVIDER_API_KEY: apiKey,
|
|
481
|
+
MODEL_PROVIDER_MODEL: model,
|
|
482
|
+
...(embeddings ? { EMBEDDING_BASE_URL: baseUrl, EMBEDDING_API_KEY: apiKey } : {}),
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** What setup would refuse in an answer, as the prompt says it. */
|
|
489
|
+
function issue(field: keyof z.input<typeof providerFields>) {
|
|
490
|
+
return (value: string) =>
|
|
491
|
+
ENV_SAFE.test(value)
|
|
492
|
+
? providerFields.shape[field].safeParse(value).error?.issues[0]?.message
|
|
493
|
+
: "No spaces, quotes, $, # or \\: .env would not keep it as typed.";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The private hosts the stack's egress policy admits, from .env. Before a
|
|
498
|
+
* project has one (`cortex new` asks first), the default it will hold.
|
|
499
|
+
*/
|
|
500
|
+
function admittedHosts() {
|
|
501
|
+
return process.env["EGRESS_ALLOWED_HOSTS"] === undefined ? [DOCKER_HOST] : egressAllowedHosts();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A URL the key may travel to under the stack's egress policy; where it resolves is `unreachable`'s. */
|
|
505
|
+
function checkBaseUrl(value: string) {
|
|
506
|
+
if (!URL.canParse(value)) return "A full URL, like https://api.openai.com/v1.";
|
|
507
|
+
if (!canCarrySecret(value, admittedHosts())) {
|
|
508
|
+
return "https, or http to a host EGRESS_ALLOWED_HOSTS admits, like host.docker.internal.";
|
|
509
|
+
}
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Why Cortex's containers could not call the provider, or undefined. They
|
|
515
|
+
* run in Docker, where localhost is the container, and their egress policy
|
|
516
|
+
* refuses every private address but the hosts it admits; a hostname is
|
|
517
|
+
* judged by what it resolves to here.
|
|
518
|
+
*/
|
|
519
|
+
async function unreachable(baseUrl: string) {
|
|
520
|
+
const host = normalizeHost(new URL(baseUrl).hostname);
|
|
521
|
+
if (isEgressAllowedHost(host, admittedHosts())) return undefined;
|
|
522
|
+
const addresses = await lookup(host, { all: true }).catch(() => []);
|
|
523
|
+
if (!addresses.some(({ address }) => isPrivateAddress(address))) return undefined;
|
|
524
|
+
return `${host} is on this machine or a private network, which Cortex's containers do not call. Use ${DOCKER_HOST} for this machine, or add the host to EGRESS_ALLOWED_HOSTS in .env and restart cortex in \`bun run dev\`.`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function requireReachable(baseUrl: string) {
|
|
528
|
+
const reason = await unreachable(baseUrl);
|
|
529
|
+
if (reason) report.fail({ what: "Cortex cannot call MODEL_PROVIDER_URL", blocks: [reason] });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* One chat message to the model, and whether the provider also serves the
|
|
534
|
+
* stack's embedding model at the stack's dimension.
|
|
535
|
+
*/
|
|
536
|
+
async function checkProvider(provider: Provider) {
|
|
537
|
+
const blocked = await unreachable(provider.baseUrl);
|
|
538
|
+
if (blocked) return { chat: { ok: false as const, reason: blocked }, embeddings: false };
|
|
539
|
+
|
|
540
|
+
const [chat, embeddings] = await Promise.all([
|
|
541
|
+
post(provider, "/chat/completions", {
|
|
542
|
+
model: provider.model,
|
|
543
|
+
messages: [{ role: "user", content: "Reply with ok." }],
|
|
544
|
+
}),
|
|
545
|
+
post(provider, "/embeddings", {
|
|
546
|
+
model: EMBEDDING.model,
|
|
547
|
+
input: ["ok"],
|
|
548
|
+
dimensions: EMBEDDING.dimension,
|
|
549
|
+
}),
|
|
550
|
+
]);
|
|
551
|
+
return {
|
|
552
|
+
// A 200 from a catch-all page is no reply from a model.
|
|
553
|
+
chat:
|
|
554
|
+
chat.ok && !chatSchema.safeParse(chat.body).success
|
|
555
|
+
? { ok: false as const, reason: "it answered, but not with a chat completion" }
|
|
556
|
+
: chat,
|
|
557
|
+
embeddings: chat.ok && embeddings.ok && embeddingsSchema.safeParse(embeddings.body).success,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* One OpenAI-style call with the provider's key. A refusal's reason is the
|
|
563
|
+
* provider's own message, with any spelling of the key it echoes redacted.
|
|
564
|
+
*/
|
|
565
|
+
async function post(provider: Provider, path: string, body: unknown) {
|
|
566
|
+
const scrub = (text: string) =>
|
|
567
|
+
String(scrubCredentialStrings(text, credentialForms(provider.apiKey)));
|
|
568
|
+
// The check runs on this machine, where Docker's name for it does not resolve.
|
|
569
|
+
const url = new URL(`${provider.baseUrl}${path}`);
|
|
570
|
+
if (url.hostname === DOCKER_HOST) url.hostname = "localhost";
|
|
571
|
+
try {
|
|
572
|
+
const response = await fetch(url, {
|
|
573
|
+
method: "POST",
|
|
574
|
+
headers: {
|
|
575
|
+
Authorization: `Bearer ${provider.apiKey}`,
|
|
576
|
+
"Content-Type": "application/json",
|
|
577
|
+
},
|
|
578
|
+
body: JSON.stringify(body),
|
|
579
|
+
signal: AbortSignal.timeout(30_000),
|
|
580
|
+
});
|
|
581
|
+
const answer: unknown = await response.json().catch(() => null);
|
|
582
|
+
if (response.ok) return { ok: true as const, body: answer };
|
|
583
|
+
const message = z.object({ error: z.object({ message: z.string() }) }).safeParse(answer)
|
|
584
|
+
.data?.error.message;
|
|
585
|
+
return {
|
|
586
|
+
ok: false as const,
|
|
587
|
+
reason: scrub(`${response.status} ${message ?? response.statusText}`),
|
|
588
|
+
};
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return {
|
|
591
|
+
ok: false as const,
|
|
592
|
+
reason: scrub(error instanceof Error ? error.message : String(error)),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Shown once, on the run that first applied: the console's first admin, from
|
|
599
|
+
* .env. The password only reaches a terminal, never a log that captures it.
|
|
600
|
+
*/
|
|
601
|
+
function printSignIn(url: string) {
|
|
602
|
+
const email = process.env["DEFAULT_ADMIN_EMAIL"];
|
|
603
|
+
const password = process.env["DEFAULT_ADMIN_PASSWORD"];
|
|
604
|
+
if (!email || !password) return;
|
|
605
|
+
report.note(
|
|
606
|
+
process.stdout.isTTY === true
|
|
607
|
+
? `Sign in to ${url} as ${email}, password ${password}. Both are in .env.`
|
|
608
|
+
: `Sign in to ${url} with DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD from .env.`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* The boundary on a Vega-Lite spec the model wrote, shared by the widget
|
|
3
|
-
* (which draws it live) and
|
|
3
|
+
* (which draws it live) and Cortex (which describes it to the model):
|
|
4
4
|
* inline data only, no fetches, no interactivity, bounded work.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -43,9 +43,9 @@ export type ClientToolHandshake = ClientToolOutcome & {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
/*
|
|
46
|
-
* Built-in input tools:
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* Built-in input tools: client tools Cortex declares and the widget answers
|
|
47
|
+
* with a component of its own, keyed by tool name. A consumer's
|
|
48
|
+
* `toolComponents` entry under the same name replaces the built-in.
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
51
|
export const REQUEST_LOCATION_TOOL = "requestLocation";
|