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.
@@ -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
- reportPushResult(props, result, "plan", utilizedServices(config));
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,
@@ -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.32.0",
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.0",
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.0",
55
- "@neon/config": "0.9.2",
56
- "@neon/config-runtime": "0.9.2",
57
- "@neon/env": "0.11.1"
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"