@myapihq/cli 1.3.6 → 1.3.11

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.
@@ -14,6 +14,13 @@ export const EXPOSES = [
14
14
  'POST /funnel/orgs/{org_id}/funnels/{funnel_id}/verify',
15
15
  'POST /funnel/orgs/{org_id}/funnels/{funnel_id}/files',
16
16
  'GET /funnel/orgs/{org_id}/funnels/{funnel_id}/pages',
17
+ // form bindings — created/listed/removed via `funnel form --capture-to`
18
+ // and the underlying SDK helpers. List/Delete don't have dedicated CLI
19
+ // subcommands yet; the SDK functions are wired and customers using the
20
+ // SDK directly need them in EXPOSES for coverage to be honest.
21
+ 'POST /funnel/orgs/{org_id}/funnels/{funnel_id}/forms',
22
+ 'GET /funnel/orgs/{org_id}/funnels/{funnel_id}/forms',
23
+ 'DELETE /funnel/orgs/{org_id}/funnels/{funnel_id}/forms/{slug}',
17
24
  'GET /hq/orgs/{org_id}',
18
25
  ];
19
26
  export const SCHEMA = {
@@ -21,6 +28,12 @@ export const SCHEMA = {
21
28
  slug: 'string',
22
29
  env: 'string',
23
30
  'api-fn': 'string',
31
+ // form
32
+ fields: 'string',
33
+ cta: 'string',
34
+ success: 'string',
35
+ 'capture-to': 'string',
36
+ honeypot: 'string',
24
37
  };
25
38
  // Backend (2026-05-15): POST /funnels accepts an optional `name` (defaults
26
39
  // to the org's preview_subdomain for back-compat). Mirror the backend's
@@ -70,16 +83,12 @@ export async function get(id, flags) {
70
83
  const orgId = requireOrg(flags, config, 'myapi funnel get <id> [--org <id>]');
71
84
  if (!id)
72
85
  error('Missing required arguments.\nUsage: myapi funnel get <id> [--org <id>]');
73
- const raw = await sdkFunnel.getFunnel(config.api_key, orgId, id);
74
- // Backend response shape varies between two layouts:
75
- // 1) { funnel: {...}, subdomain_url, domain_url } (newer, wrapped)
76
- // 2) { id, ..., subdomain_url, domain_url } (older, flat)
77
- // Coalesce both so this works regardless of which shape we get back.
78
- const funnel = raw.funnel ?? raw;
79
- const subdomain_url = raw.subdomain_url ?? funnel.subdomain_url;
80
- const domain_url = raw.domain_url ?? funnel.domain_url;
86
+ // SDK normalizes the wire shape to the wrapped envelope. `--json`
87
+ // still emits the envelope (back-compat: legacy consumers parse it).
88
+ const result = await sdkFunnel.getFunnel(config.api_key, orgId, id);
89
+ const { funnel, subdomain_url, domain_url } = result;
81
90
  if (flags.json) {
82
- printJson(raw);
91
+ printJson(result);
83
92
  return;
84
93
  }
85
94
  info(`ID: ${funnel.id}`);
@@ -91,8 +100,8 @@ export async function get(id, flags) {
91
100
  info(`Created: ${formatDate(funnel.created_at)}`);
92
101
  if (funnel.updated_at)
93
102
  info(`Updated: ${formatDate(funnel.updated_at)}`);
94
- if (Array.isArray(funnel.pages))
95
- info(`Pages: ${funnel.pages.length}`);
103
+ // `pages` isn't part of the typed Funnel; backend doesn't include it
104
+ // here (use `myapi funnel pages` for that). Drop the inline count.
96
105
  }
97
106
  export async function del(id, flags) {
98
107
  const config = requireConfig();
@@ -178,6 +187,157 @@ export async function push(slug, flags) {
178
187
  }
179
188
  }
180
189
  }
190
+ function parseField(token) {
191
+ // <name>[:<modifier>]... — modifiers are types or `required`, order-free.
192
+ const parts = token.split(':').map(p => p.trim()).filter(Boolean);
193
+ const name = parts[0];
194
+ if (!name || !/^[a-z][a-z0-9_]*$/i.test(name)) {
195
+ error(`Invalid field name "${name}". Lowercase letters/digits/underscores; starts with a letter.`);
196
+ }
197
+ let type;
198
+ let required = false;
199
+ for (const mod of parts.slice(1)) {
200
+ const m = mod.toLowerCase();
201
+ if (m === 'required')
202
+ required = true;
203
+ else if (m === 'text' || m === 'email' || m === 'number' || m === 'tel')
204
+ type = m;
205
+ else
206
+ error(`Unknown field modifier "${mod}" on "${name}". Use one of: text, email, number, tel, required.`);
207
+ }
208
+ if (!type) {
209
+ const n = name.toLowerCase();
210
+ type = n === 'email' ? 'email'
211
+ : n === 'phone' || n === 'tel' ? 'tel'
212
+ : 'text';
213
+ }
214
+ return { name, type, required };
215
+ }
216
+ function parseCaptureTo(raw) {
217
+ const m = raw.match(/^(webhook|workflow):([a-f0-9-]{36})$/i);
218
+ if (!m)
219
+ error(`Invalid --capture-to "${raw}". Expected webhook:<uuid> or workflow:<uuid>.`);
220
+ return { kind: m[1].toLowerCase(), id: m[2] };
221
+ }
222
+ export async function formCmd(funnelArg, flags) {
223
+ const config = requireConfig();
224
+ const orgId = requireOrg(flags, config, 'myapi funnel form [funnel-id] [--slug <s>] [--fields <spec>] [--capture-to <dest>] [--honeypot <name>] [--cta "<text>"] [--success "<text>"] [--org <id>] [--json]');
225
+ // Resolve funnel id the same way push/verify do.
226
+ let funnelId = funnelArg || flags.funnel || config.default_funnel;
227
+ if (!funnelId) {
228
+ const existing = await sdkFunnel.listFunnels(config.api_key, orgId);
229
+ if (existing.length === 0)
230
+ error('No funnel found for this org. Create one with: myapi funnel create');
231
+ if (existing.length > 1) {
232
+ error(`Multiple funnels exist and no default is set.\nPass the funnel id, or set a default:\n myapi config set-funnel <id>\n\nFunnels:\n${existing.map(f => ` ${f.id}`).join('\n')}`);
233
+ }
234
+ funnelId = existing[0].id;
235
+ }
236
+ const rawSlug = flags.slug || 'join';
237
+ const slug = rawSlug.startsWith('/') ? rawSlug.slice(1) : rawSlug;
238
+ if (!/^[a-z0-9][a-z0-9-]{0,40}$/i.test(slug)) {
239
+ error(`Invalid --slug "${slug}". Lowercase letters/digits/hyphens, 1-41 chars, starts with a letter or digit.`);
240
+ }
241
+ // `--fields email:required,name,phone:tel,plan:text:required`
242
+ const fieldsArg = (flags.fields || 'email:email:required').trim();
243
+ const fieldSpecs = fieldsArg.split(',').map(s => s.trim()).filter(Boolean).map(parseField);
244
+ if (fieldSpecs.length === 0)
245
+ error('--fields must list at least one field. Example: --fields email:required,name');
246
+ // Honeypot field — match the backend default unless --honeypot overrides.
247
+ const honeypot = flags.honeypot || 'middle_name';
248
+ if (!/^[a-z][a-z0-9_]*$/i.test(honeypot))
249
+ error(`Invalid --honeypot "${honeypot}".`);
250
+ for (const f of fieldSpecs)
251
+ if (f.name === honeypot)
252
+ error(`Honeypot "${honeypot}" collides with a real field. Pick a different --honeypot name.`);
253
+ const cta = flags.cta || 'Sign up';
254
+ const successMsg = flags.success || "Thanks — we'll be in touch.";
255
+ // Resolve the binding destination.
256
+ //
257
+ // Without a binding, the backend falls submissions through to the
258
+ // funnel's default webhook with NO honeypot / field validation. That
259
+ // makes the on-page honeypot purely cosmetic and lets trivial bots
260
+ // write to your CRM. So: always register a binding. When
261
+ // `--capture-to` is omitted, target the funnel's auto-provisioned
262
+ // `org_webhook_id` — same destination as the fallback, just with
263
+ // server-side guards turned on. Backend POST upserts on duplicate
264
+ // slug, so re-runs are safe.
265
+ let registeredBinding;
266
+ let autoBound = false;
267
+ let dest = null;
268
+ if (typeof flags['capture-to'] === 'string' && flags['capture-to']) {
269
+ dest = parseCaptureTo(flags['capture-to']);
270
+ }
271
+ else {
272
+ const { funnel } = await sdkFunnel.getFunnel(config.api_key, orgId, funnelId);
273
+ if (funnel.org_webhook_id) {
274
+ dest = { kind: 'webhook', id: funnel.org_webhook_id };
275
+ autoBound = true;
276
+ }
277
+ // Legacy funnels migrated before 2026-06-03 may briefly lack
278
+ // org_webhook_id — emit the HTML without a binding and warn.
279
+ }
280
+ if (dest) {
281
+ const binding = {
282
+ slug,
283
+ destination: `${dest.kind}:${dest.id}`,
284
+ fields: fieldSpecs.map(f => ({ name: f.name, required: f.required })),
285
+ honeypot_field: honeypot,
286
+ };
287
+ registeredBinding = await sdkFunnel.createFormBinding(config.api_key, orgId, funnelId, binding);
288
+ }
289
+ const url = `https://api.myapihq.com/funnel/funnels/${funnelId}/submit/${encodeURIComponent(slug)}`;
290
+ const escAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
291
+ const escText = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
292
+ // Per-field labels — Email uses the canonical placeholder; others
293
+ // Title-Case the name.
294
+ const inputs = fieldSpecs.map(f => {
295
+ const label = f.name.charAt(0).toUpperCase() + f.name.slice(1).replace(/_/g, ' ');
296
+ const req = f.required ? ' required' : '';
297
+ return ` <label>${escText(label)} <input type="${f.type}" name="${escAttr(f.name)}"${req}></label>`;
298
+ }).join('\n');
299
+ // Honeypot — hidden via inline style, off the tab order, autocomplete
300
+ // disabled. Bots fill it; humans don't.
301
+ const honeypotInput = ` <input type="text" name="${escAttr(honeypot)}" style="display:none" tabindex="-1" autocomplete="off">`;
302
+ const html = `<form action="${escAttr(url)}" method="POST" data-myapi-form>
303
+ ${inputs}
304
+ ${honeypotInput}
305
+ <button type="submit">${escText(cta)}</button>
306
+ </form>
307
+ <script>
308
+ document.querySelectorAll('[data-myapi-form]').forEach(f => {
309
+ f.addEventListener('submit', async e => {
310
+ e.preventDefault();
311
+ const r = await fetch(f.action, { method:'POST', headers:{'Content-Type':'application/json'},
312
+ body: JSON.stringify(Object.fromEntries(new FormData(f))) });
313
+ if (r.ok) f.outerHTML = ${JSON.stringify(`<p>${escText(successMsg)}</p>`)};
314
+ });
315
+ });
316
+ </script>`;
317
+ if (flags.json) {
318
+ printJson({
319
+ action_url: url,
320
+ slug,
321
+ fields: fieldSpecs,
322
+ honeypot_field: honeypot,
323
+ binding_registered: !!registeredBinding,
324
+ binding: registeredBinding,
325
+ html,
326
+ });
327
+ return;
328
+ }
329
+ if (registeredBinding) {
330
+ // Print confirmation on stderr so stdout stays the clean snippet.
331
+ const tag = autoBound ? ' (auto, default webhook)' : '';
332
+ process.stderr.write(`✓ Registered form binding: slug=${slug} → ${registeredBinding.destination}${tag}\n`);
333
+ }
334
+ else {
335
+ // No org_webhook_id and no --capture-to → honeypot/field guards
336
+ // won't fire. Surface it so the agent can act.
337
+ process.stderr.write(`⚠ No binding registered (funnel has no org_webhook_id). Honeypot and field guards are NOT enforced for this slug. Pass --capture-to webhook:<id> or workflow:<id>, or wait for the backend migration to backfill org_webhook_id.\n`);
338
+ }
339
+ process.stdout.write(html + '\n');
340
+ }
181
341
  // Verify uses the same shape as push: slug positional (default '/'), funnel
182
342
  // resolved from --funnel / default / the org's only funnel.
183
343
  export async function verify(slug, flags) {
@@ -260,10 +420,51 @@ export async function publish(dir, flags) {
260
420
  }
261
421
  // ── Dispatcher ───────────────────────────────────────────────────────────────
262
422
  const SUBCOMMAND_USAGE = {
263
- 'list': 'myapi funnel list [--org <id>] [--json]',
264
423
  'create': 'myapi funnel create [--name <name>] [--org <id>]',
265
- 'get': 'myapi funnel get <id> [--org <id>] [--json]',
266
424
  'delete': 'myapi funnel delete <id> [--org <id>]',
425
+ 'form': `myapi funnel form [funnel-id] [--slug <slug>] [--fields <spec>]
426
+ [--capture-to <dest>] [--honeypot <name>]
427
+ [--cta "<button text>"] [--success "<message>"] [--org <id>] [--json]
428
+
429
+ Emits a ready-to-paste HTML snippet for a form that posts to the canonical
430
+ funnel submit proxy. Submissions to any slug fall through to the funnel's
431
+ auto-provisioned webhook by default (CRM ingest + bound workflows fire
432
+ without further wiring). The proxy URL is platform-stable; slug names the
433
+ form so workflows can route on it; an auto-honeypot field stops casual
434
+ bots.
435
+
436
+ --slug <s> Form identifier — appears in the proxy URL.
437
+ Defaults to "join". Lowercase letters/digits/hyphens.
438
+ --fields <spec> Comma-separated field specs.
439
+ Each spec: <name>[:<type>][:required] (modifiers
440
+ order-free). Types: text (default), email, number,
441
+ tel. Name auto-promotes to email if name="email",
442
+ to tel if name="phone" or "tel".
443
+ Defaults to "email:email:required".
444
+ --capture-to <dest> Optional. Register a per-slug binding to the
445
+ destination via POST /funnel/.../forms. Format:
446
+ webhook:<uuid> or workflow:<uuid>. Without it,
447
+ submissions fall through to the funnel's default
448
+ webhook (most agents want this).
449
+ --honeypot <name> Hidden bot-trap input name. Default "middle_name".
450
+ --cta "<text>" Submit button text. Defaults to "Sign up".
451
+ --success "<text>" Message shown on successful submit.
452
+
453
+ Examples:
454
+ myapi funnel form # email-required on default funnel
455
+ myapi funnel form --slug waitlist --fields email:required,name
456
+ myapi funnel form <funnel> --slug checkout \\
457
+ --fields email:required,plan,phone:tel \\
458
+ --capture-to webhook:<checkout_webhook_id>
459
+ myapi funnel form --json # machine-readable
460
+ myapi funnel form --slug join > form.html # snippet to a file
461
+
462
+ Do NOT hardcode webhook inbound URLs into funnel HTML. Use this verb
463
+ instead — the proxy preserves slug routing, hides the URL, and gets
464
+ future platform features (rate-limit, captcha, field validation) for
465
+ free.`,
466
+ 'get': 'myapi funnel get <id> [--org <id>] [--json]',
467
+ 'list': 'myapi funnel list [--org <id>] [--json]',
267
468
  'pages': 'myapi funnel pages [funnel_id] [--funnel <id>] [--org <id>] [--json]',
268
469
  'publish': `myapi funnel publish <dir> [--funnel <id>] [--env dev|prod] [--api-fn <id>] [--org <id>]
269
470
 
@@ -314,6 +515,7 @@ export async function run(subcommand, args, flags) {
314
515
  Subcommands:
315
516
  create Create a funnel (optional --name; backend defaults to org's preview_subdomain)
316
517
  delete Delete a funnel
518
+ form Emit HTML for a form that posts to the canonical submit proxy
317
519
  get <id> Get funnel details
318
520
  list List funnels
319
521
  pages List the pages currently published to a funnel
@@ -333,14 +535,15 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
333
535
  return;
334
536
  }
335
537
  switch (subcommand) {
336
- case 'list': return list(flags);
337
538
  case 'create': return create(flags);
338
- case 'get': return get(args[0], flags);
339
539
  case 'delete': return del(args[0], flags);
540
+ case 'form': return formCmd(args[0], flags);
541
+ case 'get': return get(args[0], flags);
542
+ case 'list': return list(flags);
543
+ case 'pages': return pages(args[0], flags);
340
544
  case 'publish': return publish(args[0], flags);
341
545
  case 'push': return push(args[0], flags);
342
546
  case 'verify': return verify(args[0], flags);
343
- case 'pages': return pages(args[0], flags);
344
547
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi funnel --help" for a list of valid subcommands.`);
345
548
  }
346
549
  }
