@mediamonks/canopy-cli 0.7.28 → 0.7.30
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/dist/ai-fAvjdjVX.mjs +54 -0
- package/dist/{db-DVMwCLlU.mjs → db-KTLn8J1u.mjs} +2 -2
- package/dist/{deploy-CTMcwHhI.mjs → deploy-CBf44bvG.mjs} +2 -2
- package/dist/dev-DGCu9MjB.mjs +2 -0
- package/dist/{dev-BvjbJcse.mjs → dev-JlzMZJow.mjs} +50 -21
- package/dist/{dev-worker-BerdPJ_U.mjs → dev-worker-DjJGSUGj.mjs} +1 -1
- package/dist/docs/cli-ai.md +43 -0
- package/dist/docs/cli-db.md +2 -0
- package/dist/docs/cli.md +2 -0
- package/dist/docs/index.json +17 -2
- package/dist/docs/platform-ai-usage.md +6 -4
- package/dist/docs/platform-members-and-access.md +13 -4
- package/dist/docs/platform-slack.md +6 -0
- package/dist/docs/sdk-ai.md +41 -10
- package/dist/docs/sdk-notify.md +140 -0
- package/dist/docs/sdk-realtime.md +149 -0
- package/dist/docs/sdk-slack.md +3 -0
- package/dist/docs/sdk.md +5 -4
- package/dist/docs/static-serving.md +19 -0
- package/dist/{docs-DMFMPnxG.mjs → docs-Dg0UJugG.mjs} +2 -2
- package/dist/{egress-Cn-P46O3.mjs → egress-Cn9zDfDT.mjs} +1 -1
- package/dist/{feedback-OMAkwHpR.mjs → feedback-Dvb6CdlP.mjs} +2 -2
- package/dist/index.mjs +28 -26
- package/dist/{local-laAZEDVJ.mjs → local-BA_TPWpN.mjs} +56 -4
- package/dist/{login-C4MDP5NM.mjs → login-MX0xq8UE.mjs} +1 -1
- package/dist/{logs-CfDINw7U.mjs → logs-D5RWqsWl.mjs} +1 -1
- package/dist/{migrations-B9fXxMDj.mjs → migrations-C8yRPJIX.mjs} +1 -1
- package/dist/{open-rS1ARzOy.mjs → open-BJgd0R61.mjs} +1 -1
- package/dist/{package-BL6hRqtr.mjs → package-CnS450Yc.mjs} +1 -1
- package/dist/{projects-Dmm_976k.mjs → projects-BG7Injpb.mjs} +1 -1
- package/dist/{schedule-Buu71RWQ.mjs → schedule-CLuksxSM.mjs} +1 -1
- package/dist/{secrets-BD3DaTih.mjs → secrets-Bl3uOx4l.mjs} +1 -1
- package/dist/skill/canopy/SKILL.md +3 -0
- package/dist/{skill-C83Za5BU.mjs → skill-CuES7kPj.mjs} +2 -2
- package/dist/{src-CwSKZwNK.mjs → src-C7zPhbxi.mjs} +238 -1
- package/dist/{staging-CKEoDnac.mjs → staging-D06zobnf.mjs} +1 -1
- package/dist/{token-Dp2CoIYj.mjs → token-C_PFNqcv.mjs} +1 -1
- package/dist/{ui-DR-siu2S.mjs → ui-CeupNHNa.mjs} +4 -0
- package/dist/{upgrade-BjQguwG6.mjs → upgrade-C8YdSKKd.mjs} +2 -2
- package/dist/{usage-81JSiWIx.mjs → usage-CV7TjTO8.mjs} +1 -1
- package/package.json +2 -2
- package/dist/dev-BOvMCaML.mjs +0 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { d as withContext, l as apiRequest, r as reportErrors } from "./ui-CeupNHNa.mjs";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
//#region src/ai.ts
|
|
4
|
+
/**
|
|
5
|
+
* canopy ai models — the site's AI model list (OpenSpec change
|
|
6
|
+
* ai-model-catalog): the auto/* aliases with what they resolve to, the
|
|
7
|
+
* default, available models with prices, and legacy models with their
|
|
8
|
+
* replacements. Site-wide, so it needs no linked project; `--profile` picks
|
|
9
|
+
* the site. Agents should check this before naming a model in code.
|
|
10
|
+
*/
|
|
11
|
+
function perMTok(n) {
|
|
12
|
+
return `$${Number.isInteger(n) ? n : n.toFixed(2).replace(/0$/, "")}`;
|
|
13
|
+
}
|
|
14
|
+
function renderModels(site) {
|
|
15
|
+
const lines = ["Aliases (prefer these over model ids)"];
|
|
16
|
+
for (const alias of site.aliases) {
|
|
17
|
+
const target = alias.resolvesTo ?? "— (no model on this site)";
|
|
18
|
+
const tag = alias.id === site.default ? " (default)" : "";
|
|
19
|
+
lines.push(` ${alias.id.padEnd(12)} → ${target}${tag}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push("", "Models (USD per 1M tokens, in / out)");
|
|
22
|
+
if (site.models.length === 0) lines.push(" none — AI is not enabled on this site");
|
|
23
|
+
const width = Math.max(0, ...site.models.map((m) => m.id.length));
|
|
24
|
+
for (const model of site.models) lines.push(` ${model.id.padEnd(width)} ${perMTok(model.inputPerMTok)} / ${perMTok(model.outputPerMTok)}`);
|
|
25
|
+
if (site.legacy.length > 0) {
|
|
26
|
+
lines.push("", "Legacy (still answered, by the replacement)");
|
|
27
|
+
const legacyWidth = Math.max(...site.legacy.map((m) => m.id.length));
|
|
28
|
+
for (const model of site.legacy) lines.push(` ${model.id.padEnd(legacyWidth)} → ${model.replacement}`);
|
|
29
|
+
}
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
const aiCommand = defineCommand({
|
|
33
|
+
meta: {
|
|
34
|
+
name: "ai",
|
|
35
|
+
description: "AI models available to projects on this site"
|
|
36
|
+
},
|
|
37
|
+
subCommands: { models: defineCommand({
|
|
38
|
+
meta: {
|
|
39
|
+
name: "models",
|
|
40
|
+
description: "List the AI models and auto/* aliases this site offers"
|
|
41
|
+
},
|
|
42
|
+
args: { json: {
|
|
43
|
+
type: "boolean",
|
|
44
|
+
description: "Print raw JSON (same shape as GET /control/v1/ai/models)",
|
|
45
|
+
default: false
|
|
46
|
+
} },
|
|
47
|
+
run: reportErrors("ai models", async ({ args }) => {
|
|
48
|
+
const site = await withContext(apiRequest("GET", "/ai/models"), { action: "list the site's AI models" });
|
|
49
|
+
console.log(args.json ? JSON.stringify(site, null, 2) : renderModels(site));
|
|
50
|
+
})
|
|
51
|
+
}) }
|
|
52
|
+
});
|
|
53
|
+
//#endregion
|
|
54
|
+
export { aiCommand };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, s as apiDataRequest } from "./ui-
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
2
|
+
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, s as apiDataRequest } from "./ui-CeupNHNa.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C8yRPJIX.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui-
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
2
|
+
import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui-CeupNHNa.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C8yRPJIX.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
-
import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui-
|
|
3
|
-
import { r as readMigrationFiles } from "./migrations-
|
|
4
|
-
import { n as stampWorkerHeaders } from "./src-
|
|
2
|
+
import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui-CeupNHNa.mjs";
|
|
3
|
+
import { r as readMigrationFiles } from "./migrations-C8yRPJIX.mjs";
|
|
4
|
+
import { n as stampWorkerHeaders } from "./src-C7zPhbxi.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { defineCommand } from "citty";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
@@ -38,7 +38,7 @@ async function workerPlugin(flags, slug) {
|
|
|
38
38
|
console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
-
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-
|
|
41
|
+
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-DjJGSUGj.mjs");
|
|
42
42
|
const secrets = readDevVars(flags.dir);
|
|
43
43
|
const email = siteConfig().email;
|
|
44
44
|
const plugin = workerDevPlugin({
|
|
@@ -146,6 +146,48 @@ function devProxyHeaders(token, slug, channel) {
|
|
|
146
146
|
});
|
|
147
147
|
return Object.fromEntries(headers);
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* One request from Vite's /_api proxy into the --local sandbox. The response
|
|
151
|
+
* body is streamed, not buffered — realtime subscriptions (docs/48) are
|
|
152
|
+
* long-lived SSE responses — and a client that goes away aborts the sandbox
|
|
153
|
+
* request and cancels its body, so the subscription is released.
|
|
154
|
+
*/
|
|
155
|
+
async function bridgeToSandbox(sandbox, req, res) {
|
|
156
|
+
const gone = new AbortController();
|
|
157
|
+
res.on("close", () => gone.abort());
|
|
158
|
+
const chunks = [];
|
|
159
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
160
|
+
const body = Buffer.concat(chunks);
|
|
161
|
+
const headers = new Headers();
|
|
162
|
+
if (req.headers["content-type"]) headers.set("content-type", req.headers["content-type"]);
|
|
163
|
+
if (req.headers["accept"]) headers.set("accept", req.headers["accept"]);
|
|
164
|
+
const response = await sandbox.handle(new Request(`http://canopy.local${req.url ?? ""}`, {
|
|
165
|
+
method: req.method,
|
|
166
|
+
headers,
|
|
167
|
+
body: body.length === 0 ? void 0 : new Uint8Array(body),
|
|
168
|
+
signal: gone.signal
|
|
169
|
+
}));
|
|
170
|
+
if (gone.signal.aborted) {
|
|
171
|
+
await response.body?.cancel().catch(() => {});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
175
|
+
if (!response.body) {
|
|
176
|
+
res.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const reader = response.body.getReader();
|
|
180
|
+
gone.signal.addEventListener("abort", () => void reader.cancel().catch(() => {}), { once: true });
|
|
181
|
+
try {
|
|
182
|
+
for (;;) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
res.write(value);
|
|
186
|
+
}
|
|
187
|
+
} catch {} finally {
|
|
188
|
+
res.end();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
149
191
|
async function devRemote(flags, slug) {
|
|
150
192
|
const { token, expires_at } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
|
|
151
193
|
const { apiUrl } = siteConfig();
|
|
@@ -176,7 +218,7 @@ async function devRemote(flags, slug) {
|
|
|
176
218
|
async function devLocal(flags, slug) {
|
|
177
219
|
let dump;
|
|
178
220
|
if (flags.pull) {
|
|
179
|
-
const { fetchProjectDump } = await import("./local-
|
|
221
|
+
const { fetchProjectDump } = await import("./local-BA_TPWpN.mjs");
|
|
180
222
|
console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
|
|
181
223
|
dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
|
|
182
224
|
}
|
|
@@ -191,7 +233,7 @@ async function devLocal(flags, slug) {
|
|
|
191
233
|
const reason = err instanceof ApiError ? err.explain().why : String(err);
|
|
192
234
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
|
|
193
235
|
}
|
|
194
|
-
const { createSandbox } = await import("./local-
|
|
236
|
+
const { createSandbox } = await import("./local-BA_TPWpN.mjs");
|
|
195
237
|
const sandbox = await createSandbox({
|
|
196
238
|
slug,
|
|
197
239
|
persistDir: join(flags.dir, ".canopy", "local"),
|
|
@@ -207,20 +249,7 @@ async function devLocal(flags, slug) {
|
|
|
207
249
|
const { applied } = await sandbox.applyMigrations(migrationFiles);
|
|
208
250
|
if (applied.length > 0) console.log(`Applied ${applied.length} pending migration(s): ${applied.join(", ")}`);
|
|
209
251
|
}
|
|
210
|
-
const handleApi =
|
|
211
|
-
const chunks = [];
|
|
212
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
213
|
-
const body = Buffer.concat(chunks);
|
|
214
|
-
const headers = new Headers();
|
|
215
|
-
if (req.headers["content-type"]) headers.set("content-type", req.headers["content-type"]);
|
|
216
|
-
const response = await sandbox.handle(new Request(`http://canopy.local${req.url ?? ""}`, {
|
|
217
|
-
method: req.method,
|
|
218
|
-
headers,
|
|
219
|
-
body: body.length === 0 ? void 0 : new Uint8Array(body)
|
|
220
|
-
}));
|
|
221
|
-
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
222
|
-
res.end(Buffer.from(await response.arrayBuffer()));
|
|
223
|
-
};
|
|
252
|
+
const handleApi = (req, res) => bridgeToSandbox(sandbox, req, res);
|
|
224
253
|
const { createServer: createHttpServer } = await import("node:http");
|
|
225
254
|
const apiServer = createHttpServer((req, res) => {
|
|
226
255
|
handleApi(req, res).catch((err) => {
|
|
@@ -312,4 +341,4 @@ const devCommand = defineCommand({
|
|
|
312
341
|
})
|
|
313
342
|
});
|
|
314
343
|
//#endregion
|
|
315
|
-
export {
|
|
344
|
+
export { rewriteApiPath as i, devCommand as n, devProxyHeaders as r, bridgeToSandbox as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as stampWorkerHeaders, t as redactCanopyCredentials } from "./src-
|
|
1
|
+
import { n as stampWorkerHeaders, t as redactCanopyCredentials } from "./src-C7zPhbxi.mjs";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
//#region src/dev-worker.ts
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: canopy ai models
|
|
3
|
+
description: List the AI models and auto/* aliases your site offers, with prices.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`canopy ai models` shows which AI models projects on your site can use. Run it before you name a model in code: only names on this list work.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
canopy ai models [--json]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Arguments & flags
|
|
15
|
+
|
|
16
|
+
- `--json`: print the raw JSON instead of the formatted list. Default `false`.
|
|
17
|
+
- `--profile <id>`: which site to ask, as with every command.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
The list belongs to the site, not to a project, so the command works from any directory. It prints three parts:
|
|
22
|
+
|
|
23
|
+
- **Aliases.** `auto/low`, `auto/mid` and `auto/high`, each with the model it answers with right now. The default (`auto/low`) is marked. Aliases follow newer models as they arrive, so prefer them over fixed model ids.
|
|
24
|
+
- **Models.** Every model you can name, with its price per million input and output tokens.
|
|
25
|
+
- **Legacy.** Retired models that still work, and the model that now answers for each.
|
|
26
|
+
|
|
27
|
+
It's read-only. With `--json`, the output is ready for scripts and agents.
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
canopy ai models
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Shows the aliases, models and prices for your active site.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
canopy ai models --json | jq -r '.aliases[] | "\(.id) \(.resolvesTo)"'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Prints what each alias answers with right now.
|
|
42
|
+
|
|
43
|
+
See also [AI](https://docs.canopy.website/sdk/ai) for calling models from your project, and [AI access & usage](https://docs.canopy.website/platform/ai-usage) for budgets and spend.
|
package/dist/docs/cli-db.md
CHANGED
|
@@ -53,6 +53,8 @@ Behavior:
|
|
|
53
53
|
2. Unless `--yes` is passed, it asks you to **type the project slug** to confirm — a deliberate speed bump, since restore is destructive. A non-TTY without `--yes` errors instead of guessing.
|
|
54
54
|
3. The chosen snapshot or file replaces the entire database, and the CLI confirms `Restored <slug>.`
|
|
55
55
|
|
|
56
|
+
A dump only restores into a database of the same kind. Feeding a dump from a SQLite-based project to a Postgres-based one (or the other way round) is refused up front with `dump_dialect_mismatch`, before anything is wiped. Check with `canopy db pull` on both sides: a SQLite dump opens with a `PRAGMA` line, a Postgres dump with a `-- Canopy project dump (postgres)` banner. For a project that uses **document collections only**, the collection table has the same layout everywhere, so deleting that leading `PRAGMA` line makes a SQLite dump restore on Postgres. Custom tables need their column types adapted by hand.
|
|
57
|
+
|
|
56
58
|
### canopy db migrate
|
|
57
59
|
|
|
58
60
|
Applies pending schema migrations to the project's **live** database, without deploying. Requires a `"migrations"` directory in `canopy.json` (for example your `drizzle-kit generate` output — see the [migrations guide](https://docs.canopy.website/guides/migrations)).
|
package/dist/docs/cli.md
CHANGED
|
@@ -60,6 +60,8 @@ has a full page per command.
|
|
|
60
60
|
`--snapshot <key>`. `--yes` skips the confirmation. **Destructive.**
|
|
61
61
|
- `canopy usage` — AI spend and storage for the project (same data as the
|
|
62
62
|
dashboard Usage tab). `--project <slug>` / `--json` for agents.
|
|
63
|
+
- `canopy ai models` — the AI models and `auto/*` aliases this site offers,
|
|
64
|
+
with prices. Works from any directory; `--json` for agents.
|
|
63
65
|
|
|
64
66
|
## CI / deploy tokens
|
|
65
67
|
|
package/dist/docs/index.json
CHANGED
|
@@ -276,6 +276,11 @@
|
|
|
276
276
|
"title": "canopy usage",
|
|
277
277
|
"summary": "Show a project's AI spend, storage footprint, and deploy count from the terminal."
|
|
278
278
|
},
|
|
279
|
+
{
|
|
280
|
+
"slug": "cli-ai",
|
|
281
|
+
"title": "canopy ai models",
|
|
282
|
+
"summary": "List the AI models and auto/* aliases your site offers, with prices."
|
|
283
|
+
},
|
|
279
284
|
{
|
|
280
285
|
"slug": "cli-feedback",
|
|
281
286
|
"title": "canopy feedback",
|
|
@@ -306,12 +311,12 @@
|
|
|
306
311
|
{
|
|
307
312
|
"key": "sdk",
|
|
308
313
|
"title": "SDK reference",
|
|
309
|
-
"description": "The typed client your project code uses — auth, data, files, AI, services.",
|
|
314
|
+
"description": "The typed client your project code uses — auth, data, files, AI, email, notify, realtime, services.",
|
|
310
315
|
"topics": [
|
|
311
316
|
{
|
|
312
317
|
"slug": "sdk",
|
|
313
318
|
"title": "Using the SDK",
|
|
314
|
-
"summary": "One typed client for auth, data, files, AI, email, and services — no keys, no connection strings, the platform authenticates for you."
|
|
319
|
+
"summary": "One typed client for auth, data, files, AI, email, notify, realtime, and services — no keys, no connection strings, the platform authenticates for you."
|
|
315
320
|
},
|
|
316
321
|
{
|
|
317
322
|
"slug": "sdk-auth",
|
|
@@ -338,6 +343,16 @@
|
|
|
338
343
|
"title": "Email",
|
|
339
344
|
"summary": "Send email from your project without a mail server or an API key — Monks recipients only, with a per-project daily quota and a sent log."
|
|
340
345
|
},
|
|
346
|
+
{
|
|
347
|
+
"slug": "sdk-notify",
|
|
348
|
+
"title": "Notify",
|
|
349
|
+
"summary": "Post a Slack message from your project through the Canopy bot — to your linked channels or a colleague's DMs — with nothing to set up."
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"slug": "sdk-realtime",
|
|
353
|
+
"title": "Realtime",
|
|
354
|
+
"summary": "Live updates between the people using your app — subscribe to a topic, publish to it, no backend needed."
|
|
355
|
+
},
|
|
341
356
|
{
|
|
342
357
|
"slug": "sdk-google",
|
|
343
358
|
"title": "Google",
|
|
@@ -21,10 +21,12 @@ const reply = await canopy.ai.chat({
|
|
|
21
21
|
|
|
22
22
|
- **No keys in your code.** Requests authenticate as the project; the
|
|
23
23
|
platform holds the provider credentials.
|
|
24
|
-
- **
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
- **Pick a tier, not a model.** Name `auto/low` (the default), `auto/mid`, or
|
|
25
|
+
`auto/high` and the platform answers with the best current model for it.
|
|
26
|
+
You can also name a specific model from the site's list. `canopy ai models`
|
|
27
|
+
and the Models section on your project's Usage tab show it, with prices.
|
|
28
|
+
Names that aren't on the list are refused, and retired models keep working,
|
|
29
|
+
answered by their replacement, until you switch.
|
|
28
30
|
- **Streaming** via `canopy.ai.chatStream()` for chat-style UIs.
|
|
29
31
|
- **Documents and images.** Messages can attach files — reference something
|
|
30
32
|
in the project's [blob storage](https://docs.canopy.website/sdk/blob) by key, or inline base64 — see
|
|
@@ -35,10 +35,19 @@ nobody outside the team can touch its data or settings.
|
|
|
35
35
|
|
|
36
36
|
## Inviting collaborators
|
|
37
37
|
|
|
38
|
-
Open the project's **Settings** tab and use the **Collaborators** card.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
Open the project's **Settings** tab and use the **Collaborators** card. Start
|
|
39
|
+
typing a name or email: colleagues who have signed in to Canopy before show up
|
|
40
|
+
as suggestions, and picking one adds them immediately.
|
|
41
|
+
|
|
42
|
+
Someone who has never opened Canopy can be added too — type their full
|
|
43
|
+
company email and choose **Invite by email**. They appear on the roster as
|
|
44
|
+
**invited** and become a collaborator automatically the first time they sign
|
|
45
|
+
in, whether that's opening the project's URL or running `canopy login`. Until
|
|
46
|
+
then the invite grants nothing. You can withdraw a pending invite from the
|
|
47
|
+
same card. Canopy doesn't send them a message, so let them know yourself.
|
|
48
|
+
|
|
49
|
+
Only company addresses that can sign in to Canopy can be invited. Owners (and
|
|
50
|
+
admins) can also remove people from the same card.
|
|
42
51
|
|
|
43
52
|
## Collaborators-only mode
|
|
44
53
|
|
|
@@ -3,6 +3,12 @@ title: Slack
|
|
|
3
3
|
description: Give your project its own Slack bot — Canopy creates and manages the app, your code handles the events.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
+
> **A bot, or just a message?** If all your project needs is to post to a
|
|
7
|
+
> channel or DM a colleague, skip this page: `canopy.notify.slack` does that
|
|
8
|
+
> through the Canopy bot with nothing to set up. Read
|
|
9
|
+
> [Notify](https://docs.canopy.website/sdk/notify). Come back here when people need to talk to your
|
|
10
|
+
> bot, run slash commands, or see your project's own name and icon.
|
|
11
|
+
|
|
6
12
|
Your project can have its own Slack app: its own name and bot, in whatever
|
|
7
13
|
workspace you choose. Canopy creates the app, keeps its settings in sync,
|
|
8
14
|
holds every credential, and routes Slack's traffic to your backend. Your
|
package/dist/docs/sdk-ai.md
CHANGED
|
@@ -8,6 +8,29 @@ Every request authenticates as the project and is metered against hard
|
|
|
8
8
|
per-project budgets — spend, limits, and how prompts are handled by the
|
|
9
9
|
model providers are covered in [AI access & usage](https://docs.canopy.website/platform/ai-usage).
|
|
10
10
|
|
|
11
|
+
## Picking a model
|
|
12
|
+
|
|
13
|
+
Name a quality tier instead of a model id, and the platform answers with the
|
|
14
|
+
best current model for it:
|
|
15
|
+
|
|
16
|
+
- `auto/low` — fast and cheap. The **default** when you leave `model` out.
|
|
17
|
+
- `auto/mid` — a good balance for most product features.
|
|
18
|
+
- `auto/high` — the most capable model, for hard reasoning and long documents.
|
|
19
|
+
|
|
20
|
+
Each alias moves to newer models as they arrive, without a code change. You
|
|
21
|
+
can also name a specific model from the site's list. Run
|
|
22
|
+
`canopy ai models` to see the aliases, what each answers with right now, and
|
|
23
|
+
every model with its price:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
canopy ai models
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Only names on that list work: anything else is refused with a `400` that lists
|
|
30
|
+
what is available. When a model is retired, calls naming it keep working,
|
|
31
|
+
answered by its replacement: `res.model` names the model that answered, and
|
|
32
|
+
the response carries a `deprecation` notice (below) until you switch.
|
|
33
|
+
|
|
11
34
|
## ai.chat(options)
|
|
12
35
|
|
|
13
36
|
One-shot chat completion. Takes a `ChatOptions` object, resolves to a
|
|
@@ -19,7 +42,7 @@ const res = await canopy.ai.chat({
|
|
|
19
42
|
{ role: "system", content: "You are terse." },
|
|
20
43
|
{ role: "user", content: "Say hi in five words." },
|
|
21
44
|
],
|
|
22
|
-
model: "
|
|
45
|
+
model: "auto/mid", // optional — defaults to auto/low
|
|
23
46
|
temperature: 0.7, // optional, 0–2
|
|
24
47
|
maxTokens: 256, // optional
|
|
25
48
|
});
|
|
@@ -34,10 +57,10 @@ res.usage; // { inputTokens: number, outputTokens: number }
|
|
|
34
57
|
- `messages` — required, non-empty array of `{ role, content }`. `role` is
|
|
35
58
|
`"system" | "user" | "assistant"`; `content` is a non-empty string, or an
|
|
36
59
|
array of content parts to attach documents and images (below).
|
|
37
|
-
- `model` — optional `
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
- `model` — optional: an alias (`auto/low`, `auto/mid`, `auto/high`) or a
|
|
61
|
+
`"{provider}/{model}"` id from the site's list (`canopy ai models`). Omit it
|
|
62
|
+
and `auto/low` answers. A name that isn't on the list fails with a message
|
|
63
|
+
listing what _is_ available.
|
|
41
64
|
- `temperature` — optional number between 0 and 2. Reasoning models (the
|
|
42
65
|
GPT-5 family) only accept their default temperature, so the platform drops
|
|
43
66
|
this setting for them rather than failing the call — it has effect on
|
|
@@ -52,7 +75,11 @@ res.usage; // { inputTokens: number, outputTokens: number }
|
|
|
52
75
|
|
|
53
76
|
**`ChatResponse`:** `{ message, model, usage: { inputTokens, outputTokens } }`.
|
|
54
77
|
`message.role` is `"assistant"` and `message.content` is always a plain
|
|
55
|
-
string, whatever went in.
|
|
78
|
+
string, whatever went in. `model` is the model that actually answered. When
|
|
79
|
+
you named a retired model, the response also has
|
|
80
|
+
`deprecation: { requested, replacement }`. Switch to the replacement or an
|
|
81
|
+
alias. Streams and the endpoints below say the same with a `Deprecation:
|
|
82
|
+
true` response header.
|
|
56
83
|
|
|
57
84
|
## Attaching documents and images
|
|
58
85
|
|
|
@@ -143,8 +170,10 @@ The compat endpoint speaks OpenAI's chat-completions dialect to every model —
|
|
|
143
170
|
portable, but the lowest common denominator. When you want a provider's own
|
|
144
171
|
protocol — Anthropic prompt caching, PDFs with citations, or OpenAI's
|
|
145
172
|
Responses API — use the native base URLs from `endpoint()`. Requests pass
|
|
146
|
-
through to the provider unchanged; auth, the model
|
|
147
|
-
|
|
173
|
+
through to the provider unchanged; auth, the model list, the daily budget,
|
|
174
|
+
and metering all still apply. Aliases work here too: on the Anthropic endpoint
|
|
175
|
+
`auto/mid` picks the best Claude model for that tier, on the OpenAI endpoint
|
|
176
|
+
the best OpenAI one.
|
|
148
177
|
|
|
149
178
|
```ts
|
|
150
179
|
import Anthropic from "@anthropic-ai/sdk";
|
|
@@ -152,7 +181,7 @@ import Anthropic from "@anthropic-ai/sdk";
|
|
|
152
181
|
const { native } = await canopy.ai.endpoint();
|
|
153
182
|
const anthropic = new Anthropic({ baseURL: native.anthropic, apiKey: "unused" });
|
|
154
183
|
const msg = await anthropic.messages.create({
|
|
155
|
-
model: "
|
|
184
|
+
model: "auto/mid", // an alias, or a model id from `canopy ai models` (bare or "anthropic/…")
|
|
156
185
|
max_tokens: 1024,
|
|
157
186
|
messages: [{ role: "user", content: "Hello" }],
|
|
158
187
|
});
|
|
@@ -188,7 +217,9 @@ as the `message`. Two worth handling:
|
|
|
188
217
|
or after a maintainer raises it). Everything else in your app keeps
|
|
189
218
|
working.
|
|
190
219
|
- **Invalid request** — status `400`, body contains `"invalid_chat"` and a
|
|
191
|
-
detail explaining which option was rejected.
|
|
220
|
+
detail explaining which option was rejected. A model that isn't on the
|
|
221
|
+
site's list lands here too, with the available aliases and models in the
|
|
222
|
+
detail.
|
|
192
223
|
|
|
193
224
|
> **Note:** Budgets are enforced before every call, so a runaway loop becomes a capped bill — see [AI access
|
|
194
225
|
> & usage](https://docs.canopy.website/platform/ai-usage) for how spend is tracked and displayed.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Notify
|
|
3
|
+
description: Post a Slack message from your project through the Canopy bot — to your linked channels or a colleague's DMs — with nothing to set up.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`canopy.notify` tells people things on your project's behalf. The Canopy bot,
|
|
7
|
+
the one that already DMs you about reports and deploys, delivers the message
|
|
8
|
+
and says which project sent it. There is no Slack app to create, no token, no
|
|
9
|
+
install: it works the moment your project has something to say.
|
|
10
|
+
|
|
11
|
+
Two rules are enforced by the platform, not by your code:
|
|
12
|
+
|
|
13
|
+
- **You reach people your project has a relationship with.** Channels are the
|
|
14
|
+
ones a project member linked with `/canopy link`; people are Canopy users,
|
|
15
|
+
named by the email they sign in with. Nothing else is addressable.
|
|
16
|
+
- **A daily quota per project**, 200 deliveries by default. Each person and
|
|
17
|
+
each channel a message goes to counts as one.
|
|
18
|
+
|
|
19
|
+
## notify.slack(options)
|
|
20
|
+
|
|
21
|
+
Resolves once the platform has queued the message:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// Every channel linked to the project.
|
|
25
|
+
await canopy.notify.slack({ text: "Export finished: *1,204* rows." });
|
|
26
|
+
|
|
27
|
+
// A colleague, and nobody else.
|
|
28
|
+
await canopy.notify.slack({
|
|
29
|
+
to: "someone@monks.com",
|
|
30
|
+
text: "Your approval is needed on <https://…|request #42>.",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// People and the linked channels together.
|
|
34
|
+
await canopy.notify.slack({ to: ["a@monks.com", "b@monks.com"], channels: true, text: "…" });
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**`NotifySlackOptions`:**
|
|
38
|
+
|
|
39
|
+
- `text` — required, up to 2000 characters of Slack mrkdwn. Bold, links and
|
|
40
|
+
`<@U…>` mentions work.
|
|
41
|
+
- `to` — optional. One email or an array of up to 20, each the address a
|
|
42
|
+
Canopy user signs in with. Leave it out to post to the linked channels
|
|
43
|
+
instead.
|
|
44
|
+
- `channels` — optional. With `to`, also post to the linked channels. Without
|
|
45
|
+
`to` it is always on.
|
|
46
|
+
|
|
47
|
+
**Returns** `{ queued }`, the number of deliveries queued: people plus
|
|
48
|
+
channels. Delivery happens right after, with retries if Slack is slow; the
|
|
49
|
+
outcome shows on the project's Usage tab.
|
|
50
|
+
|
|
51
|
+
## What the message looks like
|
|
52
|
+
|
|
53
|
+
Every message opens with a line the platform writes: your project's name,
|
|
54
|
+
linked to its dashboard page, and "sent by this project via Canopy". Your
|
|
55
|
+
text follows as a new top-level message. Nothing threads onto earlier
|
|
56
|
+
messages, and there are no buttons or blocks: this is a notification, not a
|
|
57
|
+
conversation.
|
|
58
|
+
|
|
59
|
+
If someone replies under it, the Canopy assistant answers that the message
|
|
60
|
+
came from your project and that replies don't reach it. If you want replies
|
|
61
|
+
to reach your code, you want [a bot of your own](https://docs.canopy.website/platform/slack).
|
|
62
|
+
|
|
63
|
+
## Who receives it
|
|
64
|
+
|
|
65
|
+
Channels get every message. People are protected the same way platform
|
|
66
|
+
notifications protect them: a colleague who muted your project hears
|
|
67
|
+
nothing, and anyone can turn off "Messages from projects" in their
|
|
68
|
+
notification settings. Someone whose email has no Slack account is skipped.
|
|
69
|
+
None of this is reported back to your code, so `queued` counts what was
|
|
70
|
+
sent, not what arrived.
|
|
71
|
+
|
|
72
|
+
An email that is not a Canopy user is refused outright, so you notice the
|
|
73
|
+
typo. Everyone at Monks becomes a Canopy user by opening the dashboard once.
|
|
74
|
+
|
|
75
|
+
## Linking a channel
|
|
76
|
+
|
|
77
|
+
Type `/canopy link <your-project-slug>` in the channel, then invite `@Canopy`
|
|
78
|
+
to it. The channel now receives the project's messages, and its activity
|
|
79
|
+
too: reports, project events, deploys. Linked channels are listed under the
|
|
80
|
+
project's **Services → Slack**, where any member can unlink one.
|
|
81
|
+
|
|
82
|
+
Until a channel is linked, a send without `to` fails with a 404 rather than
|
|
83
|
+
disappearing silently.
|
|
84
|
+
|
|
85
|
+
## Sending from the backend
|
|
86
|
+
|
|
87
|
+
Like email, this is meant for code that runs with nobody signed in: a
|
|
88
|
+
[scheduled](https://docs.canopy.website/platform/schedules) job, a worker route reacting to a webhook.
|
|
89
|
+
It authenticates as the project itself. Browser code cannot call it.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
export default {
|
|
93
|
+
async scheduled(event, env) {
|
|
94
|
+
const canopy = createCanopy({ fetch: env.CANOPY.fetch.bind(env.CANOPY) });
|
|
95
|
+
const stale = await staleRequests(env.DB);
|
|
96
|
+
if (stale.length === 0) return;
|
|
97
|
+
|
|
98
|
+
await canopy.notify.slack({
|
|
99
|
+
text: `${stale.length} approval request${stale.length === 1 ? "" : "s"} waiting longer than a week.`,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Seeing what was sent
|
|
106
|
+
|
|
107
|
+
The project's **Usage** tab shows today's count against the quota and the
|
|
108
|
+
recent sends: when, to whom, the first line, and whether it was delivered.
|
|
109
|
+
Only that first line is kept.
|
|
110
|
+
|
|
111
|
+
## When it fails
|
|
112
|
+
|
|
113
|
+
Every failure is a `CanopyError` with a status you can branch on:
|
|
114
|
+
|
|
115
|
+
- **400** — the text is empty or too long, `to` has more than 20 entries, or
|
|
116
|
+
an address is not a Canopy user (`unknown_recipient`, naming it).
|
|
117
|
+
- **404** — no `to` and no linked channel (`no_linked_channel`).
|
|
118
|
+
- **429** — the project used its daily quota, or the platform-wide ceiling is
|
|
119
|
+
reached. Both reset at UTC midnight.
|
|
120
|
+
- **501** — this Canopy site has no Canopy bot.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { CanopyError } from "@mediamonks/canopy";
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await canopy.notify.slack({ text });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof CanopyError && err.status === 404) {
|
|
129
|
+
// Nobody has linked a channel yet — tell a member to run /canopy link.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## What this is not
|
|
135
|
+
|
|
136
|
+
This is not a bot. It cannot listen, answer, show buttons, run slash
|
|
137
|
+
commands, post in a client's workspace or carry your project's name and
|
|
138
|
+
icon. The moment you want any of that, give the project [its own Slack
|
|
139
|
+
app](https://docs.canopy.website/platform/slack): a few minutes of setup, and `canopy.slack` does the
|
|
140
|
+
rest.
|