@ishlabs/cli 0.24.0 → 0.24.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.
@@ -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
@@ -663,7 +663,7 @@ Examples:
663
663
  : `CLI default — pass --max-interactions to override`;
664
664
  log(` Max steps: ${stepsForMedia} (${source})`);
665
665
  if (Number.isFinite(stepsForMedia)) {
666
- const est = estimateMediaRun({ participantCount, maxInteractions: stepsForMedia });
666
+ const est = estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: stepsForMedia });
667
667
  log(` Credits (est): ≈ ${est.upper_bound} credit(s) upper bound — ${est.breakdown}`);
668
668
  }
669
669
  }
@@ -1021,7 +1021,7 @@ Examples:
1021
1021
  const steps = resolveMaxInteractions(opts.maxInteractions, iteration.details);
1022
1022
  if (!Number.isFinite(steps))
1023
1023
  return null;
1024
- return estimateMediaRun({ participantCount, maxInteractions: steps });
1024
+ return estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: steps });
1025
1025
  })();
1026
1026
  if (!opts.wait) {
1027
1027
  if (globals.json) {
@@ -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
- * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
- * and `app/billing/service.py`. If the backend formula changes, update
7
- * this file in the same commit, otherwise the CLI will mislead agents.
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
- * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
- * per principal (per participant for media/interactive, per conversation for
11
- * chat, ×2 for participant-pair). Asks bill flat 1 credit per successful
12
- * participant response. These are intentionally per-run estimates; long-term
13
- * we'll fetch `GET /billing/rates` and parameterise modalitiessee
14
- * `reference/credits` docs page.
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
- * Media/interactive run: 1 credit-cost-per-participant × participant count. Modality
32
- * doesn't currently affect the rate (interactive == text == video at the
33
- * billing layer) — kept as a parameter for forward compatibility.
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;
@@ -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
- * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
- * and `app/billing/service.py`. If the backend formula changes, update
7
- * this file in the same commit, otherwise the CLI will mislead agents.
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
- * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
- * per principal (per participant for media/interactive, per conversation for
11
- * chat, ×2 for participant-pair). Asks bill flat 1 credit per successful
12
- * participant response. These are intentionally per-run estimates; long-term
13
- * we'll fetch `GET /billing/rates` and parameterise modalitiessee
14
- * `reference/credits` docs page.
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
- /** Mirror of `app/media/billing.py::media_credit_cost`. */
17
- export function mediaCreditCost(steps) {
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, Math.round(steps / 10));
63
+ return Math.max(1, bankersRound(steps * PER_STEP_CREDITS[modality]));
21
64
  }
22
- /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
23
- export function chatCreditCost(turns) {
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
- return Math.max(1, Math.round(turns / 10));
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
- * Media/interactive run: 1 credit-cost-per-participant × participant count. Modality
30
- * doesn't currently affect the rate (interactive == text == video at the
31
- * billing layer) — kept as a parameter for forward compatibility.
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 = mediaCreditCost(args.maxInteractions);
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 / 10)) = ${args.participantCount} × ${perParticipant} = ${total}`,
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 / 10)) = ${args.participantCount} × ${perParticipant} = ${total}`,
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 perSide = chatCreditCost(args.maxTurns);
57
- const total = Math.max(0, args.conversationCount) * perSide * 2;
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 / 10)) × 2 sides = ${args.conversationCount} × ${perSide} × 2 = ${total}`,
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) × 1 credit/response = ${total} (upper bound; only successful responses bill)`,
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
  }
@@ -7,7 +7,16 @@
7
7
  * and the call sites (`study run`, `iteration update`, ...) pick up the
8
8
  * change for free.
9
9
  */
10
- export type Modality = "interactive" | "video" | "audio" | "text" | "image" | "document" | "chat";
10
+ /** All study modalities. Matches the backend `Modality` enum value set. */
11
+ export declare const MODALITIES: readonly ["interactive", "video", "audio", "text", "image", "document", "chat"];
12
+ export type Modality = typeof MODALITIES[number];
13
+ /**
14
+ * Coerce a raw study-modality string to a known `Modality`, defaulting to
15
+ * `"interactive"` for unknown/empty values — the same default the CLI uses
16
+ * elsewhere (`study.modality || "interactive"`) and the highest-cost rate, so
17
+ * an unrecognised modality errs toward over-estimating rather than under.
18
+ */
19
+ export declare function toModality(raw: string | undefined): Modality;
11
20
  /** True for the media-bucket modalities that share batch + content semantics. */
12
21
  export declare function isMediaModality(modality: string | undefined): boolean;
13
22
  /** True for chat modality. Wrapped as a helper so callers don't sprinkle string literals. */
@@ -9,6 +9,27 @@
9
9
  */
10
10
  import { normalizeEnumValue } from "./enums.js";
11
11
  import { MEDIA_MODALITIES } from "./types.js";
12
+ /** All study modalities. Matches the backend `Modality` enum value set. */
13
+ export const MODALITIES = [
14
+ "interactive",
15
+ "video",
16
+ "audio",
17
+ "text",
18
+ "image",
19
+ "document",
20
+ "chat",
21
+ ];
22
+ /**
23
+ * Coerce a raw study-modality string to a known `Modality`, defaulting to
24
+ * `"interactive"` for unknown/empty values — the same default the CLI uses
25
+ * elsewhere (`study.modality || "interactive"`) and the highest-cost rate, so
26
+ * an unrecognised modality errs toward over-estimating rather than under.
27
+ */
28
+ export function toModality(raw) {
29
+ return MODALITIES.includes(raw ?? "")
30
+ ? raw
31
+ : "interactive";
32
+ }
12
33
  /** True for the media-bucket modalities that share batch + content semantics. */
13
34
  export function isMediaModality(modality) {
14
35
  return !!modality && MEDIA_MODALITIES.includes(modality);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {