@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 +4 -1
- package/dist/cli/commands.mjs +4 -0
- package/dist/cli/screens.mjs +8 -4
- package/dist/core/version.mjs +1 -1
- package/dist/setup/command.mjs +49 -2
- package/dist/spend/command.mjs +21 -4
- package/dist/spend/routing.mjs +2 -0
- package/package.json +1 -1
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.
|
|
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` |
|
package/dist/cli/commands.mjs
CHANGED
|
@@ -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:
|
package/dist/cli/screens.mjs
CHANGED
|
@@ -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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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)
|
package/dist/core/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const VERSION = '1.2.8';
|
package/dist/setup/command.mjs
CHANGED
|
@@ -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
|
}
|
package/dist/spend/command.mjs
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/spend/routing.mjs
CHANGED
|
@@ -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);
|