@ishlabs/cli 0.24.0 → 0.25.0
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/ask.js +3 -3
- package/dist/commands/iteration.js +1 -1
- package/dist/commands/study-analyze.js +1 -1
- package/dist/commands/study-run.js +83 -15
- package/dist/commands/study.js +11 -7
- package/dist/lib/alias-store.js +1 -1
- package/dist/lib/api-client.d.ts +2 -0
- package/dist/lib/billing.d.ts +30 -16
- package/dist/lib/billing.js +77 -27
- package/dist/lib/docs.js +57 -42
- package/dist/lib/local-sim/actions.d.ts +10 -2
- package/dist/lib/local-sim/actions.js +16 -11
- package/dist/lib/local-sim/adb.d.ts +103 -0
- package/dist/lib/local-sim/adb.js +352 -0
- package/dist/lib/local-sim/android.d.ts +111 -0
- package/dist/lib/local-sim/android.js +499 -0
- package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
- package/dist/lib/local-sim/apk-manifest.js +210 -0
- package/dist/lib/local-sim/browser.d.ts +22 -0
- package/dist/lib/local-sim/browser.js +65 -0
- package/dist/lib/local-sim/coordinates.d.ts +69 -0
- package/dist/lib/local-sim/coordinates.js +59 -0
- package/dist/lib/local-sim/device.d.ts +143 -0
- package/dist/lib/local-sim/device.js +152 -0
- package/dist/lib/local-sim/ios.d.ts +168 -0
- package/dist/lib/local-sim/ios.js +546 -0
- package/dist/lib/local-sim/loop.d.ts +14 -2
- package/dist/lib/local-sim/loop.js +166 -73
- package/dist/lib/local-sim/native-a11y.d.ts +97 -0
- package/dist/lib/local-sim/native-a11y.js +384 -0
- package/dist/lib/local-sim/simctl.d.ts +85 -0
- package/dist/lib/local-sim/simctl.js +273 -0
- package/dist/lib/local-sim/types.d.ts +37 -2
- package/dist/lib/local-sim/upload.d.ts +1 -1
- package/dist/lib/local-sim/upload.js +9 -6
- package/dist/lib/modality.d.ts +10 -1
- package/dist/lib/modality.js +21 -0
- package/dist/lib/output.js +58 -12
- package/dist/lib/skill-content.js +10 -9
- package/package.json +2 -1
package/dist/commands/ask.js
CHANGED
|
@@ -436,15 +436,15 @@ Picks come back with a \`pick_confidence\` (0..1) score per participant when
|
|
|
436
436
|
// error rather than a transient failure.
|
|
437
437
|
ask
|
|
438
438
|
.command("dispatch")
|
|
439
|
-
.description("Dispatch a draft ask —
|
|
439
|
+
.description("Dispatch a draft ask — draws credits and starts the round")
|
|
440
440
|
.argument("[id]", "Ask alias or UUID (defaults to active ask)")
|
|
441
441
|
.option("--ask <id>", "Ask ID; alternative to positional argument")
|
|
442
442
|
.option("--wait", "Wait until the first round completes (or errors)")
|
|
443
443
|
.option("--timeout <s>", "Wait timeout in seconds (default 300)")
|
|
444
444
|
.addHelpText("after", `
|
|
445
445
|
Use after \`ish ask create --no-dispatch\` to start a draft once the user has
|
|
446
|
-
reviewed it.
|
|
447
|
-
|
|
446
|
+
reviewed it. Dispatch draws credits as responses land, the same as a normal
|
|
447
|
+
create. This is the expected way to run an ask — go ahead and dispatch.
|
|
448
448
|
|
|
449
449
|
Examples:
|
|
450
450
|
# Dispatch the active draft and wait for results:
|
|
@@ -383,7 +383,7 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
383
383
|
// Media image
|
|
384
384
|
.option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
|
|
385
385
|
// Shared media
|
|
386
|
-
.option("--title <title>", "Content title — media modalities")
|
|
386
|
+
.option("--title <title>", "Content title shown to participants (the leading headline they read) — media modalities")
|
|
387
387
|
.option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
|
|
388
388
|
// Copy/caption
|
|
389
389
|
.option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
|
|
@@ -81,7 +81,7 @@ export function attachStudyAnalyzeCommands(study) {
|
|
|
81
81
|
study
|
|
82
82
|
.command("analyze")
|
|
83
83
|
.description("Trigger an AI summary + key-insights analysis for a study. " +
|
|
84
|
-
"First analysis per study is
|
|
84
|
+
"First analysis per study is included; subsequent runs draw 10 credits.")
|
|
85
85
|
.argument("[id]", "Study ID (defaults to active study)")
|
|
86
86
|
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
|
|
87
87
|
.option("--wait", "Poll until the run reaches completed or failed")
|
|
@@ -14,7 +14,7 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
|
14
14
|
import { output, formatSimulationPoll } from "../lib/output.js";
|
|
15
15
|
import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
16
16
|
import { streamStudyEvents } from "../lib/study-events.js";
|
|
17
|
-
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
|
|
17
|
+
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, toModality, } from "../lib/modality.js";
|
|
18
18
|
// NOTE: local-sim modules are loaded via dynamic import at the `--local`
|
|
19
19
|
// branch below, NOT statically here. `local-sim/install.ts` deep-imports
|
|
20
20
|
// `playwright-core/lib/server/registry/index`, which is not exposed by
|
|
@@ -33,8 +33,8 @@ function parseMaxInteractions(value) {
|
|
|
33
33
|
/**
|
|
34
34
|
* Default cap the CLI sends when neither `--max-interactions` nor the
|
|
35
35
|
* iteration carries its own value. Picked to match the frontend's
|
|
36
|
-
* conservative interactive launchers and to prevent runaway
|
|
37
|
-
* iteration runs against a broken or non-responsive surface — without a
|
|
36
|
+
* conservative interactive launchers and to prevent runaway credit draw when
|
|
37
|
+
* an iteration runs against a broken or non-responsive surface — without a
|
|
38
38
|
* cap, a stuck participant can rack up hundreds of steps before the SDK gives
|
|
39
39
|
* up.
|
|
40
40
|
*/
|
|
@@ -264,6 +264,34 @@ function readIterationDetails(details) {
|
|
|
264
264
|
...(typeof details.title === "string" && { title: details.title }),
|
|
265
265
|
};
|
|
266
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Normalize a platform string for matching. "web", "browser", and unset all
|
|
269
|
+
* mean the default browser path; "android"/"ios" are native. Lets `--platform
|
|
270
|
+
* web` match a "browser" iteration (and vice-versa) without false mismatches.
|
|
271
|
+
*/
|
|
272
|
+
function normalizePlatform(platform) {
|
|
273
|
+
const p = (platform ?? "").toLowerCase();
|
|
274
|
+
if (p === "" || p === "web" || p === "browser")
|
|
275
|
+
return "browser";
|
|
276
|
+
return p;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The local platform the user explicitly requested via flags, before any
|
|
280
|
+
* iteration is picked: --platform, or inferred from --app's extension
|
|
281
|
+
* (.apk → android, .app → ios). Undefined when neither is set (no preference →
|
|
282
|
+
* don't filter iterations by platform). The iteration's stored platform is
|
|
283
|
+
* deliberately NOT consulted here — it's what we're selecting against.
|
|
284
|
+
*/
|
|
285
|
+
function requestedLocalPlatform(opts) {
|
|
286
|
+
if (opts.platform)
|
|
287
|
+
return opts.platform;
|
|
288
|
+
const app = opts.app?.toLowerCase();
|
|
289
|
+
if (app?.endsWith(".apk"))
|
|
290
|
+
return "android";
|
|
291
|
+
if (app?.endsWith(".app"))
|
|
292
|
+
return "ios";
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
267
295
|
export function attachStudyRunCommands(study) {
|
|
268
296
|
// --- Primary: `study run` ---
|
|
269
297
|
const studyRun = study
|
|
@@ -294,6 +322,8 @@ export function attachStudyRunCommands(study) {
|
|
|
294
322
|
.option("--devtools", "Open Chrome DevTools (local mode only)")
|
|
295
323
|
.option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
|
|
296
324
|
.option("--parallel <n>", "Run N participants in parallel (local mode only, default: all)")
|
|
325
|
+
.option("--platform <platform>", "Local target platform: 'web' (Playwright), 'android' (adb emulator), or 'ios' (simctl+idb simulator). Defaults to the iteration's platform.")
|
|
326
|
+
.option("--app <path>", "Native local mode: path to an .apk (android) / .app (ios) to install, or an installed package/bundle id to launch. The extension implies --platform.")
|
|
297
327
|
.addHelpText("after", `
|
|
298
328
|
Note: --workspace and --study are optional if you have set active context
|
|
299
329
|
via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
|
|
@@ -326,7 +356,7 @@ Examples:
|
|
|
326
356
|
$ ish study run --config c-c3c
|
|
327
357
|
|
|
328
358
|
# Cap interactions per participant (default 20 — pass higher to allow deeper
|
|
329
|
-
# exploration, lower to cap
|
|
359
|
+
# exploration, lower to cap credit draw on a known-broken surface):
|
|
330
360
|
$ ish study run --max-interactions 30
|
|
331
361
|
|
|
332
362
|
# Block until all simulations finish (or timeout):
|
|
@@ -412,8 +442,13 @@ Examples:
|
|
|
412
442
|
if (!study.assignments || study.assignments.length === 0) {
|
|
413
443
|
throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
|
|
414
444
|
}
|
|
415
|
-
// Step 1: Pick iteration (explicit --iteration, or latest on study)
|
|
445
|
+
// Step 1: Pick iteration (explicit --iteration, or latest on study).
|
|
446
|
+
// When --platform / --app requests a local platform, select the
|
|
447
|
+
// iteration whose details.platform matches — otherwise a multi-platform
|
|
448
|
+
// study (e.g. an android AND an ios iteration) would silently run the
|
|
449
|
+
// latest, which may be the wrong platform.
|
|
416
450
|
const iterations = study.iterations || [];
|
|
451
|
+
const wantPlatform = opts.local ? requestedLocalPlatform(opts) : undefined;
|
|
417
452
|
let iteration;
|
|
418
453
|
if (opts.iteration) {
|
|
419
454
|
const wantedId = resolveId(opts.iteration);
|
|
@@ -421,6 +456,25 @@ Examples:
|
|
|
421
456
|
if (!iteration) {
|
|
422
457
|
throw new Error(`Iteration ${opts.iteration} not found on this study.`);
|
|
423
458
|
}
|
|
459
|
+
// An explicit --iteration whose platform contradicts --platform is a
|
|
460
|
+
// footgun (you'd drive the wrong device); refuse rather than guess.
|
|
461
|
+
if (wantPlatform) {
|
|
462
|
+
const itPlatform = readIterationDetails(iteration.details).platform;
|
|
463
|
+
if (normalizePlatform(itPlatform) !== normalizePlatform(wantPlatform)) {
|
|
464
|
+
throw new Error(`--platform ${wantPlatform} but iteration ${opts.iteration} is platform ` +
|
|
465
|
+
`'${itPlatform ?? "browser"}'. Pass the matching iteration or drop --platform.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
else if (wantPlatform) {
|
|
470
|
+
// Latest iteration whose platform matches the requested one.
|
|
471
|
+
const matching = iterations.filter((it) => normalizePlatform(readIterationDetails(it.details).platform) === normalizePlatform(wantPlatform));
|
|
472
|
+
if (matching.length === 0) {
|
|
473
|
+
const available = [...new Set(iterations.map((it) => normalizePlatform(readIterationDetails(it.details).platform)))].join(", ") || "none";
|
|
474
|
+
throw new Error(`No ${wantPlatform} iteration on this study (platforms present: ${available}). ` +
|
|
475
|
+
`Create one with \`ish iteration create --study ${resolvedStudy} ...\`, or pass --iteration.`);
|
|
476
|
+
}
|
|
477
|
+
iteration = matching[matching.length - 1];
|
|
424
478
|
}
|
|
425
479
|
else if (iterations.length > 0) {
|
|
426
480
|
iteration = iterations[iterations.length - 1];
|
|
@@ -663,7 +717,7 @@ Examples:
|
|
|
663
717
|
: `CLI default — pass --max-interactions to override`;
|
|
664
718
|
log(` Max steps: ${stepsForMedia} (${source})`);
|
|
665
719
|
if (Number.isFinite(stepsForMedia)) {
|
|
666
|
-
const est = estimateMediaRun({ participantCount, maxInteractions: stepsForMedia });
|
|
720
|
+
const est = estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: stepsForMedia });
|
|
667
721
|
log(` Credits (est): ≈ ${est.upper_bound} credit(s) upper bound — ${est.breakdown}`);
|
|
668
722
|
}
|
|
669
723
|
}
|
|
@@ -691,6 +745,21 @@ Examples:
|
|
|
691
745
|
// by reusing the iteration's existing Conversation rows or by
|
|
692
746
|
// calling pair-batch.
|
|
693
747
|
let pairConversationIds = [];
|
|
748
|
+
// Resolve the local target platform ONCE so participant-create and the
|
|
749
|
+
// local-sim dispatch agree. Precedence: --platform flag > --app
|
|
750
|
+
// extension (.apk → android, .app → ios) > iteration's stored platform
|
|
751
|
+
// > browser. Used to set participant.platform (so the backend's native
|
|
752
|
+
// trigger is driven primarily by the participant, not just the
|
|
753
|
+
// empty-tree fallback) and to pick the device in the local loop.
|
|
754
|
+
const platformFromApp = opts.app?.toLowerCase().endsWith(".apk")
|
|
755
|
+
? "android"
|
|
756
|
+
: opts.app?.toLowerCase().endsWith(".app")
|
|
757
|
+
? "ios"
|
|
758
|
+
: undefined;
|
|
759
|
+
const resolvedPlatform = opts.platform
|
|
760
|
+
?? platformFromApp
|
|
761
|
+
?? detailsView.platform
|
|
762
|
+
?? "browser";
|
|
694
763
|
if (isPair && pairConfig) {
|
|
695
764
|
// Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
|
|
696
765
|
// 1. If the iteration already carries `conversations[]` from a
|
|
@@ -782,7 +851,7 @@ Examples:
|
|
|
782
851
|
participant_type: "ai",
|
|
783
852
|
status: "draft",
|
|
784
853
|
...(opts.language && { language: opts.language }),
|
|
785
|
-
...(!isMedia && !isChat && { platform:
|
|
854
|
+
...(!isMedia && !isChat && { platform: resolvedPlatform }),
|
|
786
855
|
}));
|
|
787
856
|
log(`Creating ${participantInputs.length} participant${participantInputs.length > 1 ? "s" : ""}...`);
|
|
788
857
|
const batchResult = await client.post(`/iterations/${iterationId}/participants/batch`, { participants: participantInputs }, { timeout: dispatchTimeoutMs });
|
|
@@ -814,6 +883,8 @@ Examples:
|
|
|
814
883
|
devtools: opts.devtools,
|
|
815
884
|
debug: opts.debug,
|
|
816
885
|
parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
|
|
886
|
+
platform: resolvedPlatform,
|
|
887
|
+
...(opts.app && { appPath: opts.app }),
|
|
817
888
|
quiet: globals.quiet,
|
|
818
889
|
json: globals.json,
|
|
819
890
|
});
|
|
@@ -1021,7 +1092,7 @@ Examples:
|
|
|
1021
1092
|
const steps = resolveMaxInteractions(opts.maxInteractions, iteration.details);
|
|
1022
1093
|
if (!Number.isFinite(steps))
|
|
1023
1094
|
return null;
|
|
1024
|
-
return estimateMediaRun({ participantCount, maxInteractions: steps });
|
|
1095
|
+
return estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: steps });
|
|
1025
1096
|
})();
|
|
1026
1097
|
if (!opts.wait) {
|
|
1027
1098
|
if (globals.json) {
|
|
@@ -1042,14 +1113,11 @@ Examples:
|
|
|
1042
1113
|
}, true);
|
|
1043
1114
|
}
|
|
1044
1115
|
else {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
log(` ${personName.padEnd(24)} QUEUED`);
|
|
1049
|
-
}
|
|
1116
|
+
const studyAlias = tagAlias(ALIAS_PREFIX.study, resolvedStudy);
|
|
1117
|
+
const n = createdParticipants.length;
|
|
1118
|
+
log(`Dispatched ${n} participant${n > 1 ? "s" : ""} — run \`ish study results ${studyAlias}\` for results (or \`ish study poll --study ${studyAlias}\` / --wait to track progress).`);
|
|
1050
1119
|
const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
|
|
1051
|
-
log(
|
|
1052
|
-
log(`Run \`ish study poll --study ${resolvedStudy}\` (or --wait next time) to check progress.`);
|
|
1120
|
+
log(` ${terminalLink(url, "Open in browser ↗")}`);
|
|
1053
1121
|
}
|
|
1054
1122
|
return;
|
|
1055
1123
|
}
|
package/dist/commands/study.js
CHANGED
|
@@ -113,7 +113,7 @@ Concept pages: ish docs get-page concepts/study
|
|
|
113
113
|
.requiredOption("--name <name>", "Study name")
|
|
114
114
|
.option("--description <description>", "Study description")
|
|
115
115
|
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
|
|
116
|
-
.option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Not used for interactive / chat.")
|
|
116
|
+
.option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Changes how --title is presented to participants (e.g. content-type email renders --title as the Subject: line). Not used for interactive / chat.")
|
|
117
117
|
.option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
|
|
118
118
|
.option("--assignments-file <path>", "JSON file with assignments array")
|
|
119
119
|
.option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
|
|
@@ -124,7 +124,7 @@ Concept pages: ish docs get-page concepts/study
|
|
|
124
124
|
.option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait (hyphen/underscore variants accepted)")
|
|
125
125
|
.option("--content-url <url>", "Public URL of the media file. Creates iteration A inline (video, audio, document modalities). For local files, use the 2-step `iteration create` flow.")
|
|
126
126
|
.option("--image-urls <urls>", "Comma-separated public image URLs. Creates iteration A inline (image modality). For local files, use the 2-step `iteration create` flow.")
|
|
127
|
-
.option("--title <title>", "
|
|
127
|
+
.option("--title <title>", "Participant-facing content title — the headline participants read before the body (text + media modalities — image, video, audio, document; optional). With --content-type email it becomes the email Subject: line. Not an internal label. Not used for interactive / chat.")
|
|
128
128
|
.option("--segmentation-json <json>", "Segmentation JSON for the inline iteration A — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} (text + media). section_based sections are SEMANTIC: group related paragraphs into a few coherent sections (a long article is usually 3-6 sections, not one per paragraph). Lets one `study create` build a complete segmented iteration — no separate `iteration create` needed.")
|
|
129
129
|
.option("--content-config-json <json>", "Content-config JSON for the inline iteration A (early_termination, selected_segment_indices) — text + media.")
|
|
130
130
|
.option("--content-html <html>", "HTML version of the text, or @filepath — text modality (email rendering)")
|
|
@@ -239,6 +239,9 @@ Examples:
|
|
|
239
239
|
|
|
240
240
|
Content types by modality (source: VALID_CONTENT_TYPES in src/lib/types.ts; interactive + chat omitted — they don't take --content-type):
|
|
241
241
|
${describeContentTypes()}
|
|
242
|
+
The content type also shapes how --title is rendered to participants: with
|
|
243
|
+
content-type email, --title becomes the email Subject: line; otherwise it
|
|
244
|
+
reads as the leading headline of the content.
|
|
242
245
|
|
|
243
246
|
Tips:
|
|
244
247
|
Use \`--get <path>\` to capture a single value (e.g. \`--get id\`),
|
|
@@ -310,11 +313,12 @@ Next: configure a run with \`ish iteration create --study <id>\`,
|
|
|
310
313
|
}
|
|
311
314
|
normalizedScreenFormat = normalized;
|
|
312
315
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
316
|
+
// --title is participant-facing content: the backend renders it as a
|
|
317
|
+
// leading headline participants read before the body (and as the email
|
|
318
|
+
// Subject: line when content-type is email). It only exists on text +
|
|
319
|
+
// media modalities (see `buildIterationDetails` in iteration.ts), so
|
|
320
|
+
// reject it on shapes that have no title field — interactive (URL only)
|
|
321
|
+
// and chat (endpoint config carries its own configuration).
|
|
318
322
|
if (opts.title !== undefined
|
|
319
323
|
&& opts.contentText === undefined
|
|
320
324
|
&& opts.contentUrl === undefined
|
package/dist/lib/alias-store.js
CHANGED
|
@@ -129,7 +129,7 @@ const HYDRATE_HINT = {
|
|
|
129
129
|
// alias map at ~/.ish/aliases.json carries any sources the CLI has
|
|
130
130
|
// touched in this session.
|
|
131
131
|
ps: "ish source upload <file> # or `cat ~/.ish/aliases.json | grep ^ps-` to recover prior aliases",
|
|
132
|
-
pt: "ish participant
|
|
132
|
+
pt: "ish study participant <participant-id>",
|
|
133
133
|
c: "ish config list",
|
|
134
134
|
a: "ish ask list",
|
|
135
135
|
r: "ish ask get <ask-id>",
|
package/dist/lib/api-client.d.ts
CHANGED
package/dist/lib/billing.d.ts
CHANGED
|
@@ -2,17 +2,23 @@
|
|
|
2
2
|
* Credit cost estimators — mirror the backend's billing formulas so the CLI
|
|
3
3
|
* can surface a pre-dispatch estimate without a network round-trip.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Source of truth: `ish-backend/app/billing/rates.py` (`MODALITY_RATES`,
|
|
6
|
+
* `compute_step_cost`, `compute_chat_cost`, `ASK_PER_RESPONSE_CREDITS`,
|
|
7
|
+
* `PAIR_CHAT_SIDE_MULTIPLIER`). These numbers MUST match that module — when a
|
|
8
|
+
* multiplier changes there, update this file in the same commit, otherwise the
|
|
9
|
+
* CLI will mislead agents. The backend prices through one `price_run` dispatcher
|
|
10
|
+
* shared by `POST /billing/estimate` and the real preflight reservation, so the
|
|
11
|
+
* preview here is calibrated to the actual charge as long as the rates agree.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Each modality has its own per-step rate (interactive costs the most —
|
|
14
|
+
* screenshot + vision per step; text-only the least). The per-principal cost is
|
|
15
|
+
* `max(1, round(steps * per_step_credits))`, per participant for step-based
|
|
16
|
+
* modalities and per conversation (×2 in pair mode) for chat. Asks bill a flat
|
|
17
|
+
* credit per successful participant response (an upper bound — refusals/errors
|
|
18
|
+
* don't bill). Clients that need authoritative live rates can fetch them from
|
|
19
|
+
* `GET /billing/rates`; this offline mirror is for the pre-dispatch preview.
|
|
15
20
|
*/
|
|
21
|
+
import type { Modality } from "./modality.js";
|
|
16
22
|
export interface CreditEstimate {
|
|
17
23
|
/** Upper bound (no early termination). Never claims exactness. */
|
|
18
24
|
upper_bound: number;
|
|
@@ -23,16 +29,24 @@ export interface CreditEstimate {
|
|
|
23
29
|
/** Always "credits" today; reserved for future-proofing (e.g. millicredits). */
|
|
24
30
|
unit: "credits";
|
|
25
31
|
}
|
|
26
|
-
/** Mirror of `app/media/billing.py::media_credit_cost`. */
|
|
27
|
-
export declare function mediaCreditCost(steps: number): number;
|
|
28
|
-
/** Mirror of `app/chat/billing.py::chat_credit_cost`. */
|
|
29
|
-
export declare function chatCreditCost(turns: number): number;
|
|
30
32
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
33
|
+
* Per-principal step-based cost for one participant running `steps` interactions
|
|
34
|
+
* on a study of `modality`. Mirror of `app/billing/rates.py::compute_step_cost`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stepCreditCost(modality: Modality, steps: number): number;
|
|
37
|
+
/**
|
|
38
|
+
* Per-conversation chat cost. Mirror of `app/billing/rates.py::compute_chat_cost`
|
|
39
|
+
* — `max(1, round(turns * chat_rate))`, doubled in pair mode (both sides bill
|
|
40
|
+
* per turn).
|
|
41
|
+
*/
|
|
42
|
+
export declare function chatCreditCost(turns: number, isPair?: boolean): number;
|
|
43
|
+
/**
|
|
44
|
+
* Step-based run (interactive / text / image / video / audio / document):
|
|
45
|
+
* per-participant cost × participant count. The per-step rate is modality-
|
|
46
|
+
* specific (see `PER_STEP_CREDITS`).
|
|
34
47
|
*/
|
|
35
48
|
export declare function estimateMediaRun(args: {
|
|
49
|
+
modality: Modality;
|
|
36
50
|
participantCount: number;
|
|
37
51
|
maxInteractions: number;
|
|
38
52
|
}): CreditEstimate;
|
package/dist/lib/billing.js
CHANGED
|
@@ -2,41 +2,90 @@
|
|
|
2
2
|
* Credit cost estimators — mirror the backend's billing formulas so the CLI
|
|
3
3
|
* can surface a pre-dispatch estimate without a network round-trip.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Source of truth: `ish-backend/app/billing/rates.py` (`MODALITY_RATES`,
|
|
6
|
+
* `compute_step_cost`, `compute_chat_cost`, `ASK_PER_RESPONSE_CREDITS`,
|
|
7
|
+
* `PAIR_CHAT_SIDE_MULTIPLIER`). These numbers MUST match that module — when a
|
|
8
|
+
* multiplier changes there, update this file in the same commit, otherwise the
|
|
9
|
+
* CLI will mislead agents. The backend prices through one `price_run` dispatcher
|
|
10
|
+
* shared by `POST /billing/estimate` and the real preflight reservation, so the
|
|
11
|
+
* preview here is calibrated to the actual charge as long as the rates agree.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Each modality has its own per-step rate (interactive costs the most —
|
|
14
|
+
* screenshot + vision per step; text-only the least). The per-principal cost is
|
|
15
|
+
* `max(1, round(steps * per_step_credits))`, per participant for step-based
|
|
16
|
+
* modalities and per conversation (×2 in pair mode) for chat. Asks bill a flat
|
|
17
|
+
* credit per successful participant response (an upper bound — refusals/errors
|
|
18
|
+
* don't bill). Clients that need authoritative live rates can fetch them from
|
|
19
|
+
* `GET /billing/rates`; this offline mirror is for the pre-dispatch preview.
|
|
15
20
|
*/
|
|
16
|
-
/**
|
|
17
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Per-step credit multiplier per modality. Mirror of
|
|
23
|
+
* `app/billing/rates.py::MODALITY_RATES` (the `per_step_credits` field).
|
|
24
|
+
*/
|
|
25
|
+
const PER_STEP_CREDITS = {
|
|
26
|
+
interactive: 1.0,
|
|
27
|
+
video: 0.5,
|
|
28
|
+
audio: 0.3,
|
|
29
|
+
image: 0.2,
|
|
30
|
+
text: 0.1,
|
|
31
|
+
document: 0.3,
|
|
32
|
+
chat: 0.2,
|
|
33
|
+
};
|
|
34
|
+
/** Mirror of `app/billing/rates.py::ASK_PER_RESPONSE_CREDITS`. */
|
|
35
|
+
const ASK_PER_RESPONSE_CREDITS = 1;
|
|
36
|
+
/** Mirror of `app/billing/rates.py::PAIR_CHAT_SIDE_MULTIPLIER`. */
|
|
37
|
+
const PAIR_CHAT_SIDE_MULTIPLIER = 2;
|
|
38
|
+
/**
|
|
39
|
+
* Round half-to-even ("banker's rounding") to match Python's built-in
|
|
40
|
+
* `round()`, which the backend uses. `Math.round` rounds half *up*, so for
|
|
41
|
+
* costs that land on a .5 boundary (e.g. video at rate 0.5 with an odd step
|
|
42
|
+
* count: 5 × 0.5 = 2.5) it would over-quote by one credit vs the real charge.
|
|
43
|
+
* Replicating banker's rounding keeps the preview byte-for-byte with the
|
|
44
|
+
* backend's `compute_step_cost` / `compute_chat_cost`.
|
|
45
|
+
*/
|
|
46
|
+
function bankersRound(value) {
|
|
47
|
+
const floor = Math.floor(value);
|
|
48
|
+
const diff = value - floor;
|
|
49
|
+
if (diff < 0.5)
|
|
50
|
+
return floor;
|
|
51
|
+
if (diff > 0.5)
|
|
52
|
+
return floor + 1;
|
|
53
|
+
// Exactly .5 — round to the nearest even integer.
|
|
54
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Per-principal step-based cost for one participant running `steps` interactions
|
|
58
|
+
* on a study of `modality`. Mirror of `app/billing/rates.py::compute_step_cost`.
|
|
59
|
+
*/
|
|
60
|
+
export function stepCreditCost(modality, steps) {
|
|
18
61
|
if (!Number.isFinite(steps) || steps <= 0)
|
|
19
62
|
return 1;
|
|
20
|
-
return Math.max(1,
|
|
63
|
+
return Math.max(1, bankersRound(steps * PER_STEP_CREDITS[modality]));
|
|
21
64
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Per-conversation chat cost. Mirror of `app/billing/rates.py::compute_chat_cost`
|
|
67
|
+
* — `max(1, round(turns * chat_rate))`, doubled in pair mode (both sides bill
|
|
68
|
+
* per turn).
|
|
69
|
+
*/
|
|
70
|
+
export function chatCreditCost(turns, isPair = false) {
|
|
24
71
|
if (!Number.isFinite(turns) || turns <= 0)
|
|
25
|
-
return 1;
|
|
26
|
-
|
|
72
|
+
return isPair ? PAIR_CHAT_SIDE_MULTIPLIER : 1;
|
|
73
|
+
const perSide = Math.max(1, bankersRound(turns * PER_STEP_CREDITS.chat));
|
|
74
|
+
return isPair ? perSide * PAIR_CHAT_SIDE_MULTIPLIER : perSide;
|
|
27
75
|
}
|
|
28
76
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
77
|
+
* Step-based run (interactive / text / image / video / audio / document):
|
|
78
|
+
* per-participant cost × participant count. The per-step rate is modality-
|
|
79
|
+
* specific (see `PER_STEP_CREDITS`).
|
|
32
80
|
*/
|
|
33
81
|
export function estimateMediaRun(args) {
|
|
34
|
-
const perParticipant =
|
|
82
|
+
const perParticipant = stepCreditCost(args.modality, args.maxInteractions);
|
|
35
83
|
const total = Math.max(0, args.participantCount) * perParticipant;
|
|
84
|
+
const rate = PER_STEP_CREDITS[args.modality];
|
|
36
85
|
return {
|
|
37
86
|
upper_bound: total,
|
|
38
87
|
formula: "media_per_participant",
|
|
39
|
-
breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxInteractions} steps
|
|
88
|
+
breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxInteractions} steps × ${rate})) = ${args.participantCount} × ${perParticipant} = ${total}`,
|
|
40
89
|
unit: "credits",
|
|
41
90
|
};
|
|
42
91
|
}
|
|
@@ -47,18 +96,19 @@ export function estimateChatSolo(args) {
|
|
|
47
96
|
return {
|
|
48
97
|
upper_bound: total,
|
|
49
98
|
formula: "chat_solo",
|
|
50
|
-
breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxTurns} turns
|
|
99
|
+
breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxTurns} turns × ${PER_STEP_CREDITS.chat})) = ${args.participantCount} × ${perParticipant} = ${total}`,
|
|
51
100
|
unit: "credits",
|
|
52
101
|
};
|
|
53
102
|
}
|
|
54
103
|
/** Participant-pair chat: each turn bills both sides, so cost doubles. */
|
|
55
104
|
export function estimateChatPair(args) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
105
|
+
const perConversation = chatCreditCost(args.maxTurns, true);
|
|
106
|
+
const perSide = perConversation / PAIR_CHAT_SIDE_MULTIPLIER;
|
|
107
|
+
const total = Math.max(0, args.conversationCount) * perConversation;
|
|
58
108
|
return {
|
|
59
109
|
upper_bound: total,
|
|
60
110
|
formula: "chat_pair",
|
|
61
|
-
breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns
|
|
111
|
+
breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns × ${PER_STEP_CREDITS.chat})) × ${PAIR_CHAT_SIDE_MULTIPLIER} sides = ${args.conversationCount} × ${perSide} × ${PAIR_CHAT_SIDE_MULTIPLIER} = ${total}`,
|
|
62
112
|
unit: "credits",
|
|
63
113
|
};
|
|
64
114
|
}
|
|
@@ -67,11 +117,11 @@ export function estimateChatPair(args) {
|
|
|
67
117
|
* for completed responses; the upper bound assumes everyone completes).
|
|
68
118
|
*/
|
|
69
119
|
export function estimateAskRound(args) {
|
|
70
|
-
const total = Math.max(0, args.participantCount);
|
|
120
|
+
const total = Math.max(0, args.participantCount) * ASK_PER_RESPONSE_CREDITS;
|
|
71
121
|
return {
|
|
72
122
|
upper_bound: total,
|
|
73
123
|
formula: "ask_per_response",
|
|
74
|
-
breakdown: `${args.participantCount} participant(s) ×
|
|
124
|
+
breakdown: `${args.participantCount} participant(s) × ${ASK_PER_RESPONSE_CREDITS} credit/response = ${total} (upper bound; only successful responses bill)`,
|
|
75
125
|
unit: "credits",
|
|
76
126
|
};
|
|
77
127
|
}
|