@@ -7,6 +7,7 @@ export const EXPOSES = [
7
7
  'POST /llm/orgs/{org_id}/complete',
8
8
  'POST /llm/orgs/{org_id}/embed',
9
9
  'GET /llm/orgs/{org_id}/models',
10
+ 'POST /llm/orgs/{org_id}/tasks/{verb}',
10
11
  ];
11
12
  export const SCHEMA = {
12
13
  org: 'string',
@@ -16,8 +17,36 @@ export const SCHEMA = {
16
17
  temperature: 'number',
17
18
  stop: 'string',
18
19
  file: 'string',
20
+ // verb-specific
21
+ labels: 'string',
22
+ multi: 'boolean',
23
+ schema: 'string',
24
+ style: 'string',
19
25
  kind: 'string',
26
+ context: 'string',
27
+ prompt: 'string',
28
+ tier: 'string',
20
29
  };
30
+ // Cache the catalog once per CLI invocation so verbs (which don't take a
31
+ // --model flag) can fall back to "the first chat model" without an extra
32
+ // network round-trip per command.
33
+ let cachedModels = null;
34
+ async function loadModels(apiKey, orgId) {
35
+ if (cachedModels)
36
+ return cachedModels;
37
+ const res = await sdkLlm.listModels(apiKey, orgId);
38
+ cachedModels = res.models;
39
+ return cachedModels;
40
+ }
41
+ async function defaultChatModel(apiKey, orgId) {
42
+ const models = await loadModels(apiKey, orgId);
43
+ const chat = models.find(m => m.kind === 'chat');
44
+ if (!chat) {
45
+ error('No chat model available — run "myapi llm models" to inspect the catalog.');
46
+ throw new Error('unreachable'); // satisfy the type checker
47
+ }
48
+ return chat.id;
49
+ }
21
50
  function readPromptArg(promptArg, flags) {
22
51
  if (typeof flags.file === 'string' && flags.file) {
23
52
  return fs.readFileSync(flags.file, 'utf-8');
@@ -27,11 +56,62 @@ function readPromptArg(promptArg, flags) {
27
56
  }
28
57
  return promptArg ?? '';
29
58
  }
30
- const DEFAULT_CHAT_MODEL = 'gemini-3.1-flash-lite-preview';
59
+ function fmtCostCents(cents) {
60
+ // Sub-cent calls (the common case for short prompts) get four decimals so
61
+ // the footer stays informative — "0.0063¢" reads honestly as "less than a
62
+ // cent." Larger numbers collapse to two decimals.
63
+ return cents < 1 ? `${cents.toFixed(4)}¢` : `${cents.toFixed(2)}¢`;
64
+ }
65
+ function parseSchemaFlag(flags) {
66
+ if (typeof flags.schema !== 'string' || !flags.schema)
67
+ return null;
68
+ // Accept either a path to a JSON file or an inline JSON string.
69
+ let raw = flags.schema;
70
+ if (fs.existsSync(raw)) {
71
+ try {
72
+ raw = fs.readFileSync(raw, 'utf-8');
73
+ }
74
+ catch (e) {
75
+ error(`Could not read --schema file: ${e.message}`);
76
+ }
77
+ }
78
+ try {
79
+ return JSON.parse(raw);
80
+ }
81
+ catch {
82
+ error('--schema must be valid JSON (inline) or a path to a JSON file');
83
+ return null;
84
+ }
85
+ }
86
+ function parseContextFlag(flags) {
87
+ if (typeof flags.context !== 'string' || !flags.context)
88
+ return undefined;
89
+ try {
90
+ const parsed = JSON.parse(flags.context);
91
+ if (typeof parsed === 'object' && parsed !== null)
92
+ return parsed;
93
+ error('--context must be a JSON object');
94
+ return undefined;
95
+ }
96
+ catch {
97
+ error('--context must be valid JSON');
98
+ return undefined;
99
+ }
100
+ }
101
+ function tierFromFlag(flags) {
102
+ if (typeof flags.tier !== 'string' || !flags.tier)
103
+ return undefined;
104
+ if (flags.tier === 'fast' || flags.tier === 'reasoning' || flags.tier === 'cheap')
105
+ return flags.tier;
106
+ error('--tier must be one of: fast, reasoning, cheap');
107
+ }
108
+ // ── raw ──────────────────────────────────────────────────────────────────
31
109
  async function complete(promptArg, flags) {
32
110
  const config = requireConfig();
33
111
  const orgId = requireOrg(flags, config, 'myapi llm complete "<prompt>" [--model <id>] [--system <s>] [--max-tokens N] [--temperature 0..1] [--stop <csv>] [--org <id>]');
34
- const model = typeof flags.model === 'string' && flags.model ? flags.model : DEFAULT_CHAT_MODEL;
112
+ const model = typeof flags.model === 'string' && flags.model
113
+ ? flags.model
114
+ : await defaultChatModel(config.api_key, orgId);
35
115
  const prompt = readPromptArg(promptArg, flags);
36
116
  if (!prompt.trim())
37
117
  error('Empty prompt. Pass <prompt> as an argument, "-" to read stdin, or --file <path>.');
@@ -56,7 +136,7 @@ async function complete(promptArg, flags) {
56
136
  }
57
137
  info(res.content);
58
138
  // Usage footer on stderr so it doesn't pollute piped output.
59
- process.stderr.write(`\n— ${res.model} · ${res.usage.input_tokens}+${res.usage.output_tokens} tokens · $${res.usage.cost_usd.toFixed(6)} · ${res.finish_reason}\n`);
139
+ process.stderr.write(`\n— ${res.model} · ${res.usage.input_tokens}+${res.usage.output_tokens ?? 0} tokens · ${fmtCostCents(res.usage.cost_cents)} · ${res.finish_reason}\n`);
60
140
  }
61
141
  async function embed(inputArg, flags) {
62
142
  const config = requireConfig();
@@ -73,7 +153,7 @@ async function embed(inputArg, flags) {
73
153
  return;
74
154
  }
75
155
  const first = res.embeddings[0] ?? [];
76
- info(`${res.model} · dim=${first.length} · ${res.usage.input_tokens} tokens · $${res.usage.cost_usd.toFixed(6)}`);
156
+ info(`${res.model} · dim=${first.length} · ${res.usage.input_tokens} tokens · ${fmtCostCents(res.usage.cost_cents)}`);
77
157
  info(`first 5: [${first.slice(0, 5).map(n => n.toFixed(6)).join(', ')}${first.length > 5 ? ', ...' : ''}]`);
78
158
  info(`(pass --json for the full vector)`);
79
159
  }
@@ -90,49 +170,198 @@ async function listModels(flags) {
90
170
  printTable(models.map(m => ({
91
171
  id: m.id,
92
172
  kind: m.kind,
93
- context: m.context ?? '',
173
+ context: m.context_window ?? '',
94
174
  dimensions: m.dimensions ?? '',
95
- in_per_1m: `$${m.input_per_1m}`,
96
- out_per_1m: m.output_per_1m != null ? `$${m.output_per_1m}` : '',
175
+ 'in ¢/1M': m.input_cost_per_1m_cents,
176
+ 'out ¢/1M': m.output_cost_per_1m_cents != null ? m.output_cost_per_1m_cents : '',
97
177
  })), { flags, empty: 'No models available.' });
98
178
  }
179
+ // ── verbs ────────────────────────────────────────────────────────────────
180
+ function printVerbFooter(usage) {
181
+ process.stderr.write(`\n— tier=${usage.tier_used} · ${usage.tokens_in}+${usage.tokens_out} tokens · ${fmtCostCents(usage.cost_cents)}\n`);
182
+ }
183
+ async function classify(inputArg, flags) {
184
+ const config = requireConfig();
185
+ const orgId = requireOrg(flags, config, 'myapi llm classify "<input>" --labels <csv> [--multi] [--tier <t>] [--org <id>]');
186
+ const input = readPromptArg(inputArg, flags);
187
+ if (!input.trim())
188
+ error('Empty input. Pass <input> as an argument, "-" to read stdin, or --file <path>.');
189
+ if (typeof flags.labels !== 'string' || !flags.labels) {
190
+ error('Missing required flag: --labels <comma-separated>');
191
+ return;
192
+ }
193
+ const labels = flags.labels.split(',').map(s => s.trim()).filter(Boolean);
194
+ if (labels.length === 0)
195
+ error('--labels must contain at least one label');
196
+ const multi = flags.multi === true;
197
+ const res = await sdkLlm.classify(config.api_key, orgId, { input, labels, multi, tier: tierFromFlag(flags) });
198
+ if (flags.json) {
199
+ printJson(res);
200
+ return;
201
+ }
202
+ if (multi) {
203
+ const arr = res.data.labels ?? [];
204
+ info(arr.length ? arr.join(', ') : '(no matching labels)');
205
+ }
206
+ else {
207
+ info(res.data.label ?? '(no label)');
208
+ }
209
+ printVerbFooter(res.usage);
210
+ }
211
+ async function extract(inputArg, flags) {
212
+ const config = requireConfig();
213
+ const orgId = requireOrg(flags, config, 'myapi llm extract "<input>" --schema <path|json> [--tier <t>] [--org <id>]');
214
+ const input = readPromptArg(inputArg, flags);
215
+ if (!input.trim())
216
+ error('Empty input. Pass <input> as an argument, "-" to read stdin, or --file <path>.');
217
+ const schema = parseSchemaFlag(flags);
218
+ if (!schema) {
219
+ error('Missing required flag: --schema <path|json>');
220
+ return;
221
+ }
222
+ const res = await sdkLlm.extract(config.api_key, orgId, { input, schema, tier: tierFromFlag(flags) });
223
+ if (flags.json) {
224
+ printJson(res);
225
+ return;
226
+ }
227
+ info(JSON.stringify(res.data.data, null, 2));
228
+ printVerbFooter(res.usage);
229
+ }
230
+ async function summarize(inputArg, flags) {
231
+ const config = requireConfig();
232
+ const orgId = requireOrg(flags, config, 'myapi llm summarize "<input>" [--style brief|exec|bullet] [--tier <t>] [--org <id>]');
233
+ const input = readPromptArg(inputArg, flags);
234
+ if (!input.trim())
235
+ error('Empty input. Pass <input> as an argument, "-" to read stdin, or --file <path>.');
236
+ const style = typeof flags.style === 'string' && flags.style ? flags.style : undefined;
237
+ if (style && !['brief', 'exec', 'bullet'].includes(style)) {
238
+ error('--style must be one of: brief, exec, bullet');
239
+ }
240
+ const res = await sdkLlm.summarize(config.api_key, orgId, {
241
+ input,
242
+ style: style,
243
+ tier: tierFromFlag(flags),
244
+ });
245
+ if (flags.json) {
246
+ printJson(res);
247
+ return;
248
+ }
249
+ info(res.data.summary);
250
+ printVerbFooter(res.usage);
251
+ }
252
+ async function draft(inputArg, flags) {
253
+ const config = requireConfig();
254
+ const orgId = requireOrg(flags, config, 'myapi llm draft --kind <what> [--prompt "<instructions>"] [--context <json>] ["<source text>"] [--tier <t>] [--org <id>]');
255
+ if (typeof flags.kind !== 'string' || !flags.kind) {
256
+ error('Missing required flag: --kind <email|message|reply|...>');
257
+ return;
258
+ }
259
+ const input = readPromptArg(inputArg, flags);
260
+ const promptText = typeof flags.prompt === 'string' ? flags.prompt : '';
261
+ const ctx = parseContextFlag(flags);
262
+ if (!input.trim() && !promptText.trim() && (!ctx || Object.keys(ctx).length === 0)) {
263
+ error('draft needs at least one of: <source text> (arg or --file), --prompt, or --context.');
264
+ }
265
+ const res = await sdkLlm.draft(config.api_key, orgId, {
266
+ input: input || undefined,
267
+ kind: flags.kind,
268
+ context: ctx,
269
+ prompt: promptText || undefined,
270
+ tier: tierFromFlag(flags),
271
+ });
272
+ if (flags.json) {
273
+ printJson(res);
274
+ return;
275
+ }
276
+ info(res.data.text);
277
+ printVerbFooter(res.usage);
278
+ }
279
+ // ── help ─────────────────────────────────────────────────────────────────
99
280
  const SUBCOMMAND_USAGE = {
100
281
  complete: `myapi llm complete "<prompt>" [--model <id>]
101
282
  [--system "<system msg>"] [--max-tokens N] [--temperature 0..1]
102
283
  [--stop <csv>] [--file <path>] [--org <id>] [--json]
103
284
 
104
- Default model: gemini-3.1-flash-lite-preview (cheap + fast). Pass --model
105
- to override; run "myapi llm models" to see the catalog.
285
+ Raw chat completion against a self-hosted catalog model. Without --model,
286
+ picks the first chat model from "myapi llm models" (today:
287
+ Qwen/Qwen3-Coder-30B-A3B-Instruct).
106
288
 
107
- Pass "-" as the prompt to read from stdin. The reply goes to stdout;
108
- a one-line usage footer (tokens + cost) goes to stderr so it doesn't
109
- pollute piped output.
289
+ Pass "-" as the prompt to read from stdin. The reply goes to stdout; a
290
+ one-line usage footer (tokens + cost in cents) goes to stderr so it
291
+ doesn't pollute piped output.
110
292
 
111
293
  Examples:
112
294
  myapi llm complete "summarize: $(cat README.md)"
113
- cat draft.md | myapi llm complete - --model gemini-3.1-pro-preview --system "You are an editor"
114
- myapi llm complete --file prompt.txt --max-tokens 200`,
295
+ cat draft.md | myapi llm complete - --system "You are an editor" --max-tokens 200`,
115
296
  embed: `myapi llm embed "<text>" --model <id> [--file <path>] [--org <id>] [--json]
116
297
 
117
- Returns a vector summary by default; pass --json for the full vector.
298
+ No embedding model is served on the raw catalog today this returns
299
+ EMBED_NOT_AVAILABLE until one is. Use a dedicated embedding API for now.`,
300
+ models: `myapi llm models [--kind chat|embed] [--org <id>] [--json]
301
+
302
+ Lists the live model catalog with per-1M-token pricing in cents.
303
+ The catalog reflects exactly what the inference gateway serves.`,
304
+ classify: `myapi llm classify "<input>" --labels <a,b,c> [--multi]
305
+ [--tier fast|reasoning|cheap] [--file <path>] [--org <id>] [--json]
306
+
307
+ Pick the best label (or with --multi, all applicable labels) from a set.
308
+ Output goes to stdout; usage footer to stderr.
118
309
 
119
310
  Examples:
120
- myapi llm embed "the quick brown fox" --model gemini-embedding-001
121
- myapi llm embed --file doc.txt --model gemini-embedding-001 --json > doc.vec.json`,
122
- models: `myapi llm models [--kind chat|embed] [--org <id>] [--json]
311
+ myapi llm classify "I was charged twice and need a refund" \\
312
+ --labels billing,technical,sales,spam
313
+ cat email.txt | myapi llm classify - --labels urgent,bug,question --multi`,
314
+ extract: `myapi llm extract "<input>" --schema <path|json>
315
+ [--tier <t>] [--file <path>] [--org <id>] [--json]
316
+
317
+ Extract data from <input> conforming to a JSON Schema. --schema accepts
318
+ an inline JSON string OR a path to a .json file. Stdout is the extracted
319
+ JSON object.
320
+
321
+ Examples:
322
+ myapi llm extract "Acme Corp has 250 employees" \\
323
+ --schema '{"type":"object","properties":{"company":{"type":"string"},"employees":{"type":"integer"}}}'
324
+ myapi llm extract - --schema ./order.schema.json < order.eml`,
325
+ summarize: `myapi llm summarize "<input>" [--style brief|exec|bullet]
326
+ [--tier <t>] [--file <path>] [--org <id>] [--json]
123
327
 
124
- Lists the model catalog with per-1M-token pricing at upstream rates.
125
- Models prefixed with vendor name pass through to that vendor; un-prefixed
126
- models (when available) run on MyAPI's own inference.`,
328
+ Summarize text. Default style 'brief' (1-2 sentences); 'exec' is a
329
+ decision-maker summary; 'bullet' is a short list.
330
+
331
+ Examples:
332
+ myapi llm summarize --file long-thread.txt --style bullet
333
+ cat report.md | myapi llm summarize - --style exec`,
334
+ draft: `myapi llm draft --kind <what> [--prompt "<instructions>"]
335
+ [--context '<json>'] ["<source text>"] [--file <path>]
336
+ [--tier <t>] [--org <id>] [--json]
337
+
338
+ Draft a piece of writing (email, reply, message, …). Provide at least one
339
+ of: <source text> (arg or --file), --prompt, or --context (JSON of facts).
340
+
341
+ Examples:
342
+ myapi llm draft --kind email \\
343
+ --prompt "Friendly welcome to the beta. Under 60 words." \\
344
+ --context '{"recipient":"a new signup","product":"MyAPI"}'
345
+ cat inbound.eml | myapi llm draft - --kind reply --prompt "Acknowledge and ask for the order ID"`,
127
346
  };
128
347
  export async function run(subcommand, args, flags) {
129
348
  if (!subcommand || (flags.help && !subcommand)) {
130
349
  info(`Usage: myapi llm <subcommand>
131
350
 
351
+ Two surfaces:
352
+
353
+ Raw — you pick a self-hosted catalog model and shape the prompt yourself.
354
+ Verbs — you ask for a task done; the model is implementation detail and
355
+ is never named in the response.
356
+
132
357
  Subcommands:
133
- complete Chat completion returns reply + usage (default model: gemini-3.1-flash-lite-preview)
134
- embed Text embedding returns vector + usage
135
- models List available models (chat + embed) with pricing
358
+ classify Pick a label from a set
359
+ complete Raw chat completion against a catalog model
360
+ draft Write something (email | reply | message | …)
361
+ embed Raw embeddings (no model served on raw today)
362
+ extract Pull structured data conforming to a JSON Schema
363
+ models List the live model catalog (id, kind, context, cents/1M)
364
+ summarize Summarize text (brief | exec | bullet)
136
365
 
137
366
  All commands accept --org <id> (or set default: myapi config set-org <id>).
138
367
  Use this for workflow steps and scripted pipelines — not as a replacement
@@ -151,6 +380,10 @@ for your own reasoning. The agent has its own model already.`);
151
380
  case 'complete': return complete(args[0], flags);
152
381
  case 'embed': return embed(args[0], flags);
153
382
  case 'models': return listModels(flags);
383
+ case 'classify': return classify(args[0], flags);
384
+ case 'extract': return extract(args[0], flags);
385
+ case 'summarize': return summarize(args[0], flags);
386
+ case 'draft': return draft(args[0], flags);
154
387
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi llm --help" for valid subcommands.`);
155
388
  }
156
389
  }
@@ -7,6 +7,7 @@ export const EXPOSES = [
7
7
  'POST /queue/orgs/{org_id}/queues',
8
8
  'GET /queue/orgs/{org_id}/queues',
9
9
  'GET /queue/orgs/{org_id}/queues/{name}',
10
+ 'DELETE /queue/orgs/{org_id}/queues/{name}',
10
11
  'POST /queue/orgs/{org_id}/queues/{name}/jobs',
11
12
  'GET /queue/orgs/{org_id}/queues/{name}/jobs',
12
13
  'GET /queue/orgs/{org_id}/jobs/{id}',