@m6d/cortex-cli 0.18.0 → 0.19.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/package.json +1 -1
- package/src/commands/apply.ts +12 -7
- package/src/commands/management.ts +17 -15
- package/src/commands/setup.ts +72 -60
- package/src/contracts/management/index.ts +169 -0
- package/src/contracts/management/procedures.generated.ts +5891 -0
- package/src/contracts/manifest/index.ts +26 -17
- package/src/management/client.ts +17 -77
- package/src/scaffold/files.ts +2 -2
package/package.json
CHANGED
package/src/commands/apply.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { relative, resolve } from "node:path";
|
|
|
4
4
|
import { Option } from "@commander-js/extra-typings";
|
|
5
5
|
import type { Command } from "@commander-js/extra-typings";
|
|
6
6
|
import { isRecord } from "@cortex/contracts/json";
|
|
7
|
+
import {
|
|
8
|
+
callManagement,
|
|
9
|
+
ManagementClientError,
|
|
10
|
+
managementPath,
|
|
11
|
+
} from "@cortex/contracts/management";
|
|
7
12
|
import {
|
|
8
13
|
appliedManifestSchema,
|
|
9
14
|
manifestApplyInputSchema,
|
|
@@ -12,7 +17,7 @@ import {
|
|
|
12
17
|
import { loadManifestFile } from "@cortex/contracts/manifest-file";
|
|
13
18
|
import { z } from "zod";
|
|
14
19
|
|
|
15
|
-
import {
|
|
20
|
+
import { managementTarget } from "@/management/client";
|
|
16
21
|
import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
|
|
17
22
|
import { askChoice, canAsk } from "@/ui/prompts";
|
|
18
23
|
import * as report from "@/ui/report";
|
|
@@ -137,7 +142,7 @@ export async function applyManifest(options: {
|
|
|
137
142
|
const { profile, dryRun } = options;
|
|
138
143
|
const answer = await sendManifest({
|
|
139
144
|
...options,
|
|
140
|
-
path: dryRun ? "
|
|
145
|
+
path: managementPath(dryRun ? "manifests.preview" : "manifests.sync"),
|
|
141
146
|
});
|
|
142
147
|
// The secrets are read apart from the changes: a sync has committed by
|
|
143
148
|
// now, and a summary this CLI is too old to read must not lose them.
|
|
@@ -179,14 +184,14 @@ async function sendManifest(options: {
|
|
|
179
184
|
const resolutions: Resolution = [];
|
|
180
185
|
for (;;) {
|
|
181
186
|
try {
|
|
182
|
-
return await callManagement(profile,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
resolutions,
|
|
187
|
+
return await callManagement(managementTarget(profile), {
|
|
188
|
+
method: "post",
|
|
189
|
+
path,
|
|
190
|
+
input: { ...manifest, prefer, resolutions },
|
|
186
191
|
});
|
|
187
192
|
} catch (error) {
|
|
188
193
|
const refused =
|
|
189
|
-
error instanceof
|
|
194
|
+
error instanceof ManagementClientError
|
|
190
195
|
? unresolvedSchema.safeParse(error.detail)
|
|
191
196
|
: undefined;
|
|
192
197
|
if (!refused?.success) throw error;
|
|
@@ -3,12 +3,13 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { type Command, Option } from "@commander-js/extra-typings";
|
|
4
4
|
import { canCarrySecret } from "@cortex/contracts/egress-policy";
|
|
5
5
|
import { isRecord } from "@cortex/contracts/json";
|
|
6
|
+
import { callManagement, ManagementClientError } from "@cortex/contracts/management";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
|
-
callManagement,
|
|
9
9
|
inputSchemaOf,
|
|
10
10
|
loadOpenApi,
|
|
11
|
-
|
|
11
|
+
managementClient,
|
|
12
|
+
managementTarget,
|
|
12
13
|
UnreachableError,
|
|
13
14
|
} from "@/management/client";
|
|
14
15
|
import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
|
|
@@ -134,14 +135,11 @@ async function login(given: string | undefined, name: string) {
|
|
|
134
135
|
*/
|
|
135
136
|
async function whoami(profile: { url: string; key: string }) {
|
|
136
137
|
try {
|
|
137
|
-
const me =
|
|
138
|
-
name: string;
|
|
139
|
-
fingerprint: string;
|
|
140
|
-
};
|
|
138
|
+
const me = await managementClient(profile).managementKeys.mine();
|
|
141
139
|
return `key "${me.name}" (${me.fingerprint})`;
|
|
142
140
|
} catch (error) {
|
|
143
|
-
if (error instanceof
|
|
144
|
-
if (error instanceof
|
|
141
|
+
if (error instanceof ManagementClientError && error.status === 401) return null;
|
|
142
|
+
if (error instanceof ManagementClientError) {
|
|
145
143
|
report.fail({
|
|
146
144
|
what: `${profile.url} answered ${error.status} ${error.code}: ${error.message}`,
|
|
147
145
|
});
|
|
@@ -323,7 +321,7 @@ function registerOperation(scope: {
|
|
|
323
321
|
const result = await callOrFail(profile, () =>
|
|
324
322
|
all
|
|
325
323
|
? walkPages(profile, method, path, body)
|
|
326
|
-
: callManagement(profile, method, path, body),
|
|
324
|
+
: callManagement(managementTarget(profile), { method, path, input: body }),
|
|
327
325
|
);
|
|
328
326
|
print(result, format);
|
|
329
327
|
if (publisher) {
|
|
@@ -427,7 +425,11 @@ async function publishAfterWrite(
|
|
|
427
425
|
}
|
|
428
426
|
if (publish) {
|
|
429
427
|
const published = await callOrFail(profile, () =>
|
|
430
|
-
callManagement(profile,
|
|
428
|
+
callManagement(managementTarget(profile), {
|
|
429
|
+
method: publisher.method,
|
|
430
|
+
path: publisher.path,
|
|
431
|
+
input: { id },
|
|
432
|
+
}),
|
|
431
433
|
);
|
|
432
434
|
options.note(`Published ${id}.`);
|
|
433
435
|
for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
|
|
@@ -454,7 +456,7 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
|
|
|
454
456
|
* rethrown.
|
|
455
457
|
*/
|
|
456
458
|
export function failCall(profile: { url: string }, error: unknown): never {
|
|
457
|
-
if (error instanceof
|
|
459
|
+
if (error instanceof ManagementClientError) {
|
|
458
460
|
report.fail({
|
|
459
461
|
what: `${error.status} ${error.code}: ${error.message}`,
|
|
460
462
|
blocks: [
|
|
@@ -605,10 +607,10 @@ export async function walkPages(
|
|
|
605
607
|
// Bounded: if Cortex reports more rows than it serves, the walk must not
|
|
606
608
|
// spin, and a listing the bound cannot cover is an error, never a short result.
|
|
607
609
|
for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
|
|
608
|
-
const page = (await callManagement(profile,
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
pageSize,
|
|
610
|
+
const page = (await callManagement(managementTarget(profile), {
|
|
611
|
+
method,
|
|
612
|
+
path,
|
|
613
|
+
input: { ...given, pageIndex, pageSize },
|
|
612
614
|
})) as { items?: unknown[]; count?: number; filteredCount?: number };
|
|
613
615
|
if (!Array.isArray(page.items)) return page;
|
|
614
616
|
items.push(...page.items);
|
package/src/commands/setup.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
normalizeHost,
|
|
13
13
|
scrubCredentialStrings,
|
|
14
14
|
} from "@cortex/contracts/egress-policy";
|
|
15
|
+
import { ManagementClientError, managementPath } from "@cortex/contracts/management";
|
|
15
16
|
import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH, SETTING_BOUNDS } from "@cortex/contracts/manifest";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
requireProfile,
|
|
30
31
|
walkPages,
|
|
31
32
|
} from "@/commands/management";
|
|
32
|
-
import {
|
|
33
|
+
import { managementClient, UnreachableError } from "@/management/client";
|
|
33
34
|
import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
|
|
34
35
|
import * as prompts from "@/ui/prompts";
|
|
35
36
|
import * as report from "@/ui/report";
|
|
@@ -154,7 +155,7 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
|
|
|
154
155
|
interactive,
|
|
155
156
|
command: "bun cortex apply --yes",
|
|
156
157
|
}).catch(function (error: unknown) {
|
|
157
|
-
if (error instanceof
|
|
158
|
+
if (error instanceof ManagementClientError && error.message === "chat_model_required") {
|
|
158
159
|
return undefined;
|
|
159
160
|
}
|
|
160
161
|
return failCall(profile, error);
|
|
@@ -233,12 +234,12 @@ async function waitForCortex(profile: Profile, wait: boolean) {
|
|
|
233
234
|
let announced = false;
|
|
234
235
|
for (;;) {
|
|
235
236
|
try {
|
|
236
|
-
await
|
|
237
|
+
await managementClient(profile).secrets.list({ pageSize: 1 });
|
|
237
238
|
return;
|
|
238
239
|
} catch (error) {
|
|
239
240
|
const starting =
|
|
240
241
|
error instanceof UnreachableError ||
|
|
241
|
-
(error instanceof
|
|
242
|
+
(error instanceof ManagementClientError && error.status >= 500);
|
|
242
243
|
if (!starting || Date.now() >= deadline) failCall(profile, error);
|
|
243
244
|
if (!announced) {
|
|
244
245
|
report.row("", `Waiting for ${profile.url}. A first start pulls the image.`);
|
|
@@ -249,24 +250,21 @@ async function waitForCortex(profile: Profile, wait: boolean) {
|
|
|
249
250
|
}
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
function call(profile: Profile, method: "get" | "post", path: string, input: unknown) {
|
|
253
|
-
return callOrFail(profile, () => callManagement(profile, method, path, input));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
253
|
/** The entry named exactly `name`, across every page; a listing's search matches any part of it. */
|
|
257
254
|
async function findByName<TItem extends typeof namedSchema>(
|
|
258
255
|
profile: Profile,
|
|
259
|
-
router:
|
|
256
|
+
router: "secrets" | "modelProviders" | "servers",
|
|
260
257
|
name: string,
|
|
261
258
|
item: TItem,
|
|
262
259
|
) {
|
|
263
|
-
const { items } = z
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
),
|
|
269
|
-
)
|
|
260
|
+
const { items } = z.object({ items: z.array(item) }).parse(
|
|
261
|
+
await callOrFail(profile, () =>
|
|
262
|
+
walkPages(profile, "get", managementPath(`${router}.list`), {
|
|
263
|
+
q: name,
|
|
264
|
+
pageSize: 100,
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
);
|
|
270
268
|
return items.find((entry) => entry.name === name);
|
|
271
269
|
}
|
|
272
270
|
|
|
@@ -284,7 +282,9 @@ function secretFor(owner: string, role: "api-key" | "server-key", value: string)
|
|
|
284
282
|
|
|
285
283
|
async function ensureSecret(profile: Profile, name: string, value: string) {
|
|
286
284
|
const found = await findByName(profile, "secrets", name, namedSchema);
|
|
287
|
-
if (!found)
|
|
285
|
+
if (!found) {
|
|
286
|
+
await callOrFail(profile, () => managementClient(profile).secrets.set({ name, value }));
|
|
287
|
+
}
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
/**
|
|
@@ -307,7 +307,7 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
|
|
|
307
307
|
* write.
|
|
308
308
|
*/
|
|
309
309
|
async function ensureProvider(profile: Profile, provider: Provider) {
|
|
310
|
-
const found = await findByName(profile, "
|
|
310
|
+
const found = await findByName(profile, "modelProviders", provider.name, providerRowSchema);
|
|
311
311
|
if (found && found.baseUrl !== provider.baseUrl) {
|
|
312
312
|
failCallsElsewhere(
|
|
313
313
|
`Provider ${provider.name}`,
|
|
@@ -330,13 +330,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
330
330
|
await ensureSecret(profile, secretName, provider.apiKey);
|
|
331
331
|
|
|
332
332
|
if (!found) {
|
|
333
|
-
await
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
333
|
+
await callOrFail(profile, () =>
|
|
334
|
+
managementClient(profile).modelProviders.create({
|
|
335
|
+
name: provider.name,
|
|
336
|
+
baseUrl: provider.baseUrl,
|
|
337
|
+
secretName,
|
|
338
|
+
models,
|
|
339
|
+
efforts: [],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
340
342
|
report.input("Provider", `${provider.name} · created`);
|
|
341
343
|
return;
|
|
342
344
|
}
|
|
@@ -344,13 +346,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
|
|
|
344
346
|
report.input("Provider", `${provider.name} · kept`);
|
|
345
347
|
return;
|
|
346
348
|
}
|
|
347
|
-
await
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
349
|
+
await callOrFail(profile, () =>
|
|
350
|
+
managementClient(profile).modelProviders.update({
|
|
351
|
+
id: found.id,
|
|
352
|
+
baseUrl: found.baseUrl,
|
|
353
|
+
secretName,
|
|
354
|
+
models,
|
|
355
|
+
efforts: found.efforts,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
354
358
|
report.input("Provider", `${provider.name} · updated`);
|
|
355
359
|
}
|
|
356
360
|
|
|
@@ -369,7 +373,11 @@ async function ensureServer(
|
|
|
369
373
|
const url = `${project.CORTEX_SERVER_URL}/cortex`;
|
|
370
374
|
const found = await findByName(profile, "servers", name, namedSchema);
|
|
371
375
|
const server = found
|
|
372
|
-
? serverSchema.parse(
|
|
376
|
+
? serverSchema.parse(
|
|
377
|
+
await callOrFail(profile, () =>
|
|
378
|
+
managementClient(profile).servers.get({ id: found.id }),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
373
381
|
: undefined;
|
|
374
382
|
if (server && server.url !== url) {
|
|
375
383
|
failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
|
|
@@ -384,15 +392,17 @@ async function ensureServer(
|
|
|
384
392
|
};
|
|
385
393
|
if (!server) {
|
|
386
394
|
const created = serverSchema.parse(
|
|
387
|
-
await
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
395
|
+
await callOrFail(profile, () =>
|
|
396
|
+
managementClient(profile).servers.create({
|
|
397
|
+
name,
|
|
398
|
+
description: "",
|
|
399
|
+
url,
|
|
400
|
+
keySecretName,
|
|
401
|
+
...model,
|
|
402
|
+
signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
|
|
403
|
+
triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
|
|
404
|
+
}),
|
|
405
|
+
),
|
|
396
406
|
);
|
|
397
407
|
report.input("Server", `${name} · created`);
|
|
398
408
|
return created;
|
|
@@ -404,22 +414,24 @@ async function ensureServer(
|
|
|
404
414
|
return server;
|
|
405
415
|
}
|
|
406
416
|
const updated = serverSchema.parse(
|
|
407
|
-
await
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
417
|
+
await callOrFail(profile, () =>
|
|
418
|
+
managementClient(profile).servers.update({
|
|
419
|
+
id: server.id,
|
|
420
|
+
name,
|
|
421
|
+
description: server.description ?? "",
|
|
422
|
+
url,
|
|
423
|
+
keySecretName,
|
|
424
|
+
...(takesModel
|
|
425
|
+
? model
|
|
426
|
+
: {
|
|
427
|
+
modelProvider: server.modelProvider,
|
|
428
|
+
modelName: server.modelName,
|
|
429
|
+
modelEffort: server.modelEffort,
|
|
430
|
+
}),
|
|
431
|
+
signInChecksPerMinute: server.signInChecksPerMinute,
|
|
432
|
+
triggerChecksPerMinute: server.triggerChecksPerMinute,
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
423
435
|
);
|
|
424
436
|
report.input("Server", `${name} · updated`);
|
|
425
437
|
return updated;
|
|
@@ -434,14 +446,14 @@ async function syncServer(profile: Profile, id: string, url: string) {
|
|
|
434
446
|
const deadline = Date.now() + SYNC_WAIT_MS;
|
|
435
447
|
for (;;) {
|
|
436
448
|
try {
|
|
437
|
-
await
|
|
449
|
+
await managementClient(profile).servers.sync({ id });
|
|
438
450
|
report.input("Synced", `${url}/cortex`);
|
|
439
451
|
return;
|
|
440
452
|
} catch (error) {
|
|
441
453
|
// Only a backend Cortex cannot reach yet is worth asking again; a
|
|
442
454
|
// refused manifest or blocked host fails the same way next time.
|
|
443
455
|
const starting =
|
|
444
|
-
error instanceof
|
|
456
|
+
error instanceof ManagementClientError && error.message === "upstream_failed";
|
|
445
457
|
if (starting && Date.now() < deadline) {
|
|
446
458
|
await Bun.sleep(POLL_MS);
|
|
447
459
|
continue;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { isRecord } from "../json/index";
|
|
4
|
+
import { managementProcedureKinds } from "./procedures.generated";
|
|
5
|
+
import type { ManagementProcedures } from "./procedures.generated";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Cortex's management API as a caller reaches it: one REST operation per
|
|
9
|
+
* console procedure under `/api/management`, a query as GET with its input
|
|
10
|
+
* on the query string, a mutation as POST with its input as the JSON body,
|
|
11
|
+
* and a management key as the bearer. The console routes a procedure by
|
|
12
|
+
* `managementPath`; the CLI and the TypeScript SDK call through
|
|
13
|
+
* `callManagement`, and M6d.Cortex spells the same rules in C#.
|
|
14
|
+
* `management.fixtures.json` holds every caller to the same requests and
|
|
15
|
+
* refusals. `createManagementClient` types each call from
|
|
16
|
+
* `procedures.generated.ts`, which the console prints from its procedures.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Where a console serves the management API. */
|
|
20
|
+
export const MANAGEMENT_BASE_PATH = "/api/management";
|
|
21
|
+
|
|
22
|
+
/** A procedure's REST path: its tRPC path, kebab-cased (`managementKeys.mine` is `/management-keys/mine`). */
|
|
23
|
+
export function managementPath(procedure: string) {
|
|
24
|
+
const kebab = procedure.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
|
|
25
|
+
return `/${kebab.replaceAll(".", "/")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isScalar(value: unknown): value is string | number | boolean | null {
|
|
29
|
+
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A query's input on the query string, one parameter per field: a scalar as
|
|
34
|
+
* itself, a non-empty array of scalars as a repeated parameter, anything
|
|
35
|
+
* else as JSON, as the document declares each field; "" for none.
|
|
36
|
+
*/
|
|
37
|
+
function managementQuery(input: Record<string, unknown>) {
|
|
38
|
+
const params = new URLSearchParams();
|
|
39
|
+
for (const [name, value] of Object.entries(input)) {
|
|
40
|
+
if (value === undefined) continue;
|
|
41
|
+
if (isScalar(value)) params.set(name, String(value));
|
|
42
|
+
else if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) {
|
|
43
|
+
for (const item of value) params.append(name, String(item));
|
|
44
|
+
} else params.set(name, JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
const encoded = params.toString();
|
|
47
|
+
return encoded === "" ? "" : `?${encoded}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The body a refusal answers with, each field kept only when it has its documented shape. */
|
|
51
|
+
const refusalBodySchema = z.object({
|
|
52
|
+
code: z.string().optional().catch(undefined),
|
|
53
|
+
message: z.string().optional().catch(undefined),
|
|
54
|
+
refusal: z.string().optional().catch(undefined),
|
|
55
|
+
cause: z.record(z.string(), z.unknown()).optional().catch(undefined),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cortex refused a management call: `code` is the tRPC code (`FORBIDDEN`),
|
|
60
|
+
* the message the refusal's literal (`read_only`), `detail` what the refusal
|
|
61
|
+
* names when it names something (`{ error, fields }`), and `refusal` the
|
|
62
|
+
* sentence Cortex words it in, when it has one. A body that is no refusal
|
|
63
|
+
* (a proxy's error page) reads as code `error` with the status text.
|
|
64
|
+
*/
|
|
65
|
+
export class ManagementClientError extends Error {
|
|
66
|
+
public constructor(
|
|
67
|
+
public readonly status: number,
|
|
68
|
+
public readonly code: string,
|
|
69
|
+
message: string,
|
|
70
|
+
public readonly detail?: Record<string, unknown>,
|
|
71
|
+
public readonly refusal?: string,
|
|
72
|
+
) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "ManagementClientError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where management calls go: a console's origin and a management key (`cortex_mgmt_…`); a test hands in its own `fetch`. */
|
|
79
|
+
export type ManagementTarget = {
|
|
80
|
+
url: string;
|
|
81
|
+
key: string;
|
|
82
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One management call by REST route, answering the procedure's output as
|
|
87
|
+
* parsed JSON. A refusal throws `ManagementClientError`; a Cortex that cannot
|
|
88
|
+
* be reached, or a success body that cannot be read, rejects as `fetch` and
|
|
89
|
+
* `Response.json` do.
|
|
90
|
+
*/
|
|
91
|
+
export async function callManagement(
|
|
92
|
+
target: ManagementTarget,
|
|
93
|
+
request: { method: "get" | "post"; path: string; input?: unknown; signal?: AbortSignal },
|
|
94
|
+
) {
|
|
95
|
+
const { method, path, input, signal } = request;
|
|
96
|
+
const query = method === "get" && isRecord(input) ? managementQuery(input) : "";
|
|
97
|
+
const url = `${target.url.replace(/\/+$/, "")}${MANAGEMENT_BASE_PATH}${path}${query}`;
|
|
98
|
+
const response = await (target.fetch ?? globalThis.fetch)(url, {
|
|
99
|
+
method: method.toUpperCase(),
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${target.key}`,
|
|
102
|
+
...(method === "post" ? { "Content-Type": "application/json" } : {}),
|
|
103
|
+
},
|
|
104
|
+
body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
|
|
105
|
+
signal,
|
|
106
|
+
});
|
|
107
|
+
// A success that cannot be read is the caller's failure, never a null result.
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
const output: unknown = await response.json();
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
const body: unknown = await response.json().catch(() => null);
|
|
113
|
+
const refused = refusalBodySchema.safeParse(body).data ?? {};
|
|
114
|
+
throw new ManagementClientError(
|
|
115
|
+
response.status,
|
|
116
|
+
refused.code ?? "error",
|
|
117
|
+
refused.message ?? response.statusText,
|
|
118
|
+
refused.cause,
|
|
119
|
+
refused.refusal,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type ManagementProcedure = keyof ManagementProcedures;
|
|
124
|
+
|
|
125
|
+
/** What procedure `P` takes, as it is sent. */
|
|
126
|
+
export type ManagementInput<P extends ManagementProcedure> = ManagementProcedures[P]["input"];
|
|
127
|
+
|
|
128
|
+
/** What procedure `P` answers, as JSON carries it: a date is its ISO string. */
|
|
129
|
+
export type ManagementOutput<P extends ManagementProcedure> = ManagementProcedures[P]["output"];
|
|
130
|
+
|
|
131
|
+
type CallOptions = { signal?: AbortSignal };
|
|
132
|
+
|
|
133
|
+
/** Procedure `P` as a call; its input may be left out when it takes none, or nothing it requires. */
|
|
134
|
+
type Call<P extends ManagementProcedure> =
|
|
135
|
+
Partial<ManagementInput<P>> extends ManagementInput<P>
|
|
136
|
+
? (input?: ManagementInput<P>, options?: CallOptions) => Promise<ManagementOutput<P>>
|
|
137
|
+
: (input: ManagementInput<P>, options?: CallOptions) => Promise<ManagementOutput<P>>;
|
|
138
|
+
|
|
139
|
+
/** Every procedure as `client.<router>.<procedure>`, as the API documents it. */
|
|
140
|
+
export type ManagementClient = {
|
|
141
|
+
[R in ManagementProcedure extends `${infer Router}.${string}` ? Router : never]: {
|
|
142
|
+
[P in ManagementProcedure as P extends `${R}.${infer Name}` ? Name : never]: Call<P>;
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A typed management client: `client.agents.saveSettings({ id, greeting })`
|
|
148
|
+
* sends the procedure's request, a GET or a POST as its kind is, and answers
|
|
149
|
+
* its output. A refusal throws `ManagementClientError`.
|
|
150
|
+
*/
|
|
151
|
+
export function createManagementClient(target: ManagementTarget) {
|
|
152
|
+
const client: Record<
|
|
153
|
+
string,
|
|
154
|
+
Record<string, (input?: unknown, options?: CallOptions) => Promise<unknown>>
|
|
155
|
+
> = {};
|
|
156
|
+
for (const [procedure, kind] of Object.entries(managementProcedureKinds)) {
|
|
157
|
+
const [router = "", name = ""] = procedure.split(".");
|
|
158
|
+
(client[router] ??= {})[name] = (input, options) =>
|
|
159
|
+
callManagement(target, {
|
|
160
|
+
method: kind === "query" ? "get" : "post",
|
|
161
|
+
path: managementPath(procedure),
|
|
162
|
+
input,
|
|
163
|
+
signal: options?.signal,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// The table of kinds and the procedure map are printed from one model, so
|
|
167
|
+
// each call built here is the one its type names.
|
|
168
|
+
return client as ManagementClient;
|
|
169
|
+
}
|