neonctl 2.32.0 → 2.33.1
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/commands/config.js +57 -1
- package/dist/commands/env.js +15 -0
- package/dist/utils/ai_gateway_notice.js +173 -0
- package/package.json +6 -6
package/dist/commands/config.js
CHANGED
|
@@ -3,11 +3,13 @@ import { join } from "node:path";
|
|
|
3
3
|
import { resolveConfig } from "@neon/config";
|
|
4
4
|
import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, PushConflictError, plan, } from "@neon/config-runtime";
|
|
5
5
|
import chalk from "chalk";
|
|
6
|
+
import { getApiClient } from "../api.js";
|
|
6
7
|
import { toNeonConfigView } from "../config_format.js";
|
|
7
8
|
import { contextBranch, readContextFile } from "../context.js";
|
|
8
9
|
import { isCi } from "../env.js";
|
|
9
10
|
import { loadEnvFileIntoProcess } from "../env_file.js";
|
|
10
11
|
import { log } from "../log.js";
|
|
12
|
+
import { assertAiGatewayProvisionable, warnAiGateway, } from "../utils/ai_gateway_notice.js";
|
|
11
13
|
import { announceTargetBranch } from "../utils/branch_notice.js";
|
|
12
14
|
import { renderAppliedChanges, renderBranchSettingConflicts, } from "../utils/config_diff.js";
|
|
13
15
|
import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
|
|
@@ -311,13 +313,33 @@ export const planCmd = async (props) => {
|
|
|
311
313
|
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
312
314
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
313
315
|
});
|
|
314
|
-
|
|
316
|
+
const services = utilizedServices(config);
|
|
317
|
+
reportPushResult(props, result, "plan", services);
|
|
318
|
+
// `plan` is a dry run and never pulls credentials, so it can only offer the plan-based
|
|
319
|
+
// (Free) AI Gateway notice — the reduced-model-set check needs a live gateway token,
|
|
320
|
+
// which `apply`/`checkout`/`env pull` get via the bundled env pull. Best-effort.
|
|
321
|
+
if (services.includes("AI Gateway")) {
|
|
322
|
+
await warnAiGateway({
|
|
323
|
+
apiClient: props.apiClient,
|
|
324
|
+
projectId: props.projectId,
|
|
325
|
+
branchId,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
315
328
|
};
|
|
316
329
|
export const applyCmd = async (props) => {
|
|
317
330
|
const config = await loadConfig(props);
|
|
318
331
|
const branch = await resolveBranchRef(props);
|
|
319
332
|
announceTargetBranch(props, branch, "Applying to branch");
|
|
320
333
|
const branchId = branch.branchId;
|
|
334
|
+
// The AI Gateway can't serve on the Free plan, so refuse to provision it up front rather
|
|
335
|
+
// than write a credential that won't work. Only when the policy actually enables the
|
|
336
|
+
// gateway; best-effort on the plan lookup (a transient failure never blocks a paid user).
|
|
337
|
+
if (utilizedServices(config).includes("AI Gateway")) {
|
|
338
|
+
await assertAiGatewayProvisionable({
|
|
339
|
+
apiClient: props.apiClient,
|
|
340
|
+
projectId: props.projectId,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
321
343
|
let result;
|
|
322
344
|
try {
|
|
323
345
|
result = await apply(config, {
|
|
@@ -466,6 +488,26 @@ const reportConflicts = (props, conflicts) => {
|
|
|
466
488
|
}
|
|
467
489
|
}
|
|
468
490
|
};
|
|
491
|
+
/**
|
|
492
|
+
* Block provisioning the AI Gateway on a Free plan from the `checkout` policy paths, which
|
|
493
|
+
* carry raw credentials (`apiKey`/`apiHost`) rather than the CLI's api client. Builds a client
|
|
494
|
+
* from them and defers to {@link assertAiGatewayProvisionable}. Skipped when a `runtimeApi` is
|
|
495
|
+
* injected (tests) or no `apiKey` is available; the interactive commands always have a key.
|
|
496
|
+
*/
|
|
497
|
+
const assertAiGatewayProvisionableFromCreds = async (props) => {
|
|
498
|
+
if (props.runtimeApi || !props.apiKey)
|
|
499
|
+
return;
|
|
500
|
+
if (!utilizedServices(props.config).includes("AI Gateway"))
|
|
501
|
+
return;
|
|
502
|
+
const apiClient = getApiClient({
|
|
503
|
+
apiKey: props.apiKey,
|
|
504
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
505
|
+
});
|
|
506
|
+
await assertAiGatewayProvisionable({
|
|
507
|
+
apiClient,
|
|
508
|
+
projectId: props.projectId,
|
|
509
|
+
});
|
|
510
|
+
};
|
|
469
511
|
/**
|
|
470
512
|
* Apply a `neon.ts` policy to a **freshly created** branch (used by `neonctl checkout`
|
|
471
513
|
* when it creates a branch). No-op when there is no `neon.ts` on the path from cwd up to
|
|
@@ -488,6 +530,13 @@ export const applyPolicyOnCreate = async (props) => {
|
|
|
488
530
|
return;
|
|
489
531
|
throw err;
|
|
490
532
|
}
|
|
533
|
+
await assertAiGatewayProvisionableFromCreds({
|
|
534
|
+
projectId: props.projectId,
|
|
535
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
536
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
537
|
+
...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
|
|
538
|
+
config,
|
|
539
|
+
});
|
|
491
540
|
log.info("Applying neon.ts policy to the new branch…");
|
|
492
541
|
const result = await apply(config, {
|
|
493
542
|
projectId: props.projectId,
|
|
@@ -535,6 +584,13 @@ export const createBranchFromPolicyOnCheckout = async (props) => {
|
|
|
535
584
|
return null;
|
|
536
585
|
throw err;
|
|
537
586
|
}
|
|
587
|
+
await assertAiGatewayProvisionableFromCreds({
|
|
588
|
+
projectId: props.projectId,
|
|
589
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
590
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
591
|
+
...(props.runtimeApi ? { runtimeApi: props.runtimeApi } : {}),
|
|
592
|
+
config,
|
|
593
|
+
});
|
|
538
594
|
const { branchId, branchName, result } = await createBranchFromPolicy(config, {
|
|
539
595
|
projectId: props.projectId,
|
|
540
596
|
branchName: props.branchName,
|
package/dist/commands/env.js
CHANGED
|
@@ -4,6 +4,7 @@ import chalk from "chalk";
|
|
|
4
4
|
import { resolveNeonEnvVars } from "../dev/env.js";
|
|
5
5
|
import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
|
|
6
6
|
import { log } from "../log.js";
|
|
7
|
+
import { warnAiGateway } from "../utils/ai_gateway_notice.js";
|
|
7
8
|
import { announceTargetBranch } from "../utils/branch_notice.js";
|
|
8
9
|
import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
|
|
9
10
|
export const command = "env";
|
|
@@ -104,6 +105,20 @@ export const pull = async (props, opts = {}) => {
|
|
|
104
105
|
if (removed.length > 0) {
|
|
105
106
|
log.info("Removed %d stale Neon variable%s not enabled on this branch: %s", removed.length, removed.length === 1 ? "" : "s", removed.join(", "));
|
|
106
107
|
}
|
|
108
|
+
// When the branch has the AI Gateway enabled, the pulled credentials always work, but
|
|
109
|
+
// serving is plan-gated and the model set can be reduced on the beta — surface that as a
|
|
110
|
+
// courtesy notice (best-effort; never fails the pull). The freshly pulled token lets us
|
|
111
|
+
// probe the branch's own /v1/models to detect a reduced catalog.
|
|
112
|
+
const gatewayBaseUrl = neonVars.NEON_AI_GATEWAY_BASE_URL;
|
|
113
|
+
const gatewayToken = neonVars.NEON_AI_GATEWAY_TOKEN;
|
|
114
|
+
if (gatewayBaseUrl && gatewayToken) {
|
|
115
|
+
await warnAiGateway({
|
|
116
|
+
apiClient: props.apiClient,
|
|
117
|
+
projectId: props.projectId,
|
|
118
|
+
branchId,
|
|
119
|
+
gateway: { baseUrl: gatewayBaseUrl, token: gatewayToken },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
107
122
|
return { status: "written", written, file: targetPath };
|
|
108
123
|
};
|
|
109
124
|
/**
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { log } from "../log.js";
|
|
2
|
+
/**
|
|
3
|
+
* Friendly guidance shown when a branch enables the AI Gateway (`preview.aiGateway`).
|
|
4
|
+
*
|
|
5
|
+
* The gateway is credential-gated, not provisioned: enabling it always mints a working
|
|
6
|
+
* branch credential, so `apply` / `checkout` / `env pull` succeed regardless of plan. The
|
|
7
|
+
* two things that *do* gate the gateway happen at serving time and are invisible in the
|
|
8
|
+
* provisioning result, so we surface them as a courtesy notice instead:
|
|
9
|
+
*
|
|
10
|
+
* - **Free plan** — credentials provision, but the gateway does not *serve* model requests.
|
|
11
|
+
* The account needs to upgrade to a paid plan.
|
|
12
|
+
* - **Reduced model set** — on a paid plan, an account still ramping up on the beta gets a
|
|
13
|
+
* trimmed model catalog (flagship models are missing from `/v1/models`). They can request
|
|
14
|
+
* access to more models.
|
|
15
|
+
*
|
|
16
|
+
* This is deliberately phrased for the user: it never mentions account "verification" or any
|
|
17
|
+
* other internal gating mechanism.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* `BillingSubscriptionType` values that mean the account is on a Free plan (the gateway
|
|
21
|
+
* won't serve requests). Everything else — `launch`, `scale`, `business`, the `*_v3`
|
|
22
|
+
* variants, marketplace plans — is treated as paid.
|
|
23
|
+
*/
|
|
24
|
+
const FREE_SUBSCRIPTION_TYPES = new Set(["free_v2", "free_v3"]);
|
|
25
|
+
/**
|
|
26
|
+
* The Neon Console billing page to upgrade a Free-plan account so the gateway can serve. It's
|
|
27
|
+
* org-scoped when the project belongs to an org; projects on a personal account (which is
|
|
28
|
+
* where Free plans usually live) have no org id, so we fall back to the account-level page.
|
|
29
|
+
*/
|
|
30
|
+
export const aiGatewayUpgradeUrl = (orgId) => orgId
|
|
31
|
+
? `https://console.neon.tech/app/${orgId}/billing`
|
|
32
|
+
: "https://console.neon.tech/app/billing";
|
|
33
|
+
/**
|
|
34
|
+
* The branch's AI Gateway page in the Neon Console — where a paid user with a reduced model
|
|
35
|
+
* set requests access to more models. It's branch-scoped, so it's built from the linked
|
|
36
|
+
* project and branch ids.
|
|
37
|
+
*/
|
|
38
|
+
export const aiGatewayModelsUrl = (projectId, branchId) => `https://console.neon.tech/app/projects/${projectId}/branches/${branchId}/ai-gateway`;
|
|
39
|
+
export const isFreePlan = (subscriptionType) => subscriptionType !== undefined &&
|
|
40
|
+
FREE_SUBSCRIPTION_TYPES.has(subscriptionType);
|
|
41
|
+
/**
|
|
42
|
+
* Whether a model catalog includes at least one flagship model. Flagship models (Anthropic
|
|
43
|
+
* Opus, OpenAI Codex / `*-pro`) are the first to be held back for an account still ramping
|
|
44
|
+
* up on the beta, so their total absence from a non-empty catalog is the signal that the
|
|
45
|
+
* account has a reduced model set. Matched by id substring so it survives model version
|
|
46
|
+
* bumps (e.g. `claude-opus-4-8`).
|
|
47
|
+
*/
|
|
48
|
+
export const hasFlagshipModels = (modelIds) => modelIds.some((id) => id.includes("opus") || id.includes("codex") || id.endsWith("-pro"));
|
|
49
|
+
/**
|
|
50
|
+
* The message shown when a `neon.ts` that enables the AI Gateway is applied on a Free plan.
|
|
51
|
+
* Provisioning is refused up front (see {@link assertAiGatewayProvisionable}) because the
|
|
52
|
+
* gateway won't serve model requests until the account is on a paid plan. `upgradeUrl` is the
|
|
53
|
+
* org-scoped Console billing page (see {@link aiGatewayUpgradeUrl}).
|
|
54
|
+
*/
|
|
55
|
+
export const freePlanBlockMessage = (upgradeUrl) => "This neon.ts enables the AI Gateway, which isn't available on the Free plan — the " +
|
|
56
|
+
"gateway won't serve model requests. Upgrade to a paid plan and re-run, or remove " +
|
|
57
|
+
`\`preview.aiGateway\` from neon.ts. Upgrade here: ${upgradeUrl}`;
|
|
58
|
+
/**
|
|
59
|
+
* Build the AI Gateway courtesy notice for an account's plan and (optionally) its live model
|
|
60
|
+
* catalog, or `null` when nothing needs saying.
|
|
61
|
+
*
|
|
62
|
+
* `modelIds` is `undefined` when the catalog wasn't probed (e.g. a dry-run `plan`, or a probe
|
|
63
|
+
* that failed); in that case only the plan-based (Free) notice can be produced — never a
|
|
64
|
+
* false "reduced models" warning. `upgradeUrl` is the org-scoped Console billing page
|
|
65
|
+
* (Free notice); `moreModelsUrl` is the branch's Console AI Gateway page
|
|
66
|
+
* (see {@link aiGatewayModelsUrl}), shown when the catalog is reduced.
|
|
67
|
+
*/
|
|
68
|
+
export const buildAiGatewayNotice = ({ subscriptionType, modelIds, upgradeUrl, moreModelsUrl, }) => {
|
|
69
|
+
if (isFreePlan(subscriptionType)) {
|
|
70
|
+
return {
|
|
71
|
+
level: "warning",
|
|
72
|
+
message: "AI Gateway is enabled, but the gateway does not serve model requests on the " +
|
|
73
|
+
`Free plan. Upgrade to a paid plan to start making requests: ${upgradeUrl}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (modelIds !== undefined &&
|
|
77
|
+
modelIds.length > 0 &&
|
|
78
|
+
!hasFlagshipModels(modelIds)) {
|
|
79
|
+
return {
|
|
80
|
+
level: "warning",
|
|
81
|
+
message: "AI Gateway is in public beta and not every model is enabled for your account " +
|
|
82
|
+
"yet, so some models are missing from the catalog. Request access to more " +
|
|
83
|
+
`models here: ${moreModelsUrl}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
89
|
+
/** Pull the string `id`s out of an OpenAI-compatible `{ object: "list", data: [...] }` body. */
|
|
90
|
+
const extractModelIds = (body) => {
|
|
91
|
+
if (!isRecord(body) || !Array.isArray(body.data))
|
|
92
|
+
return null;
|
|
93
|
+
const ids = [];
|
|
94
|
+
for (const entry of body.data) {
|
|
95
|
+
if (isRecord(entry) && typeof entry.id === "string") {
|
|
96
|
+
ids.push(entry.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ids;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* `GET {baseUrl}/v1/models` → the served model ids, or `null` if the catalog can't be read
|
|
103
|
+
* (network / HTTP / parse failure). Returning `null` keeps the notice silent rather than
|
|
104
|
+
* risking a false "reduced models" warning. `/v1/models` is served only on the unified
|
|
105
|
+
* dialect (the `/openai/v1` Responses dialect returns 404).
|
|
106
|
+
*/
|
|
107
|
+
export const fetchGatewayModelIds = async (baseUrl, token) => {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/models`, {
|
|
110
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
return null;
|
|
114
|
+
return extractModelIds(await res.json());
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Refuse to provision the AI Gateway on a Free plan. Called before the `neon.ts` lifecycle
|
|
122
|
+
* commands provision a branch (`config apply` / `deploy`, `checkout`), so a Free-plan user
|
|
123
|
+
* gets a clear "upgrade first" error instead of a credential that can't serve requests.
|
|
124
|
+
*
|
|
125
|
+
* Best-effort on the plan lookup: if the plan can't be determined (network / API failure) we
|
|
126
|
+
* do NOT block, so a transient error never wrongly refuses a paid user's deploy. Only a
|
|
127
|
+
* positively-identified Free plan throws.
|
|
128
|
+
*/
|
|
129
|
+
export const assertAiGatewayProvisionable = async (params) => {
|
|
130
|
+
let subscriptionType;
|
|
131
|
+
let orgId;
|
|
132
|
+
try {
|
|
133
|
+
const { data } = await params.apiClient.getProject(params.projectId);
|
|
134
|
+
subscriptionType = data.project.owner?.subscription_type;
|
|
135
|
+
orgId = data.project.org_id ?? undefined;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return; // Can't determine the plan — don't block.
|
|
139
|
+
}
|
|
140
|
+
if (isFreePlan(subscriptionType)) {
|
|
141
|
+
throw new Error(freePlanBlockMessage(aiGatewayUpgradeUrl(orgId)));
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the account's plan (and, when gateway credentials are on hand, its live model
|
|
146
|
+
* catalog) and print the AI Gateway courtesy notice — used by the `neon.ts` lifecycle and
|
|
147
|
+
* `env pull` whenever a branch has the gateway enabled.
|
|
148
|
+
*
|
|
149
|
+
* Pass `gateway` (base URL + token) to enable the reduced-model-set check; omit it (e.g. a
|
|
150
|
+
* dry-run `plan`) to get only the Free-plan notice. This is best-effort: any failure while
|
|
151
|
+
* fetching the plan or catalog is swallowed so it can never break the underlying command.
|
|
152
|
+
*/
|
|
153
|
+
export const warnAiGateway = async (params) => {
|
|
154
|
+
try {
|
|
155
|
+
const { data } = await params.apiClient.getProject(params.projectId);
|
|
156
|
+
const subscriptionType = data.project.owner?.subscription_type;
|
|
157
|
+
const orgId = data.project.org_id ?? undefined;
|
|
158
|
+
const modelIds = params.gateway
|
|
159
|
+
? ((await fetchGatewayModelIds(params.gateway.baseUrl, params.gateway.token)) ?? undefined)
|
|
160
|
+
: undefined;
|
|
161
|
+
const notice = buildAiGatewayNotice({
|
|
162
|
+
subscriptionType,
|
|
163
|
+
modelIds,
|
|
164
|
+
upgradeUrl: aiGatewayUpgradeUrl(orgId),
|
|
165
|
+
moreModelsUrl: aiGatewayModelsUrl(params.projectId, params.branchId),
|
|
166
|
+
});
|
|
167
|
+
if (notice)
|
|
168
|
+
log.warning(notice.message);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// A courtesy notice must never break the command that triggered it.
|
|
172
|
+
}
|
|
173
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neonctl",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.33.1",
|
|
4
4
|
"description": "CLI tool for Neon Serverless Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"cliui": "8.0.1",
|
|
43
43
|
"diff": "5.2.0",
|
|
44
44
|
"fflate": "^0.8.3",
|
|
45
|
-
"neon-init": "0.20.
|
|
45
|
+
"neon-init": "0.20.2",
|
|
46
46
|
"open": "^10.2.0",
|
|
47
47
|
"openid-client": "6.8.1",
|
|
48
48
|
"pg-protocol": "^1.14.0",
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
"which": "3.0.1",
|
|
52
52
|
"yaml": "^2.9.0",
|
|
53
53
|
"yargs": "17.7.2",
|
|
54
|
-
"@neon/sdk": "1.1.
|
|
55
|
-
"@neon/config": "0.9.
|
|
56
|
-
"@neon/
|
|
57
|
-
"@neon/
|
|
54
|
+
"@neon/sdk": "1.1.1",
|
|
55
|
+
"@neon/config-runtime": "0.9.4",
|
|
56
|
+
"@neon/env": "0.11.3",
|
|
57
|
+
"@neon/config": "0.9.4"
|
|
58
58
|
},
|
|
59
59
|
"optionalDependencies": {
|
|
60
60
|
"esbuild": "0.28.1"
|