@phantomcodes/cli 1.2.7 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,7 +70,9 @@ pi gets the agent skill.
70
70
 
71
71
  Preview without changing files with `phantom setup --dry-run`. To leave your
72
72
  shell startup file alone, untick "Your shell" in the checklist or pass
73
- `--no-shell`; setup then prints the lines to add yourself. The Phantom CLI keeps backups;
73
+ `--no-shell`; setup then prints the lines to add yourself. Cap what the key can spend each
74
+ month with `phantom setup --limit 20` (`--limit none` removes it); at a terminal,
75
+ setup asks once when the key has no cap. The Phantom CLI keeps backups;
74
76
  undo the changes with `phantom setup --restore`.
75
77
 
76
78
  ## Connect for one run
@@ -180,6 +182,7 @@ it cancels. A named model, not `phantom-1`, still spends credit.
180
182
  | --- | --- |
181
183
  | Connect | `phantom login`, `phantom logout` |
182
184
  | Connect existing agents | `phantom setup`, `phantom setup --dry-run`, `phantom setup --restore` |
185
+ | Providers that answer soonest | `phantom route set --sort fast` (`--sort default` for cheapest first) |
183
186
  | Run inference | `phantom claude`, `phantom codex`, `phantom pi` |
184
187
  | Fund and check spending | `phantom fund`, `phantom balance`, `phantom spend`, `phantom workspace status` |
185
188
  | Manage workspace keys | `phantom keys create`, `phantom keys set`, `phantom keys rotate`, `phantom keys revoke` |
@@ -119,6 +119,9 @@ export const COMMANDS = [
119
119
  name: 'route', handler: routeCommand,
120
120
  group: 'Routing', summary: 'which models "auto" runs, and rules for picking',
121
121
  help: ` route [get] show which model "auto" runs, and why
122
+ route set --sort fast|cheap|throughput|default
123
+ which providers serve this key's models first: fast
124
+ starts answering soonest; cheap is the default
122
125
  route set --models a,b,c models for "auto", first is the default
123
126
  [--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
124
127
  [--stick-minutes n] keep a conversation's model this long (default 5)
@@ -162,6 +165,7 @@ export const COMMANDS = [
162
165
  [--diff] show every line each file gains or loses
163
166
  [--no-shell] leave your shell startup file alone and list
164
167
  the lines to add yourself
168
+ [--limit <USD|none>] cap what this key can spend each month
165
169
  [--json] print JSON in a terminal too (a pipe always gets JSON)
166
170
  [--model <id>] the model each agent starts on (default ${SETUP_MODEL})
167
171
  setup --agent <name> install the skill for one agent, without asking:
@@ -178,12 +178,15 @@ export function routeScreen(r, style = {}) {
178
178
  const w = ruleWidth(style);
179
179
  const lines = ['', ...caption(p, w, 'ROUTING', p.dim('how model "auto" picks for this key'))];
180
180
  const policy = r.route_policy;
181
- if (!policy) {
182
- lines.push(' No route policy, so model "auto" runs phantom-1, which picks a model for each request.');
183
- lines.push(...next(p, 'Make one:', 'phantom route set --models a,b,c'));
181
+ const row = (label, value) => ` ${p.dim(label.padEnd(14))}${value}`;
182
+ // Which providers serve first holds for every request, routed or not.
183
+ const sortWords = { cost: 'cheapest first', ttft: 'fastest to start answering first', tps: 'most tokens a second first' };
184
+ const providers = row('providers', `${sortWords[policy?.sort ?? 'cost']}${policy?.sort ? '' : p.dim(' (the default)')}${policy?.zdr ? ', zero data retention only' : ''}${policy?.no_training ? ', no training on prompts' : ''}`);
185
+ if (!policy || !policy.models.length) {
186
+ lines.push(' No route policy, so model "auto" runs phantom-1, which picks a model for each request.', '', providers);
187
+ lines.push(...(policy?.sort ? next(p, 'Back to cheapest first:', 'phantom route set --sort default') : next(p, 'Providers that start answering soonest:', 'phantom route set --sort fast')));
184
188
  return lines.join('\n');
185
189
  }
186
- const row = (label, value) => ` ${p.dim(label.padEnd(14))}${value}`;
187
190
  // One model a line, in the order "auto" tries them, so a long list doesn't wrap.
188
191
  policy.models.forEach((m, k) => lines.push(row(k === 0 ? 'models' : '', `${p.dim(`${k + 1}.`)} ${k === 0 ? `${p.bold(m)} ${p.dim('the default')}` : m}`)));
189
192
  lines.push(row('applies to', policy.applies_to === 'all' ? 'every request' : 'requests for model "auto"'));
@@ -191,6 +194,7 @@ export function routeScreen(r, style = {}) {
191
194
  lines.push(row('a model fails', policy.fallback_on_error ? 'try the next one' : 'stop there'));
192
195
  const stick = policy.stick_minutes ?? 5;
193
196
  lines.push(row('conversations', stick ? `stay on their model for ${plural(stick, 'minute')}${policy.stick_by_prompt === false ? ' (by x-phantom-session only)' : ''}` : 'can change model every request'));
197
+ lines.push(providers);
194
198
  const rules = policy.rules ?? [];
195
199
  lines.push('', ...caption(p, w, 'RULES', p.dim(rules.length ? 'checked in order; the first that matches picks' : '')));
196
200
  if (!rules.length)
@@ -1,2 +1,2 @@
1
1
  /** This release of phantom. `npm version` keeps it equal to the version in package.json. */
2
- export const VERSION = '1.2.7';
2
+ export const VERSION = '1.2.8';
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline/promises';
4
4
  import { die } from "../core/errors.mjs";
5
5
  import { out, pickIo } from "../core/helpers.mjs";
6
6
  import { BackToMenu, checklist, choose } from "../core/pick.mjs";
7
+ import { request } from "../core/request.mjs";
7
8
  import { resolveApiKey } from "../keys/saved.mjs";
8
9
  import { workspaceLogin } from "../workspace/command.mjs";
9
10
  import { SETUP_AGENTS, SETUP_NAMES } from "./agents.mjs";
@@ -104,6 +105,8 @@ export async function setupCommand(input) {
104
105
  // Plain `phantom setup` sets up everything it finds and asks first. Any of
105
106
  // the older flags runs just what they name, without asking.
106
107
  const guided = agent === undefined && provider === undefined;
108
+ // Checked before anything is written, so a typo in --limit changes nothing.
109
+ const limit = capFlag(flags['limit']);
107
110
  // With no key yet, sign in first, as phantom claude does, so the agents
108
111
  // get one in this run. Its words go to stderr; stdout stays setup's result.
109
112
  if (guided && !dryRun && io.isTTY && !resolveApiKey(env)) {
@@ -196,7 +199,7 @@ export async function setupCommand(input) {
196
199
  }
197
200
  break;
198
201
  }
199
- return finish(applySetup(env, plan));
202
+ return capAfter(input, await finish(applySetup(env, plan)), limit, guided ? pick : null);
200
203
  }
201
204
  const ask = io.ask ??
202
205
  (process.stdin.isTTY
@@ -219,5 +222,49 @@ export async function setupCommand(input) {
219
222
  return 0;
220
223
  }
221
224
  }
222
- return finish(applySetup(env, plan));
225
+ return capAfter(input, await finish(applySetup(env, plan)), limit, null);
226
+ }
227
+ /** `--limit`: dollars a month above zero, `none` to remove the cap, or undefined when it isn't given. */
228
+ function capFlag(value) {
229
+ if (value === undefined)
230
+ return undefined;
231
+ if (value === 'none')
232
+ return null;
233
+ const usd = Number(String(value).replace(/^\$/, ''));
234
+ if (typeof value !== 'string' || !Number.isFinite(usd) || usd <= 0)
235
+ die('--limit takes dollars a month above zero, such as 20, or none', 'setup_limit');
236
+ return usd;
237
+ }
238
+ /**
239
+ * After setup has written its files: the key's monthly spend cap (PATCH
240
+ * /key/budget, a calendar month). `--limit` sets it; without one, setup at a
241
+ * terminal asks once, only when the key has no cap yet. Returns setup's exit code.
242
+ */
243
+ async function capAfter(input, code, limit, pick) {
244
+ const { env, io, baseUrl } = input;
245
+ const apiKey = resolveApiKey(env);
246
+ if (!apiKey || (limit === undefined && !pick))
247
+ return code;
248
+ let cap = limit;
249
+ if (cap === undefined) {
250
+ const now = await request('GET', '/key/budget', apiKey, undefined, baseUrl).catch(() => null);
251
+ if (!now || now.budget_usd !== null)
252
+ return code;
253
+ io.stderr('\n');
254
+ const answer = await choose(pick, 'Cap what this key can spend each month?', [
255
+ { label: 'No cap', hint: 'free use is never charged', value: 0 },
256
+ { label: '$10 a month', value: 10 },
257
+ { label: '$25 a month', value: 25 },
258
+ { label: '$50 a month', value: 50 },
259
+ ]);
260
+ if (!answer)
261
+ return code;
262
+ cap = answer;
263
+ }
264
+ await request('PATCH', '/key/budget', apiKey, cap === null ? { budget_usd: null } : { budget_usd: cap, period_days: null }, baseUrl);
265
+ const p = paint(env, colorOn(env, io.isTTY));
266
+ io.stderr(cap === null
267
+ ? ` ${p.lime('✓')} This key has no monthly cap now.\n`
268
+ : ` ${p.lime('✓')} This key can spend at most $${cap} a month. Change it any time: ${p.cyan('phantom setup --limit <USD|none>')}\n`);
269
+ return code;
223
270
  }
@@ -6,7 +6,7 @@ import { colorOn, paint } from "../core/style.mjs";
6
6
  import { choose } from "../core/pick.mjs";
7
7
  import { hideHints, lineupScreen, priceHint, routeScreen, routeTestScreen, TIER_ABOUT } from "../cli/screens.mjs";
8
8
  import { getLineup, putLineup, resetLineup, LINEUP_TIERS } from "./lineup.mjs";
9
- import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./routing.mjs";
9
+ import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition, SORT_WORDS } from "./routing.mjs";
10
10
  /** `phantom route`: the key's route policy, its rules, and a dry run. Run by `run()`. */
11
11
  export async function routeCommand(input) {
12
12
  const { argv, io, apiKey, subcommand, flags, baseUrl, env } = input;
@@ -44,11 +44,28 @@ export async function routeCommand(input) {
44
44
  fields.stick_minutes = stick;
45
45
  if (flags['stick-by-prompt'] !== undefined)
46
46
  fields.stick_by_prompt = flags['stick-by-prompt'] !== 'false';
47
- if (Object.keys(fields).length === 0) {
48
- die('route set needs --models a,b,c, --applies-to, --on-empty, --fallback, --stick-minutes, --stick-by-prompt, or --file policy.json');
47
+ const sort = flags['sort'];
48
+ if (sort !== undefined && (typeof sort !== 'string' || !(sort in SORT_WORDS || sort === 'default'))) {
49
+ die('--sort takes fast (quickest to start answering), cheap (the default), throughput (most tokens a second) or default', 'route_usage');
50
+ }
51
+ if (typeof sort === 'string' && sort !== 'default')
52
+ fields.sort = SORT_WORDS[sort];
53
+ if (Object.keys(fields).length === 0 && sort !== 'default') {
54
+ die('route set needs --models a,b,c, --sort, --applies-to, --on-empty, --fallback, --stick-minutes, --stick-by-prompt, or --file policy.json');
49
55
  }
50
56
  const current = (await getRoute(apiKey, baseUrl)).route_policy;
51
- const result = current ? await patchRoute(apiKey, fields, baseUrl) : await putRoute(apiKey, fields, baseUrl);
57
+ let result;
58
+ if (sort === 'default') {
59
+ // Back to cheapest first: drop sort, and the whole policy when sort was all it held.
60
+ const { sort: _was, ...rest } = { ...(current ?? { models: [] }), ...fields };
61
+ void _was;
62
+ const empty = !rest.models.length && !rest.zdr && !rest.no_training && !(rest.rules ?? []).length;
63
+ result = !current ? { route_policy: null } : empty ? await clearRoute(apiKey, baseUrl) : await putRoute(apiKey, rest, baseUrl);
64
+ }
65
+ else {
66
+ // A key with no policy gets one of preferences only, which routes nothing, unless --models is given too.
67
+ result = current ? await patchRoute(apiKey, fields, baseUrl) : await putRoute(apiKey, { models: [], ...fields }, baseUrl);
68
+ }
52
69
  out(result, table, tableRoute, io.stdout);
53
70
  }
54
71
  }
@@ -1,5 +1,7 @@
1
1
  import { die } from "../core/errors.mjs";
2
2
  import { request } from "../core/request.mjs";
3
+ /** The words `phantom route set --sort` takes, and the server's name for each. The server's own names work too. */
4
+ export const SORT_WORDS = { cheap: 'cost', fast: 'ttft', throughput: 'tps', cost: 'cost', ttft: 'ttft', tps: 'tps' };
3
5
  /** The key's route policy (GET /key/route). */
4
6
  export function getRoute(apiKey, baseUrl) {
5
7
  return request('GET', '/key/route', apiKey, undefined, baseUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
5
  "keywords": [
6
6
  "phantom",