@genex-ai/cli-demo 1.36.4 → 1.36.5-dev.855

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/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  writeSecretFile,
58
58
  writeUserToken,
59
59
  writeWorkspace
60
- } from "./chunk-4BBTPRJW.js";
60
+ } from "./chunk-EABIVAGT.js";
61
61
  import {
62
62
  CLI_CHANNEL,
63
63
  DEFAULT_API_URL,
@@ -78,7 +78,7 @@ import {
78
78
  normalizeApiOrigin,
79
79
  originKey,
80
80
  resolveAgentTargets
81
- } from "./chunk-QI7FIYBY.js";
81
+ } from "./chunk-K4EHXOZK.js";
82
82
 
83
83
  // src/instrument.ts
84
84
  import * as Sentry from "@sentry/node";
@@ -981,7 +981,7 @@ Important note: put soul into your creations, with many details and love. Aim to
981
981
  21. A turn ends in exactly one of two ways: on a question the player must answer before the next step can be chosen, or on a handoff \u2014 the draft link, what changed, what to try, and what is still open. Never close a turn on a promise. "I'll keep building", "while I keep working", "adding that now" are things you say and then DO before you stop; if you are stopping, say that you have stopped and what remains. While the Build plan's \`Now:\` line has open work and no answer is needed from the player, the turn is not over: preview, hand off, and start the next milestone in the same turn, until the requested outcome is reached or the platform's own budget ends the session. If a stop was forced on you anyway, the first line of your next turn is the promise you left and where it stands.
982
982
  22. **What the player sees must read as the thing it is**, in this game's own style: a building as that building, a person as a person, a prop as that prop, a surface as its material \u2014 and a coloured box, a capsule, or a flat grey block standing in for one of them is never its finished version. Generation is the default route to that bar wherever code will not honestly reach it: the player's character, whatever the request names, whatever the player walks up to, enters, or interacts with, and the music and sound underneath \u2014 reach for the belt on your own judgment, without stopping to ask; a status line saying what you queued is enough, and \`--no-wait\` keeps you building while it lands. Procedural code is a first-class engine for what is structural, repeated, distant, or parametric \u2014 terrain, sky, fences, paving, modular kits, filler \u2014 and for anything else only when the result meets the same bar and you have looked at it in a capture before its row says \`landed\`. Start with one reusable implementation per distinct required gameplay or visual role. Background populations default to reusable procedural bodies and motion or compatible existing rigged assets. Catalog clips need a compatible skeleton and may be paid. Reserve paid custom bodies for the player and characters examined or interacted with closely, while honoring explicit user requests. Add variants only for an unmet requirement; unspent allowance is not unfinished work. Allowance questions use timeoutPolicy no-consent: silence does not raise the allowance. Record role, reuse and completion criteria in the existing Assets table. Decide per object, not per category, and write the route on each Assets row in \`DESIGN.md\` \u2014 the paid flow for generated pieces, the procedural flow with its local path for code-built ones. An Assets table with no generation in it is a decision, not a default: record it as \`Generation: none \u2014 <why code alone reaches the bar here>\` or generate.
983
983
  23. **The screenshots you take to look at your own work go in \`.genex/scratch/\`.** Captures, render comparisons, before/after strips, traces and metrics dumps are how you SEE what you built \u2014 they are not part of what you built, and \`genex preview\` pushes the whole folder to the game's source, every time, forever. \`.genex/scratch/\` already exists for this and is already ignored, so there is nothing to set up: write the capture as \`.genex/scratch/arena-before.png\` and read it back from there. Never put them in a folder of your own at the top level: \`progress/\`, \`reports/\`, \`shots/\` and their kind are pushed like source and become permanent weight in the game and in every remix of it \u2014 one real game reached 11 GB and 10,332 committed screenshots exactly this way. If you find such a folder already there, add it to \`.gitignore\`; never delete the player's files (law 18). One file in \`.genex/scratch/\` IS read, by the platform: \`.genex/scratch/cover.png\` is the game's cover \u2014 the frame you would put on its poster: real gameplay at its signature moment, well lit, no menus, popups or busy HUD, landscape 16:9, and never any text (the page sets the title beside it). The next \`genex preview\` makes it the cover; save a better moment over it whenever one comes along, and if your browser tool can set the cover itself, use that instead. Before the first publish, and whenever the game's look changes, load \`$genex-cover\`: it chooses, stages, lights and judges that frame.
984
- 24. **Benchmark before declaring an in-game LLM price.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it, and \`estimateCoins\` is the FIXED price of a started attempt: charged whether it succeeds, fails, is canceled, or stops at its own budget. Never pick that number by judgement or from a vendor's rate card. Run the real prompt on your own coins \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-coins <n> --user-approved\` \u2014 and declare the figure it recommends, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit is a price change. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate is computed from the game's own loop. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
984
+ 24. **Benchmark before declaring an in-game LLM ceiling.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it from their Genex credits, billed as used, and the game declares no price, only a per-call CEILING: \`maxCredits\` on \`generate()\`, \`perCallMaxCredits\` on a standing budget. That ceiling bounds what one call may cost AND funds how long its answer may be, so never pick it by judgement or from a vendor's rate card. Run the real prompt on your own credits \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-credits <n> --user-approved\` \u2014 and declare the figures it prints, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit changes the ceiling. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate and credits per period are computed from the game's own loop and the bench's measured average. The coin spellings (\`estimateCoins\`, \`perCallMaxCoins\`) are deprecated \u2014 write the credit names. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, credits, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
985
985
  ${CONTRACT_END}
986
986
  `;
987
987
  var TOOLS_CONTRACT_HEAD = `# Genex Tools (always in effect in this folder)
@@ -995,7 +995,7 @@ var TOOLS_CONTRACT_OFFER = `5. **When the game is built and playable \u2014 neve
995
995
  var TOOLS_CONTRACT_HOSTED_LAWS = `5. **This game is hosted on Genex now, and publishing is glue \u2014 never a rebuild.** The game ships exactly as the user built it: \`initEmbed()\` first in the boot code (the \`genex-threejs-embed-auth\` card has the call; the renderer draws before any \`await waitForPlayer()\`), a static build into \`dist/\` with relative asset paths, then \`npx genex preview\`. Never restructure, reformat or "improve" the game to ship it, never scaffold a new app around it, and never start a design document or a build plan for it \u2014 the user's own process is theirs. Load the \`genex-tool-publish\` card before the first preview; it owns the vocabulary, the links and the limits.
996
996
  6. **Ship first, then report.** \`preview\` prints a preflight \u2014 phone memory, a missing volume slider, an asset nobody wired, a viewport line. It never blocks a deploy, so push the build as it is, hand over the link, THEN relay each preflight line to the user in one plain sentence with an offer to fix it, and fix one only on their yes. The preflight is a report for the user, not a to-do list for you.
997
997
  7. **The link is the game's page, and there are two versions.** After every preview give the user \`<dashboard>/draft/<slug>\` \u2014 \`dashboardOrigins[0]\` and \`slug\` from \`.genex/project.json\` \u2014 never localhost, a file path or the bare play origin. \`preview\` updates the draft and never touches what players are on; the first release is \`npx genex publish\` (it lists the game); after that "publish it", "update it" and "yes" all mean \`npx genex promote\` \u2014 the exact draft build, no rebuild. Ask once per round of work, in one line, and keep working while you wait.`;
998
- var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with Genex coins or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
998
+ var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with their Genex credits or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
999
999
  function toolsContractLlmLaw(n, hosted) {
1000
1000
  const onYes = hosted ? "On a yes, load `$genex-tool-llm` and then `$genex-llm-in-games`, which owns the build." : "On a yes, load `$genex-tool-llm`, which owns the rest: it checks the lane with `npx genex llm models` first, then runs `npx genex init --convert` (a hosted game is a static browser build \u2014 a local server holding a key can never ship).";
1001
1001
  return `${n}. **A model running while people PLAY is a platform feature \u2014 never something you wire onto the user's own meter.** When a request implies one (NPCs that talk or decide in their own words, content written from what the player types, a prompt box in the game, "let the player pick a model"), recognise it, say in ONE line: "${TOOLS_CONTRACT_LLM_OFFER}" \u2014 then ASK and wait. ${onYes} On a no, build the authored version instead. Either way, never ship a game that calls a model on a key or an account of the user's own: every visitor would spend their money with nobody approving it, and the credential is readable in the bundle. Player-funded generation is text and JSON only \u2014 3D, images, video and audio stay the asset lanes above, on the user's meter.`;
@@ -8496,7 +8496,9 @@ var BILLING_ERROR_MARKERS = [
8496
8496
  /not enough credit/i,
8497
8497
  /returned HTTP 402\b/,
8498
8498
  /failed:\s*402\b/,
8499
- /insufficient funds/i
8499
+ /insufficient funds/i,
8500
+ /dunning decision is deny/i,
8501
+ /User is locked\. Reason:/
8500
8502
  ];
8501
8503
  function isProviderBillingError(error) {
8502
8504
  if (!error) return false;
@@ -22946,7 +22948,7 @@ async function runBlender(opts) {
22946
22948
  return 1;
22947
22949
  }
22948
22950
  if (sub === "serve") {
22949
- const { serveLocalBlender } = await import("./blender-serve-66OHC4ML.js");
22951
+ const { serveLocalBlender } = await import("./blender-serve-EH2MOW3U.js");
22950
22952
  const port = Number(process.env.GENEX_BLENDER_PORT ?? 8088);
22951
22953
  log.step(`Starting a local Blender service on port ${port}`);
22952
22954
  log.plain(
@@ -22955,7 +22957,7 @@ async function runBlender(opts) {
22955
22957
  return serveLocalBlender({ port, log });
22956
22958
  }
22957
22959
  if (sub === "mcp") {
22958
- const { runBlenderMcp } = await import("./blender-mcp-QVEGHE5L.js");
22960
+ const { runBlenderMcp } = await import("./blender-mcp-WSURPZET.js");
22959
22961
  return runBlenderMcp();
22960
22962
  }
22961
22963
  if (sub === "seat") {
@@ -23463,7 +23465,12 @@ import fs35 from "fs/promises";
23463
23465
  import path36 from "path";
23464
23466
  import { randomUUID as randomUUID2 } from "crypto";
23465
23467
  var SUBS3 = ["models", "bench", "price", "status", "cancel"];
23466
- var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN coin \u2014 your wallet, not a player's, and one this build's asset allowance cannot see. Re-run with --max-coins <n> --user-approved only after the person at the keyboard agreed to the number.";
23468
+ var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN credits \u2014 your balance, not a player's, and a spend this build's asset allowance does not count. Re-run with --max-credits <n> --user-approved only after the person at the keyboard agreed to the number.";
23469
+ var LLM_MAX_COINS_DEPRECATED = "--max-coins is deprecated: it is read as --max-credits, the same number \u2014 a bench spends credits now.";
23470
+ function benchBalanceShortLine(spendable, maxCredits) {
23471
+ return `Your spendable credits (${spendable}) cannot cover one attempt at --max-credits ${maxCredits} \u2014 nothing was sent, nothing was spent. Add credits, or re-run with a lower --max-credits once the person at the keyboard agrees to it.`;
23472
+ }
23473
+ var LLM_BENCH_UNVERIFIED_LINE = "Credits pay only for a verified email, and this account's is not verified yet (credits_unverified) \u2014 nothing was sent, nothing was spent. Verify it, then re-run.";
23467
23474
  var LLM_LANE_OFF_LINE = "The runtime LLM lane is off on this stand \u2014 in-game generate() answers 404 here, and nothing can be benchmarked.";
23468
23475
  var LLM_TOOLS_CONVERT_HINT = [
23469
23476
  "In-game model calls need a hosted game. This folder is a Genex Tools workspace, so nothing here",
@@ -23476,7 +23483,7 @@ function serverSlotCounts(body) {
23476
23483
  return { held: body.slotsHeld, limit: body.slotLimit };
23477
23484
  }
23478
23485
  var PENDING_SLOT_RELEASE_MINUTES = 10;
23479
- var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so its coin stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
23486
+ var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so what it reserved stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
23480
23487
  var LLM_STATUS_NO_COUNT_LINE = "This stand does not report how many slots are held across your projects, so no count is shown.";
23481
23488
  var LLM_CANCEL_AWAITING_BILL = "this call already stopped; its bill is awaiting the provider";
23482
23489
  var LLM_CANCEL_ALREADY_FINAL = "this call is already final";
@@ -23490,11 +23497,33 @@ var DEFAULT_SAMPLE_TIMEOUT_SEC = 180;
23490
23497
  function parseProviderKey(value) {
23491
23498
  return value === "ok" || value === "rejected" || value === "unknown" ? value : null;
23492
23499
  }
23500
+ function bpsOrNull(value) {
23501
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
23502
+ }
23503
+ function standingCreditGrantOrNull(value) {
23504
+ if (!value || typeof value !== "object") return null;
23505
+ const v = value;
23506
+ const whole = (x) => typeof x === "number" && Number.isSafeInteger(x) && x >= 0;
23507
+ if (!whole(v.minCredits) || !whole(v.maxCredits) || !whole(v.stepCredits) || !whole(v.defaultCredits)) return null;
23508
+ return {
23509
+ minCredits: v.minCredits,
23510
+ maxCredits: v.maxCredits,
23511
+ stepCredits: v.stepCredits,
23512
+ defaultCredits: v.defaultCredits,
23513
+ ...whole(v.minUsdCents) ? { minUsdCents: v.minUsdCents } : {},
23514
+ ...whole(v.maxUsdCents) ? { maxUsdCents: v.maxUsdCents } : {},
23515
+ ...whole(v.stepUsdCents) ? { stepUsdCents: v.stepUsdCents } : {}
23516
+ };
23517
+ }
23493
23518
  async function readRuntimeLane(apiUrl, token) {
23494
23519
  const empty = (state) => ({
23495
23520
  state,
23496
23521
  models: null,
23497
23522
  recommendedDeclaredHeadroomBps: null,
23523
+ currency: null,
23524
+ feeBps: null,
23525
+ usdCentsPerCredit: null,
23526
+ standingCreditGrant: null,
23498
23527
  externalProviders: null,
23499
23528
  total: null,
23500
23529
  featuredCount: null,
@@ -23513,7 +23542,11 @@ async function readRuntimeLane(apiUrl, token) {
23513
23542
  return {
23514
23543
  state: "live",
23515
23544
  models: body.models,
23516
- recommendedDeclaredHeadroomBps: typeof body.recommendedDeclaredHeadroomBps === "number" && Number.isFinite(body.recommendedDeclaredHeadroomBps) ? body.recommendedDeclaredHeadroomBps : null,
23545
+ recommendedDeclaredHeadroomBps: bpsOrNull(body.recommendedDeclaredHeadroomBps),
23546
+ currency: body.currency === "credits" || body.currency === "coins" ? body.currency : null,
23547
+ feeBps: bpsOrNull(body.feeBps),
23548
+ usdCentsPerCredit: typeof body.usdCentsPerCredit === "number" && Number.isSafeInteger(body.usdCentsPerCredit) && body.usdCentsPerCredit >= 1 ? body.usdCentsPerCredit : null,
23549
+ standingCreditGrant: standingCreditGrantOrNull(body.standingCreditGrant),
23517
23550
  externalProviders: Array.isArray(body.externalProviders) ? body.externalProviders : null,
23518
23551
  total: typeof body.total === "number" && Number.isFinite(body.total) ? body.total : null,
23519
23552
  featuredCount: typeof body.featuredCount === "number" && Number.isFinite(body.featuredCount) ? body.featuredCount : null,
@@ -23529,18 +23562,45 @@ function percentile2(values, p) {
23529
23562
  const rank2 = Math.ceil(p / 100 * sorted.length);
23530
23563
  return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
23531
23564
  }
23532
- function recommendEstimateCoins(p95, headroomBps) {
23533
- return Math.max(1, Math.ceil(p95 * (1e4 + headroomBps) / 1e4));
23565
+ function percentilePicos(values, p) {
23566
+ if (values.length === 0) return null;
23567
+ const sorted = [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
23568
+ const rank2 = Math.ceil(p / 100 * sorted.length);
23569
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
23534
23570
  }
23535
- function recommendPerCallMaxCoins(p95, max, headroomBps) {
23536
- return Math.max(recommendEstimateCoins(p95, headroomBps), recommendEstimateCoins(max, headroomBps));
23571
+ var PICOS_PER_CENT = 10000000000n;
23572
+ function ceilingCreditsFor(costPicos, terms) {
23573
+ if (costPicos <= 0n) return 1;
23574
+ const num = costPicos * BigInt(1e4 + terms.feeBps) * BigInt(1e4 + terms.headroomBps);
23575
+ const den = 100000000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
23576
+ return Math.max(1, Number((num + den - 1n) / den));
23577
+ }
23578
+ function averageCreditsPerCall(costsPicos, terms) {
23579
+ if (costsPicos.length === 0) return null;
23580
+ const sum = costsPicos.reduce((a, b) => a + b, 0n);
23581
+ return Number(sum * BigInt(1e4 + terms.feeBps)) / Number(BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT);
23582
+ }
23583
+ function perCallEstimateCreditsFor(costsPicos, terms) {
23584
+ if (costsPicos.length === 0) return null;
23585
+ const sum = costsPicos.reduce((a, b) => a + b, 0n);
23586
+ const num = sum * BigInt(1e4 + terms.feeBps);
23587
+ const den = BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
23588
+ return Math.max(1, Number((num + den - 1n) / den));
23589
+ }
23590
+ function formatCredits(value) {
23591
+ if (!Number.isFinite(value)) return "\u2014";
23592
+ if (value >= 100) return String(Math.round(value));
23593
+ return String(Number(value.toPrecision(value < 1 ? 2 : 3)));
23594
+ }
23595
+ function usdFromPicos(picos) {
23596
+ if (picos === null || picos === void 0) return "unknown";
23597
+ const value = typeof picos === "bigint" ? picos : /^\d+$/.test(picos) ? BigInt(picos) : null;
23598
+ if (value === null) return "unknown";
23599
+ return `$${(Number(value) / 1e12).toFixed(6)}`;
23537
23600
  }
23538
23601
  function usdPerMillion(value) {
23539
23602
  return `$${value.toFixed(value < 1 ? 4 : 2)}`;
23540
23603
  }
23541
- function usd(value) {
23542
- return value === null ? "unknown" : `$${value.toFixed(6)}`;
23543
- }
23544
23604
  async function runLlm(opts = {}) {
23545
23605
  const log = createLogger({ quiet: opts.quiet || opts.json });
23546
23606
  const cwd = opts.cwd ?? process.cwd();
@@ -23566,16 +23626,23 @@ async function runLlm(opts = {}) {
23566
23626
  return;
23567
23627
  }
23568
23628
  if (sub === "bench") {
23569
- if (opts.maxCoins === void 0 || opts.userApproved !== true) {
23629
+ if (opts.maxCoins !== void 0) process.stderr.write(`${c.yellow("!")} ${LLM_MAX_COINS_DEPRECATED}
23630
+ `);
23631
+ if (opts.maxCoins !== void 0 && opts.maxCredits !== void 0 && opts.maxCoins !== opts.maxCredits) {
23632
+ fail5("llm bench", `Pass --max-credits alone \u2014 --max-coins is its deprecated name, and the two disagree (${opts.maxCredits} vs ${opts.maxCoins}).`);
23633
+ return;
23634
+ }
23635
+ const approvedCredits = opts.maxCredits ?? opts.maxCoins;
23636
+ if (approvedCredits === void 0 || opts.userApproved !== true) {
23570
23637
  fail5("llm bench", LLM_BENCH_APPROVAL_REQUIRED);
23571
23638
  return;
23572
23639
  }
23573
- if (!Number.isInteger(opts.maxCoins) || opts.maxCoins < 1) {
23574
- fail5("llm bench", `--max-coins takes a whole number of coin, 1 or more (got ${String(opts.maxCoins)}).`);
23640
+ if (!Number.isInteger(approvedCredits) || approvedCredits < 1) {
23641
+ fail5("llm bench", `--max-credits takes a whole number of credits, 1 or more (got ${String(approvedCredits)}).`);
23575
23642
  return;
23576
23643
  }
23577
23644
  if (!opts.benchPrompt?.trim()) {
23578
- fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-coins <n> --user-approved.');
23645
+ fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-credits <n> --user-approved.');
23579
23646
  return;
23580
23647
  }
23581
23648
  if (opts.jsonOutput && opts.textOutput) {
@@ -23667,6 +23734,10 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23667
23734
  models: null,
23668
23735
  externalProviders: null,
23669
23736
  recommendedDeclaredHeadroomBps: null,
23737
+ currency: null,
23738
+ feeBps: null,
23739
+ usdCentsPerCredit: null,
23740
+ standingCreditGrant: null,
23670
23741
  total: null,
23671
23742
  featuredCount: null,
23672
23743
  // The stand was never asked, so the verdict is unknown — but the key is
@@ -23692,6 +23763,12 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23692
23763
  models: lane.models,
23693
23764
  externalProviders: lane.externalProviders,
23694
23765
  recommendedDeclaredHeadroomBps: lane.recommendedDeclaredHeadroomBps,
23766
+ // The credit terms a call is billed on, the server's — null on a stand
23767
+ // that predates credit billing, never a number the CLI filled in.
23768
+ currency: lane.currency,
23769
+ feeBps: lane.feeBps,
23770
+ usdCentsPerCredit: lane.usdCentsPerCredit,
23771
+ standingCreditGrant: lane.standingCreditGrant,
23695
23772
  total: lane.total ?? lane.models?.length ?? null,
23696
23773
  featuredCount: lane.featuredCount ?? (lane.models ? lane.models.filter((m) => m.featured).length : null),
23697
23774
  // `null` when the lane is not live or the server predates the probe.
@@ -23738,8 +23815,9 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23738
23815
  );
23739
23816
  for (const m of shown) {
23740
23817
  const plan = m.personalPlan ? ` \xB7 personal plan: ${m.personalPlan}` : "";
23818
+ const kind = m.kind === "typesafe" ? " \xB7 judge (classifier): judge() under a budget, not generate() or bench" : "";
23741
23819
  log.plain(
23742
- ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}`
23820
+ ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}${kind}`
23743
23821
  );
23744
23822
  }
23745
23823
  if (hiddenCount > 0) {
@@ -23755,74 +23833,104 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23755
23833
  }
23756
23834
  log.plain("");
23757
23835
  if (lane.recommendedDeclaredHeadroomBps === null) {
23758
- log.dim(" This stand serves no recommended headroom, so no price can be recommended from a bench.");
23836
+ log.dim(" This stand serves no recommended headroom, so no ceiling can be recommended from a bench.");
23759
23837
  } else {
23760
- log.dim(` Recommended headroom over benchmarked coins on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
23838
+ log.dim(` Recommended headroom over a benchmarked call's cost on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
23839
+ }
23840
+ if (lane.feeBps === null) {
23841
+ log.dim(" This stand serves no platform fee \u2014 it predates credit billing, so no ceiling can be recommended from a bench.");
23842
+ } else {
23843
+ log.dim(` In-game calls are billed in the player's credits, as used: the provider's cost plus this stand's platform fee (${lane.feeBps} bps).`);
23761
23844
  }
23762
23845
  if (toolsOnly) {
23763
23846
  printConvertHint();
23764
23847
  return;
23765
23848
  }
23766
- log.dim(" Those per-million rates are the PROVIDER's; what your game is charged is the platform");
23767
- log.dim(` tariff on top, which only a real call reveals: ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')}`);
23849
+ log.dim(" Those per-million rates are the PROVIDER's list; what one call of YOUR prompt really costs,");
23850
+ log.dim(` and the per-call ceiling to declare, only a real call measures: ${c.cyan('genex llm bench "<prompt>" --max-credits <n> --user-approved')}`);
23768
23851
  }
23769
23852
  function wholeOrNull(value) {
23770
23853
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
23771
23854
  }
23855
+ function costPicosOf(usage) {
23856
+ if (typeof usage?.costUsdPicos === "string" && /^\d+$/.test(usage.costUsdPicos)) return usage.costUsdPicos;
23857
+ if (typeof usage?.costUsd === "number" && Number.isFinite(usage.costUsd) && usage.costUsd >= 0) {
23858
+ return String(BigInt(Math.round(usage.costUsd * 1e12)));
23859
+ }
23860
+ return null;
23861
+ }
23772
23862
  function sampleFrom(id, settled) {
23773
23863
  const usage = settled?.usage;
23864
+ const picos = costPicosOf(usage);
23774
23865
  return {
23775
23866
  id,
23776
23867
  status: settled?.status ?? null,
23777
23868
  billingStatus: settled?.billingStatus ?? null,
23778
- chargedCoins: typeof settled?.chargedCoins === "number" ? settled.chargedCoins : null,
23779
- costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : null,
23869
+ chargedCredits: wholeOrNull(settled?.chargedCredits),
23870
+ costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : picos !== null ? Number(picos) / 1e12 : null,
23871
+ costUsdPicos: picos,
23780
23872
  error: settled?.error ?? (settled ? null : "timed_out"),
23781
23873
  providerMessage: typeof settled?.providerMessage === "string" && settled.providerMessage.trim() ? settled.providerMessage.trim() : null,
23782
23874
  inputTokens: wholeOrNull(usage?.inputTokens),
23783
23875
  outputTokens: wholeOrNull(usage?.outputTokens),
23784
23876
  maxOutputTokens: wholeOrNull(usage?.maxOutputTokens),
23785
- fitCoins: typeof usage?.fitCoins === "number" && Number.isSafeInteger(usage.fitCoins) && usage.fitCoins >= 1 ? usage.fitCoins : null,
23877
+ fitCredits: typeof usage?.fitCredits === "number" && Number.isSafeInteger(usage.fitCredits) && usage.fitCredits >= 1 ? usage.fitCredits : null,
23786
23878
  fitExceedsCap: typeof usage?.fitExceedsCap === "boolean" ? usage.fitExceedsCap : null,
23787
23879
  truncated: typeof usage?.truncated === "boolean" ? usage.truncated : settled?.error === "provider_token_limit" ? true : null
23788
23880
  };
23789
23881
  }
23790
23882
  var PROVIDER_TOKEN_LIMIT = "provider_token_limit";
23791
- function benchRecommendation(results, headroomBps) {
23792
- const settled = results.filter((r) => r.status === "succeeded" && r.billingStatus === "final" && typeof r.chargedCoins === "number");
23793
- const charged = settled.map((r) => r.chargedCoins);
23794
- const fits = settled.map((r) => r.fitCoins).filter((v) => typeof v === "number");
23795
- const p95 = percentile2(charged, 95);
23796
- const max = charged.length ? Math.max(...charged) : null;
23883
+ function benchRecommendation(results, terms) {
23884
+ const settled = results.filter(
23885
+ (r) => r.status === "succeeded" && r.billingStatus === "final" && r.chargedCredits !== null && r.costUsdPicos !== null
23886
+ );
23887
+ const costs = settled.map((r) => BigInt(r.costUsdPicos));
23888
+ const charged = settled.map((r) => r.chargedCredits);
23889
+ const fits = settled.map((r) => r.fitCredits).filter((v) => typeof v === "number");
23890
+ const costP95 = percentilePicos(costs, 95);
23891
+ const costMax = costs.length ? costs.reduce((a, b) => b > a ? b : a) : null;
23797
23892
  const fitP95 = percentile2(fits, 95);
23798
23893
  const fitMax = fits.length ? Math.max(...fits) : null;
23799
23894
  const exceedsStandCap = results.some((r) => r.fitExceedsCap === true);
23800
23895
  const cutOffSamples = results.filter((r) => r.error === PROVIDER_TOKEN_LIMIT && r.fitExceedsCap !== true).length;
23801
23896
  const noRecommendationReason = exceedsStandCap ? "answer_exceeds_stand_cap" : cutOffSamples > 0 ? "answer_cut_off" : null;
23802
- const chargedBased = p95 !== null && headroomBps !== null ? recommendEstimateCoins(p95, headroomBps) : null;
23803
- const recommended = chargedBased === null || noRecommendationReason !== null ? null : Math.max(chargedBased, fitP95 ?? 0);
23804
- const ceiling = recommended === null || p95 === null || max === null || headroomBps === null ? null : Math.max(recommendPerCallMaxCoins(p95, max, headroomBps), recommended, fitMax ?? 0);
23897
+ const { headroomBps, feeBps, usdCentsPerCredit } = terms;
23898
+ const missingTerms = headroomBps === null ? "headroom" : feeBps === null || usdCentsPerCredit === null ? "credit_terms" : null;
23899
+ const full = headroomBps !== null && feeBps !== null && usdCentsPerCredit !== null ? { headroomBps, feeBps, usdCentsPerCredit } : null;
23900
+ const billing = feeBps !== null && usdCentsPerCredit !== null ? { feeBps, usdCentsPerCredit } : null;
23901
+ const costBased = full !== null && costP95 !== null ? ceilingCreditsFor(costP95, full) : null;
23902
+ const recommended = costBased === null || noRecommendationReason !== null ? null : Math.max(costBased, fitP95 ?? 0);
23903
+ const ceiling = recommended === null || costMax === null || full === null ? null : Math.max(ceilingCreditsFor(costMax, full), recommended, fitMax ?? 0);
23904
+ const average = billing !== null ? averageCreditsPerCall(costs, billing) : null;
23905
+ const estimate = ceiling === null || billing === null ? null : Math.min(ceiling, perCallEstimateCreditsFor(costs, billing) ?? 1);
23805
23906
  return {
23907
+ costs,
23806
23908
  charged,
23807
- p50: percentile2(charged, 50),
23808
- p95,
23809
- max,
23909
+ costP50: percentilePicos(costs, 50),
23910
+ costP95,
23911
+ costMax,
23912
+ chargedP50: percentile2(charged, 50),
23913
+ chargedP95: percentile2(charged, 95),
23914
+ chargedMax: charged.length ? Math.max(...charged) : null,
23810
23915
  fits,
23811
23916
  fitP95,
23812
23917
  fitMax,
23813
- chargedBasedEstimateCoins: chargedBased,
23814
- recommendedEstimateCoins: recommended,
23815
- recommendedPerCallMaxCoins: ceiling,
23816
- lengthRaisedPrice: recommended !== null && chargedBased !== null && recommended > chargedBased,
23918
+ costBasedMaxCredits: costBased,
23919
+ recommendedMaxCredits: recommended,
23920
+ recommendedPerCallMaxCredits: ceiling,
23921
+ averageCreditsPerCall: average,
23922
+ recommendedPerCallEstimateCredits: estimate,
23923
+ lengthRaisedCeiling: recommended !== null && costBased !== null && recommended > costBased,
23817
23924
  cutOffSamples,
23818
23925
  exceedsStandCap,
23819
- noRecommendationReason
23926
+ noRecommendationReason,
23927
+ missingTerms
23820
23928
  };
23821
23929
  }
23822
- var LLM_LENGTH_RAISED_LINE = "The declared price also pays for how long the answer may be: at the charged-based price the answer would be cut off, so declare this one.";
23823
- var LLM_EXCEEDS_STAND_CAP_LINE = "No price recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
23824
- function benchCutOffLine(cutOff, maxCoins) {
23825
- return `No price recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-coins ${maxCoins}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-coins.`;
23930
+ var LLM_LENGTH_RAISED_LINE = "The ceiling also decides how long the answer may be: at the cost-based ceiling the answer would be cut off, so declare this one.";
23931
+ var LLM_EXCEEDS_STAND_CAP_LINE = "No ceiling recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
23932
+ function benchCutOffLine(cutOff, maxCredits) {
23933
+ return `No ceiling recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-credits ${maxCredits}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-credits.`;
23826
23934
  }
23827
23935
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["requires_confirmation", "queued", "dispatching", "awaiting_external"]);
23828
23936
  function rowState(row) {
@@ -23833,7 +23941,22 @@ function rowState(row) {
23833
23941
  function rowHoldsSlot(row) {
23834
23942
  if (typeof row.slotHeld === "boolean") return row.slotHeld;
23835
23943
  const state = rowState(row);
23836
- return state === "active" || state === "awaiting bill" && (row.reservedCoins ?? 0) > 0;
23944
+ return state === "active" || state === "awaiting bill" && ((row.reservedCoins ?? 0) > 0 || (row.reservedCredits ?? 0) > 0);
23945
+ }
23946
+ function rowMoney(row) {
23947
+ const credits = row.currency === "credits" || row.currency === void 0 && typeof row.reservedCredits === "number" && typeof row.reservedCoins !== "number";
23948
+ if (credits) {
23949
+ return {
23950
+ unit: "credits",
23951
+ reserved: typeof row.reservedCredits === "number" ? row.reservedCredits : null,
23952
+ charged: typeof row.chargedCredits === "number" ? row.chargedCredits : null
23953
+ };
23954
+ }
23955
+ return {
23956
+ unit: "coin",
23957
+ reserved: typeof row.reservedCoins === "number" ? row.reservedCoins : null,
23958
+ charged: typeof row.chargedCoins === "number" ? row.chargedCoins : null
23959
+ };
23837
23960
  }
23838
23961
  function providerRefusalStatus(error) {
23839
23962
  const m = /^provider_http_(\d{3})$/.exec(error ?? "");
@@ -23852,7 +23975,7 @@ function formatAge(iso, now = Date.now()) {
23852
23975
  }
23853
23976
  async function runBench(args) {
23854
23977
  const { apiUrl, token, projectId, cwd, opts, log } = args;
23855
- const maxCoins = opts.maxCoins;
23978
+ const maxCredits = opts.maxCredits ?? opts.maxCoins;
23856
23979
  const samples = opts.samples ?? DEFAULT_BENCH_SAMPLES;
23857
23980
  const prompt = opts.benchPrompt.trim();
23858
23981
  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
@@ -23874,7 +23997,7 @@ async function runBench(args) {
23874
23997
  const lane = await readRuntimeLane(apiUrl, token);
23875
23998
  if (lane.state !== "live" || !lane.models || lane.models.length === 0) {
23876
23999
  if (lane.state === "off") {
23877
- if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts), status: "off", error: null });
24000
+ if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts), status: "off", error: null });
23878
24001
  else {
23879
24002
  log.plain(LLM_LANE_OFF_LINE);
23880
24003
  log.dim(" Nothing was spent.");
@@ -23901,17 +24024,27 @@ async function runBench(args) {
23901
24024
  );
23902
24025
  return;
23903
24026
  }
23904
- const balanceBefore = await readCoinBalance(apiUrl, token);
24027
+ const walletBefore = await fetchCreditsSnapshot(apiUrl, token);
24028
+ if (walletBefore?.emailVerified === false) {
24029
+ benchFailed(opts, log, LLM_BENCH_UNVERIFIED_LINE);
24030
+ return;
24031
+ }
24032
+ if (walletBefore !== null && walletBefore.spendable < maxCredits) {
24033
+ benchFailed(opts, log, benchBalanceShortLine(walletBefore.spendable, maxCredits));
24034
+ return;
24035
+ }
24036
+ const balanceBefore = walletBefore?.spendable ?? null;
23905
24037
  if (!opts.json) {
23906
24038
  log.plain(c.bold("genex llm bench"));
23907
24039
  log.dim(` ${apiUrl}`);
23908
24040
  log.plain("");
23909
24041
  log.plain(` Model ${c.cyan(modelId)}`);
23910
24042
  log.plain(` Samples ${samples} real attempt${samples === 1 ? "" : "s"}, ${outputFormat} output`);
23911
- log.plain(` Approved ${maxCoins} coin per attempt \u2014 at worst ${maxCoins * samples} coin for this run`);
24043
+ log.plain(` Approved ${maxCredits} credit${maxCredits === 1 ? "" : "s"} per attempt \u2014 at worst ${maxCredits * samples} credits for this run, from your own balance`);
23912
24044
  log.plain(
23913
- ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} coin spendable`}`
24045
+ ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} credits spendable`}`
23914
24046
  );
24047
+ log.dim(" Billed as used: each attempt is charged its real cost plus the platform fee, rounded up to a whole credit.");
23915
24048
  log.plain("");
23916
24049
  }
23917
24050
  const base = `${apiUrl}/api/runtime/development/projects/${encodeURIComponent(projectId)}/generations`;
@@ -23937,7 +24070,7 @@ async function runBench(args) {
23937
24070
  const res = await call(base, {
23938
24071
  method: "POST",
23939
24072
  body: JSON.stringify({
23940
- maxCoins,
24073
+ maxCredits,
23941
24074
  request: {
23942
24075
  idempotencyKey: `bench-${randomUUID2()}`,
23943
24076
  modelId,
@@ -23967,13 +24100,14 @@ async function runBench(args) {
23967
24100
  if (refusedAt !== null) providerRefused++;
23968
24101
  if (!opts.json) {
23969
24102
  const length = row.outputTokens !== null ? ` \xB7 ${row.outputTokens}${row.maxOutputTokens !== null ? ` of ${row.maxOutputTokens}` : ""} tokens out` : "";
24103
+ const charged = row.chargedCredits === null ? "\u2014" : String(row.chargedCredits);
23970
24104
  log.plain(
23971
- ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${String(row.chargedCoins ?? "\u2014").padStart(4)} coin charged \xB7 provider ${usd(row.costUsd)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
24105
+ ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${charged.padStart(4)} credit${row.chargedCredits === 1 ? "" : "s"} charged \xB7 cost ${usdFromPicos(row.costUsdPicos)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
23972
24106
  );
23973
24107
  if (refusedAt !== null) printProviderRefusal(log, refusedAt, row.providerMessage, row.status === "unknown");
23974
24108
  if (row.error === PROVIDER_TOKEN_LIMIT) {
23975
24109
  log.dim(
23976
- row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no price makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-coins ${maxCoins}) before it was finished \u2014 it was charged, and it is not a sample.`
24110
+ row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no ceiling makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-credits ${maxCredits}) before it was finished \u2014 it was charged, and it is not a sample.`
23977
24111
  );
23978
24112
  }
23979
24113
  }
@@ -23981,16 +24115,19 @@ async function runBench(args) {
23981
24115
  } finally {
23982
24116
  process.removeListener("SIGINT", onSigint);
23983
24117
  }
23984
- const headroomBps = lane.recommendedDeclaredHeadroomBps;
23985
- const rec = benchRecommendation(results, headroomBps);
23986
- const { charged, p50, p95, max } = rec;
23987
- const settledCount = charged.length;
23988
- const recommended = rec.recommendedEstimateCoins;
23989
- const ceiling = rec.recommendedPerCallMaxCoins;
24118
+ const terms = {
24119
+ headroomBps: lane.recommendedDeclaredHeadroomBps,
24120
+ feeBps: lane.feeBps,
24121
+ usdCentsPerCredit: lane.usdCentsPerCredit
24122
+ };
24123
+ const rec = benchRecommendation(results, terms);
24124
+ const settledCount = rec.charged.length;
23990
24125
  const lengthBlocked = rec.noRecommendationReason !== null;
23991
- const balanceAfter = await readCoinBalance(apiUrl, token);
24126
+ const balanceAfter = (await fetchCreditsSnapshot(apiUrl, token))?.spendable ?? null;
24127
+ const picosOrNull = (v) => v === null ? null : String(v);
23992
24128
  const record = {
23993
- v: 1,
24129
+ v: 2,
24130
+ currency: "credits",
23994
24131
  ranAt: (/* @__PURE__ */ new Date()).toISOString(),
23995
24132
  apiUrl,
23996
24133
  projectId,
@@ -23999,28 +24136,34 @@ async function runBench(args) {
23999
24136
  outputFormat,
24000
24137
  samples,
24001
24138
  completed: settledCount,
24002
- chargedCoins: charged,
24003
- p50,
24004
- p95,
24005
- max,
24006
- recommendedDeclaredHeadroomBps: headroomBps,
24007
- recommendedEstimateCoins: recommended,
24008
- recommendedPerCallMaxCoins: ceiling,
24009
- maxCoinsPerSample: maxCoins,
24010
- fitCoins: rec.fits,
24139
+ maxCreditsPerSample: maxCredits,
24140
+ costUsdPicos: rec.costs.map(String),
24141
+ chargedCredits: rec.charged,
24142
+ cost: { p50: picosOrNull(rec.costP50), p95: picosOrNull(rec.costP95), max: picosOrNull(rec.costMax) },
24143
+ charged: { p50: rec.chargedP50, p95: rec.chargedP95, max: rec.chargedMax },
24144
+ fitCredits: rec.fits,
24011
24145
  fitP95: rec.fitP95,
24012
- chargedBasedEstimateCoins: rec.chargedBasedEstimateCoins,
24013
- lengthRaisedPrice: rec.lengthRaisedPrice,
24146
+ fitMax: rec.fitMax,
24147
+ recommendedDeclaredHeadroomBps: terms.headroomBps,
24148
+ feeBps: terms.feeBps,
24149
+ usdCentsPerCredit: terms.usdCentsPerCredit,
24150
+ costBasedMaxCredits: rec.costBasedMaxCredits,
24151
+ recommendedMaxCredits: rec.recommendedMaxCredits,
24152
+ recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
24153
+ averageCreditsPerCall: rec.averageCreditsPerCall,
24154
+ recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
24155
+ lengthRaisedCeiling: rec.lengthRaisedCeiling,
24014
24156
  cutOffSamples: rec.cutOffSamples,
24015
24157
  noRecommendationReason: rec.noRecommendationReason
24016
24158
  };
24017
24159
  const savedTo = await saveBench(cwd, record);
24018
24160
  if (opts.json) {
24019
- const ok = charged.length > 0 && !lengthBlocked;
24161
+ const ok = settledCount > 0 && !lengthBlocked;
24020
24162
  writeJsonLine({
24021
24163
  command: "llm bench",
24022
24164
  status: ok ? "ok" : "failed",
24023
24165
  error: ok ? null : rec.noRecommendationReason ?? "no_sample_settled",
24166
+ currency: "credits",
24024
24167
  apiUrl,
24025
24168
  projectId,
24026
24169
  modelId,
@@ -24029,35 +24172,54 @@ async function runBench(args) {
24029
24172
  schemaPath: opts.schemaPath ?? null,
24030
24173
  samples,
24031
24174
  completed: settledCount,
24032
- maxCoinsPerSample: maxCoins,
24175
+ maxCreditsPerSample: maxCredits,
24176
+ // Spendable credits before and after the run; null when unreadable.
24033
24177
  balanceBefore,
24034
24178
  balanceAfter,
24035
24179
  results,
24036
24180
  // The create-time refusal that ended the run, or null. Beside the
24037
24181
  // samples, never among them: no attempt existed and nothing was spent.
24038
24182
  refusal: refusal2,
24039
- chargedCoins: { p50, p95, max },
24040
- // The answer's LENGTH, priced by the server per sample (`fitCoins`,
24183
+ // Over the settled samples: the real provider cost (pico-dollar strings
24184
+ // and USD) and the whole credits charged.
24185
+ costUsdPicos: record.cost,
24186
+ costUsd: {
24187
+ p50: rec.costP50 === null ? null : Number(rec.costP50) / 1e12,
24188
+ p95: rec.costP95 === null ? null : Number(rec.costP95) / 1e12,
24189
+ max: rec.costMax === null ? null : Number(rec.costMax) / 1e12
24190
+ },
24191
+ chargedCredits: record.charged,
24192
+ // The answer's LENGTH, priced by the server per sample (`fitCredits`,
24041
24193
  // headroom already on the tokens), over the settled samples.
24042
- fitCoins: { p95: rec.fitP95, max: rec.fitMax },
24043
- chargedBasedEstimateCoins: rec.chargedBasedEstimateCoins,
24044
- lengthRaisedPrice: rec.lengthRaisedPrice,
24194
+ fitCredits: { p95: rec.fitP95, max: rec.fitMax },
24195
+ recommendedDeclaredHeadroomBps: terms.headroomBps,
24196
+ feeBps: terms.feeBps,
24197
+ usdCentsPerCredit: terms.usdCentsPerCredit,
24198
+ missingTerms: rec.missingTerms,
24199
+ costBasedMaxCredits: rec.costBasedMaxCredits,
24200
+ lengthRaisedCeiling: rec.lengthRaisedCeiling,
24045
24201
  cutOffSamples: rec.cutOffSamples,
24046
24202
  exceedsStandCap: rec.exceedsStandCap,
24047
24203
  noRecommendationReason: rec.noRecommendationReason,
24048
- recommendedDeclaredHeadroomBps: headroomBps,
24049
- recommendedEstimateCoins: recommended,
24050
- recommendedPerCallMaxCoins: ceiling,
24204
+ recommendedMaxCredits: rec.recommendedMaxCredits,
24205
+ recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
24206
+ averageCreditsPerCall: rec.averageCreditsPerCall,
24207
+ recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
24051
24208
  savedTo
24052
24209
  });
24053
24210
  if (!ok) process.exitCode = 1;
24054
24211
  return;
24055
24212
  }
24056
24213
  log.plain("");
24214
+ const printMeasured = () => {
24215
+ log.plain(c.bold(" Real cost per call"));
24216
+ log.plain(` p50 ${usdFromPicos(rec.costP50)} p95 ${usdFromPicos(rec.costP95)} max ${usdFromPicos(rec.costMax)} (${settledCount} of ${samples} settled)`);
24217
+ log.plain(c.bold(" Charged credits"));
24218
+ log.plain(` p50 ${rec.chargedP50} p95 ${rec.chargedP95} max ${rec.chargedMax} (whole credits, rounded up per call)`);
24219
+ };
24057
24220
  if (lengthBlocked) {
24058
- if (charged.length > 0) {
24059
- log.plain(c.bold(" Charged coins"));
24060
- log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
24221
+ if (settledCount > 0) {
24222
+ printMeasured();
24061
24223
  log.plain("");
24062
24224
  }
24063
24225
  printRecommendation(log, record);
@@ -24065,17 +24227,16 @@ async function runBench(args) {
24065
24227
  process.exitCode = 1;
24066
24228
  return;
24067
24229
  }
24068
- if (charged.length === 0) {
24230
+ if (settledCount === 0) {
24069
24231
  log.error(
24070
- providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to price from." : " No sample settled, so there is nothing to price from."
24232
+ providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to measure from." : " No sample settled, so there is nothing to measure from."
24071
24233
  );
24072
24234
  process.exitCode = 1;
24073
24235
  return;
24074
24236
  }
24075
- log.plain(c.bold(" Charged coins"));
24076
- log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
24237
+ printMeasured();
24077
24238
  if (balanceBefore !== null && balanceAfter !== null) {
24078
- log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} coin spendable`);
24239
+ log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} credits spendable`);
24079
24240
  }
24080
24241
  log.plain("");
24081
24242
  printRecommendation(log, record);
@@ -24087,43 +24248,58 @@ function printRecommendation(log, record) {
24087
24248
  return;
24088
24249
  }
24089
24250
  if (record.noRecommendationReason === "answer_cut_off") {
24090
- log.warn(` ${benchCutOffLine(record.cutOffSamples ?? 1, record.maxCoinsPerSample ?? 0)}`);
24251
+ log.warn(` ${benchCutOffLine(record.cutOffSamples || 1, record.maxCreditsPerSample)}`);
24091
24252
  return;
24092
24253
  }
24093
- if (record.recommendedEstimateCoins === null || record.p95 === null) {
24094
- log.warn(" No price recommended.");
24254
+ const attempts = `${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}`;
24255
+ if (record.recommendedMaxCredits === null || record.cost.p95 === null) {
24256
+ log.warn(" No ceiling recommended.");
24095
24257
  log.dim(
24096
- record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " No sample settled, so there is no p95 to build a price on \u2014"
24258
+ record.cost.p95 === null ? " No sample settled, so there is no p95 to build a ceiling on \u2014" : record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " This stand served no platform fee or credit value, and those are the server's to set \u2014"
24097
24259
  );
24098
24260
  log.dim(" declaring a number from this run would be a guess dressed as a measurement.");
24261
+ printAverage(log, record, attempts);
24099
24262
  return;
24100
24263
  }
24101
- log.plain(c.bold(` Declare estimateCoins: ${record.recommendedEstimateCoins}`));
24102
- if (record.lengthRaisedPrice) {
24264
+ log.plain(c.bold(` Declare maxCredits: ${record.recommendedMaxCredits}`));
24265
+ if (record.lengthRaisedCeiling) {
24103
24266
  log.plain(` ${LLM_LENGTH_RAISED_LINE}`);
24104
24267
  log.dim(
24105
- ` = the smallest price whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
24268
+ ` = the smallest ceiling whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
24106
24269
  );
24107
24270
  log.dim(
24108
- ` charged-based ${record.chargedBasedEstimateCoins ?? "\u2014"} (p95 of charged coins ${record.p95} plus ${record.recommendedDeclaredHeadroomBps} bps), over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`
24271
+ ` cost-based ${record.costBasedMaxCredits ?? "\u2014"} (p95 real cost ${usdFromPicos(record.cost.p95)} with the platform fee, ${record.feeBps} bps, and ${record.recommendedDeclaredHeadroomBps} bps of headroom), over ${attempts}.`
24109
24272
  );
24110
24273
  } else {
24111
24274
  log.dim(
24112
- ` = p95 of charged coins (${record.p95}) plus this stand's recommended headroom (${record.recommendedDeclaredHeadroomBps} bps),`
24275
+ ` = p95 real cost (${usdFromPicos(record.cost.p95)}) with this stand's platform fee (${record.feeBps} bps) and recommended headroom (${record.recommendedDeclaredHeadroomBps} bps), in whole credits,`
24113
24276
  );
24114
- log.dim(` measured over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`);
24277
+ log.dim(` measured over ${attempts}.`);
24115
24278
  }
24116
- log.dim(" That number is a PRICE: a started attempt is charged in full, including one that fails.");
24117
- if (record.recommendedPerCallMaxCoins != null) {
24118
- log.plain(c.bold(` Grant perCallMaxCoins: ${record.recommendedPerCallMaxCoins}`));
24279
+ log.dim(" That number is a CEILING, not a price: a call is billed its real cost as used, never more than it \u2014");
24280
+ log.dim(" and it also sizes how long the answer may be.");
24281
+ if (record.recommendedPerCallMaxCredits !== null) {
24282
+ log.plain(c.bold(` Grant perCallMaxCredits: ${record.recommendedPerCallMaxCredits}`));
24119
24283
  log.dim(
24120
- record.lengthRaisedPrice ? ` = the same headroom over the worst sample (${record.max}) or the longest answer's price, whichever is larger, and never below the price above it.` : ` = the same headroom over the worst sample (${record.max}), and never below the price above it.`
24284
+ record.lengthRaisedCeiling ? ` = the same over the worst sample (${usdFromPicos(record.cost.max)}) or the longest answer's ceiling, whichever is larger, and never below the ceiling above it.` : ` = the same over the worst sample (${usdFromPicos(record.cost.max)}), and never below the ceiling above it.`
24121
24285
  );
24122
- log.dim(" The ceiling is the price's room to be wrong; declaring one under the price is refused.");
24286
+ }
24287
+ printAverage(log, record, attempts);
24288
+ }
24289
+ function printAverage(log, record, attempts) {
24290
+ if (record.averageCreditsPerCall === null) return;
24291
+ log.plain(` Per-call estimate: about ${formatCredits(record.averageCreditsPerCall)} credits per call`);
24292
+ log.dim(` = the average real cost with the platform fee, no headroom, over ${attempts}.`);
24293
+ if (record.recommendedPerCallEstimateCredits !== null) {
24294
+ log.plain(c.bold(` Grant perCallEstimateCredits: ${record.recommendedPerCallEstimateCredits}`));
24295
+ log.dim(` = that average rounded up to a whole credit. disclosure.estimatedCreditsPerPeriod = your calls per period \xD7 ${formatCredits(record.averageCreditsPerCall)}, rounded up.`);
24123
24296
  }
24124
24297
  }
24125
24298
  async function explainBenchRefusal(res, call, base, log, opts, index, settledSoFar) {
24126
- if (printedStructuredError(res)) return { stop: true, refusal: { status: res.status, error: null, slotsHeld: null, slotLimit: null } };
24299
+ if (printedStructuredError(res)) {
24300
+ const printed = await res.json().catch(() => ({}));
24301
+ return { stop: true, refusal: { status: res.status, error: typeof printed.error === "string" ? printed.error : null, slotsHeld: null, slotLimit: null } };
24302
+ }
24127
24303
  const body = await res.json().catch(() => ({}));
24128
24304
  const refusal2 = { status: res.status, error: body.error ?? null, slotsHeld: null, slotLimit: null };
24129
24305
  if (res.status === 429 && body.error === "generation_limit") {
@@ -24133,6 +24309,10 @@ async function explainBenchRefusal(res, call, base, log, opts, index, settledSoF
24133
24309
  if (!opts.json) log.error(` Sample ${index + 1} ${benchSlotsHeldSentence(counts)}`);
24134
24310
  return { stop: true, refusal: refusal2 };
24135
24311
  }
24312
+ if (res.status === 403 && body.error === "credits_unverified") {
24313
+ if (!opts.json) log.error(` ${LLM_BENCH_UNVERIFIED_LINE}`);
24314
+ return { stop: true, refusal: refusal2 };
24315
+ }
24136
24316
  if (opts.json) return { stop: res.status !== 429, refusal: refusal2 };
24137
24317
  if (res.status === 404 && body.error === "not_found") {
24138
24318
  log.error(` ${LLM_LANE_OFF_LINE}`);
@@ -24148,7 +24328,7 @@ async function explainBenchRefusal(res, call, base, log, opts, index, settledSoF
24148
24328
  return { stop: true, refusal: refusal2 };
24149
24329
  }
24150
24330
  if (res.status === 402) {
24151
- log.error(" Not enough coin to start the attempt.");
24331
+ log.error(" Not enough credits to start the attempt \u2014 a bench is paid from your own balance.");
24152
24332
  return { stop: true, refusal: refusal2 };
24153
24333
  }
24154
24334
  log.error(
@@ -24157,7 +24337,7 @@ async function explainBenchRefusal(res, call, base, log, opts, index, settledSoF
24157
24337
  return { stop: res.status >= 500 || res.status === 401 || res.status === 403, refusal: refusal2 };
24158
24338
  }
24159
24339
  function printProviderRefusal(log, status2, message, awaitingBill = false) {
24160
- if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so this call's coin stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
24340
+ if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so what this call reserved stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
24161
24341
  else log.dim(" The provider refused this call at its door \u2014 no inference ran, it cost nothing, and it is not a sample.");
24162
24342
  if (message) log.dim(` Provider said: ${message}`);
24163
24343
  if (status2 === 401 || status2 === 403) {
@@ -24183,19 +24363,6 @@ async function pollSettled(call, url, timeoutSec) {
24183
24363
  }
24184
24364
  return last;
24185
24365
  }
24186
- async function readCoinBalance(apiUrl, token) {
24187
- try {
24188
- const res = await apiFetch(`${apiUrl}/api/coin/balance`, {
24189
- headers: { Authorization: `Bearer ${token}` },
24190
- signal: AbortSignal.timeout(6e3)
24191
- });
24192
- if (!res.ok) return null;
24193
- const body = await res.json().catch(() => null);
24194
- return typeof body?.spendable === "number" ? body.spendable : null;
24195
- } catch {
24196
- return null;
24197
- }
24198
- }
24199
24366
  async function saveBench(cwd, record) {
24200
24367
  try {
24201
24368
  const file = path36.join(cwd, LLM_BENCH_FILE);
@@ -24211,9 +24378,10 @@ function benchFailed(opts, log, message) {
24211
24378
  else log.error(message);
24212
24379
  process.exitCode = 1;
24213
24380
  }
24214
- function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts) {
24381
+ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts) {
24215
24382
  return {
24216
24383
  command: "llm bench",
24384
+ currency: "credits",
24217
24385
  apiUrl,
24218
24386
  projectId,
24219
24387
  modelId: opts.modelId ?? null,
@@ -24222,21 +24390,28 @@ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoi
24222
24390
  schemaPath: opts.schemaPath ?? null,
24223
24391
  samples,
24224
24392
  completed: 0,
24225
- maxCoinsPerSample: maxCoins,
24393
+ maxCreditsPerSample: maxCredits,
24226
24394
  balanceBefore: null,
24227
24395
  balanceAfter: null,
24228
24396
  results: [],
24229
24397
  refusal: null,
24230
- chargedCoins: { p50: null, p95: null, max: null },
24231
- fitCoins: { p95: null, max: null },
24232
- chargedBasedEstimateCoins: null,
24233
- lengthRaisedPrice: false,
24398
+ costUsdPicos: { p50: null, p95: null, max: null },
24399
+ costUsd: { p50: null, p95: null, max: null },
24400
+ chargedCredits: { p50: null, p95: null, max: null },
24401
+ fitCredits: { p95: null, max: null },
24402
+ recommendedDeclaredHeadroomBps: null,
24403
+ feeBps: null,
24404
+ usdCentsPerCredit: null,
24405
+ missingTerms: null,
24406
+ costBasedMaxCredits: null,
24407
+ lengthRaisedCeiling: false,
24234
24408
  cutOffSamples: 0,
24235
24409
  exceedsStandCap: false,
24236
24410
  noRecommendationReason: null,
24237
- recommendedDeclaredHeadroomBps: null,
24238
- recommendedEstimateCoins: null,
24239
- recommendedPerCallMaxCoins: null,
24411
+ recommendedMaxCredits: null,
24412
+ recommendedPerCallMaxCredits: null,
24413
+ averageCreditsPerCall: null,
24414
+ recommendedPerCallEstimateCredits: null,
24240
24415
  savedTo: null
24241
24416
  };
24242
24417
  }
@@ -24299,7 +24474,8 @@ async function reportStatus(args) {
24299
24474
  for (const row of rows) {
24300
24475
  const state = rowState(row);
24301
24476
  const mark = state === "active" ? c.cyan("\u25CF") : state === "awaiting bill" ? c.yellow("\u25D0") : c.green("\u25CB");
24302
- const reserved = typeof row.reservedCoins === "number" ? `${row.reservedCoins} coin reserved` : "reserved \u2014";
24477
+ const money2 = rowMoney(row);
24478
+ const reserved = money2.reserved === null ? "reserved \u2014" : `${money2.reserved} ${money2.unit} reserved`;
24303
24479
  log.plain(
24304
24480
  ` ${mark} ${row.id ?? "?"} ${row.modelId ?? "?"} ${state.padEnd(13)} ${formatAge(row.createdAt, now).padStart(4)} old ${reserved}${row.error ? ` ${row.error}` : ""}${rowHoldsSlot(row) ? "" : c.dim(" (no slot)")}`
24305
24481
  );
@@ -24371,10 +24547,11 @@ async function cancelCall(args) {
24371
24547
  if (!opts.json) {
24372
24548
  log.warn(` ${row.id} (${row.status ?? "?"}) \u2014 ${sentence}.`);
24373
24549
  if (state === "awaiting bill") {
24374
- log.dim(` Nothing to cancel: its coin releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
24550
+ log.dim(` Nothing to cancel: what it reserved releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
24375
24551
  if (row.providerMessage?.trim()) log.dim(` Provider said: ${row.providerMessage.trim()}`);
24376
24552
  } else {
24377
- log.dim(` ${typeof row.chargedCoins === "number" ? `${row.chargedCoins} coin charged` : "charge unknown"}${row.error ? ` \xB7 ${row.error}` : ""}.`);
24553
+ const money2 = rowMoney(row);
24554
+ log.dim(` ${money2.charged === null ? "charge unknown" : `${money2.charged} ${money2.unit} charged`}${row.error ? ` \xB7 ${row.error}` : ""}.`);
24378
24555
  }
24379
24556
  }
24380
24557
  return done("ok", null, state === "awaiting bill" ? "awaiting_bill" : "already_final", row);
@@ -24396,78 +24573,126 @@ async function cancelCall(args) {
24396
24573
  const after = await res.json().catch(() => null) ?? row;
24397
24574
  if (!opts.json) {
24398
24575
  log.success(` Cancelled ${row.id} (was ${row.status ?? "active"}).`);
24399
- log.dim(` Its coin releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
24576
+ log.dim(` What it reserved releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
24400
24577
  }
24401
24578
  return done("ok", null, "cancelled", after);
24402
24579
  }
24403
- async function reportSavedPrice(cwd, opts, log) {
24404
- let record = null;
24580
+ async function readSavedBench(cwd) {
24581
+ let parsed;
24405
24582
  try {
24406
- record = JSON.parse(await fs35.readFile(path36.join(cwd, LLM_BENCH_FILE), "utf8"));
24583
+ parsed = JSON.parse(await fs35.readFile(path36.join(cwd, LLM_BENCH_FILE), "utf8"));
24407
24584
  } catch {
24408
- record = null;
24585
+ return null;
24409
24586
  }
24410
- if (!record) {
24587
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
24588
+ const v = parsed;
24589
+ if (v.v === 2 && v.currency === "credits" && v.cost && v.charged) return { kind: "credits", record: parsed };
24590
+ return { kind: "coins", record: parsed };
24591
+ }
24592
+ var LLM_PRICE_COIN_TERMS_LINE = "This bench was measured under the old COIN terms, when a game declared a fixed price per call. In-game calls are now billed in the player's credits, as used, and a game declares a per-call CEILING (maxCredits) instead \u2014 a coin price is not that number. Re-run the bench for the figures to declare.";
24593
+ async function reportSavedPrice(cwd, opts, log) {
24594
+ const saved = await readSavedBench(cwd);
24595
+ const rerun = 'genex llm bench "<prompt>" --max-credits <n> --user-approved';
24596
+ const creditKeys = (record2) => ({
24597
+ costUsdPicos: record2?.cost ?? { p50: null, p95: null, max: null },
24598
+ chargedCredits: record2?.charged ?? { p50: null, p95: null, max: null },
24599
+ fitP95: record2?.fitP95 ?? null,
24600
+ fitMax: record2?.fitMax ?? null,
24601
+ recommendedDeclaredHeadroomBps: record2?.recommendedDeclaredHeadroomBps ?? null,
24602
+ feeBps: record2?.feeBps ?? null,
24603
+ usdCentsPerCredit: record2?.usdCentsPerCredit ?? null,
24604
+ costBasedMaxCredits: record2?.costBasedMaxCredits ?? null,
24605
+ lengthRaisedCeiling: record2?.lengthRaisedCeiling ?? false,
24606
+ cutOffSamples: record2?.cutOffSamples ?? 0,
24607
+ noRecommendationReason: record2?.noRecommendationReason ?? null,
24608
+ recommendedMaxCredits: record2?.recommendedMaxCredits ?? null,
24609
+ recommendedPerCallMaxCredits: record2?.recommendedPerCallMaxCredits ?? null,
24610
+ averageCreditsPerCall: record2?.averageCreditsPerCall ?? null,
24611
+ recommendedPerCallEstimateCredits: record2?.recommendedPerCallEstimateCredits ?? null
24612
+ });
24613
+ if (!saved) {
24411
24614
  if (opts.json) {
24412
24615
  writeJsonLine({
24413
24616
  command: "llm price",
24414
24617
  status: "failed",
24415
24618
  error: "no_bench",
24619
+ currency: null,
24416
24620
  ranAt: null,
24417
24621
  modelId: null,
24418
24622
  samples: null,
24419
24623
  completed: null,
24420
- p50: null,
24421
- p95: null,
24422
- max: null,
24423
- fitP95: null,
24424
- chargedBasedEstimateCoins: null,
24425
- lengthRaisedPrice: false,
24426
- cutOffSamples: 0,
24427
- noRecommendationReason: null,
24428
- recommendedDeclaredHeadroomBps: null,
24429
- recommendedEstimateCoins: null,
24430
- recommendedPerCallMaxCoins: null,
24624
+ ...creditKeys(null),
24625
+ legacyCoinTerms: null,
24431
24626
  savedTo: null
24432
24627
  });
24433
24628
  } else {
24434
24629
  log.error("Nothing has been benchmarked in this folder yet.");
24435
- log.dim(` Run ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')} first.`);
24630
+ log.dim(` Run ${c.cyan(rerun)} first.`);
24631
+ }
24632
+ process.exitCode = 1;
24633
+ return;
24634
+ }
24635
+ if (saved.kind === "coins") {
24636
+ const old = saved.record;
24637
+ const legacy = {
24638
+ p50: old.p50 ?? null,
24639
+ p95: old.p95 ?? null,
24640
+ max: old.max ?? null,
24641
+ recommendedEstimateCoins: old.recommendedEstimateCoins ?? null,
24642
+ recommendedPerCallMaxCoins: old.recommendedPerCallMaxCoins ?? null,
24643
+ maxCoinsPerSample: old.maxCoinsPerSample ?? null,
24644
+ noRecommendationReason: old.noRecommendationReason ?? null
24645
+ };
24646
+ if (opts.json) {
24647
+ writeJsonLine({
24648
+ command: "llm price",
24649
+ status: "failed",
24650
+ error: "coin_terms",
24651
+ currency: "coins",
24652
+ ranAt: old.ranAt ?? null,
24653
+ modelId: old.modelId ?? null,
24654
+ samples: old.samples ?? null,
24655
+ completed: old.completed ?? null,
24656
+ ...creditKeys(null),
24657
+ legacyCoinTerms: legacy,
24658
+ savedTo: LLM_BENCH_FILE
24659
+ });
24660
+ } else {
24661
+ log.plain(c.bold("genex llm price"));
24662
+ log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${old.ranAt ?? "at an unknown time"}${old.modelId ? ` on ${old.modelId}` : ""}`);
24663
+ log.plain("");
24664
+ log.warn(` ${LLM_PRICE_COIN_TERMS_LINE}`);
24665
+ const measured = legacy.p95 === null ? "no settled sample" : `charged coins p50 ${legacy.p50} \xB7 p95 ${legacy.p95} \xB7 max ${legacy.max}`;
24666
+ const recommended = legacy.recommendedEstimateCoins === null ? "no price recommended" : `it recommended estimateCoins ${legacy.recommendedEstimateCoins}${legacy.recommendedPerCallMaxCoins != null ? ` and perCallMaxCoins ${legacy.recommendedPerCallMaxCoins}` : ""}`;
24667
+ log.dim(` What it measured then: ${measured}; ${recommended}.`);
24668
+ log.dim(` Re-run: ${c.cyan(rerun)}`);
24436
24669
  }
24437
24670
  process.exitCode = 1;
24438
24671
  return;
24439
24672
  }
24673
+ const record = saved.record;
24440
24674
  if (opts.json) {
24441
24675
  writeJsonLine({
24442
24676
  command: "llm price",
24443
- status: record.recommendedEstimateCoins === null ? "failed" : "ok",
24444
- error: record.recommendedEstimateCoins === null ? record.noRecommendationReason ?? "no_recommendation" : null,
24677
+ status: record.recommendedMaxCredits === null ? "failed" : "ok",
24678
+ error: record.recommendedMaxCredits === null ? record.noRecommendationReason ?? "no_recommendation" : null,
24679
+ currency: "credits",
24445
24680
  ranAt: record.ranAt ?? null,
24446
24681
  modelId: record.modelId ?? null,
24447
24682
  samples: record.samples ?? null,
24448
24683
  completed: record.completed ?? null,
24449
- p50: record.p50 ?? null,
24450
- p95: record.p95 ?? null,
24451
- max: record.max ?? null,
24452
- // Absent from a file an older CLI wrote; null / false / 0 then, never missing.
24453
- fitP95: record.fitP95 ?? null,
24454
- chargedBasedEstimateCoins: record.chargedBasedEstimateCoins ?? null,
24455
- lengthRaisedPrice: record.lengthRaisedPrice ?? false,
24456
- cutOffSamples: record.cutOffSamples ?? 0,
24457
- noRecommendationReason: record.noRecommendationReason ?? null,
24458
- recommendedDeclaredHeadroomBps: record.recommendedDeclaredHeadroomBps ?? null,
24459
- recommendedEstimateCoins: record.recommendedEstimateCoins ?? null,
24460
- recommendedPerCallMaxCoins: record.recommendedPerCallMaxCoins ?? null,
24684
+ ...creditKeys(record),
24685
+ legacyCoinTerms: null,
24461
24686
  savedTo: LLM_BENCH_FILE
24462
24687
  });
24463
- if (record.recommendedEstimateCoins === null) process.exitCode = 1;
24688
+ if (record.recommendedMaxCredits === null) process.exitCode = 1;
24464
24689
  return;
24465
24690
  }
24466
24691
  log.plain(c.bold("genex llm price"));
24467
24692
  log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${record.ranAt}`);
24468
24693
  log.plain("");
24469
24694
  printRecommendation(log, record);
24470
- if (record.recommendedEstimateCoins === null) process.exitCode = 1;
24695
+ if (record.recommendedMaxCredits === null) process.exitCode = 1;
24471
24696
  }
24472
24697
  function writeJsonLine(value) {
24473
24698
  process.stdout.write(`${JSON.stringify(value)}
@@ -24727,7 +24952,7 @@ function runtimeRow(state, lane, toolsOnly = false) {
24727
24952
  return {
24728
24953
  label: "Runtime",
24729
24954
  value: `in-game LLM lane live \xB7 ${n} model${n === 1 ? "" : "s"}${keyRefused ? ` \xB7 ${c.yellow("provider key refused")}` : ""}`,
24730
- fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Price a call before a game declares one: `npx genex llm bench "<prompt>" --max-coins <n> --user-approved`.'
24955
+ fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Measure a call before a game declares its per-call ceiling: `npx genex llm bench "<prompt>" --max-credits <n> --user-approved`.'
24731
24956
  };
24732
24957
  }
24733
24958
  case "off":
@@ -26683,16 +26908,16 @@ ${c.bold("Usage")}
26683
26908
  status | cancel. "models" says whether this
26684
26909
  stand serves the runtime LLM lane at all, and
26685
26910
  at what provider rates. "bench" runs the real
26686
- model on YOUR OWN coin and reports what each
26687
- attempt was charged; it needs --max-coins <n>
26911
+ model on YOUR OWN credits and reports what each
26912
+ attempt really cost; it needs --max-credits <n>
26688
26913
  --user-approved, like any spend the player has
26689
26914
  to agree to. "price" reprints the last run's
26690
- recommended estimateCoins. "status" lists this
26915
+ recommended maxCredits. "status" lists this
26691
26916
  project's open calls and the slots they hold;
26692
26917
  "cancel <id>" stops an active one. Declare a
26693
- game's price from a bench, never from a guess:
26694
- a started attempt is charged in full, including
26695
- one that fails.
26918
+ game's per-call ceiling from a bench, never
26919
+ from a guess: the player is billed as used, and
26920
+ the ceiling also sizes how long an answer may be.
26696
26921
  genex player <sub> [options] Answer the in-game model requests you approve, on your
26697
26922
  OWN Claude or ChatGPT subscription: install | run |
26698
26923
  status | stop | uninstall. "install" signs this machine
@@ -27147,7 +27372,7 @@ ${c.bold("Examples")}
27147
27372
  genex shop remove sku_123
27148
27373
  genex llm models
27149
27374
  genex llm models --all
27150
- genex llm bench "Reply with one short taunt." --samples 5 --max-coins 20 --user-approved
27375
+ genex llm bench "Reply with one short taunt." --samples 5 --max-credits 20 --user-approved
27151
27376
  genex llm price
27152
27377
  genex llm status
27153
27378
  genex llm cancel <id>
@@ -27384,22 +27609,24 @@ ${c.bold("How the allowance works")}
27384
27609
 
27385
27610
  Every number printed is live from your account; prices can change without a deploy.
27386
27611
  `,
27387
- llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what should the game declare?
27612
+ llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what ceiling should the game declare?
27388
27613
 
27389
27614
  ${c.bold("Usage")}
27390
27615
  genex llm models [--all] [--json] Which models this stand serves, their provider rates per
27391
- million tokens, and the headroom it recommends over a
27392
- benchmark. The catalog is synced from OpenRouter, so the
27393
- FEATURED short list is printed by default and --all
27394
- prints every row (--json always carries every row). Off
27395
- on this stand: it says so and exits 0 \u2014 build the feature
27396
- without a model rather than promising a 404.
27397
- genex llm bench "<prompt>" --max-coins <n> --user-approved [options]
27398
- Run the real model <n> times through the development
27399
- lane and report what each attempt was CHARGED. Refused
27400
- without both flags, before any network call: this spends
27401
- YOUR OWN COIN, a wallet the build's asset allowance
27402
- cannot see. Needs a folder linked to a game you own.
27616
+ million tokens, the platform fee a call is billed at, and
27617
+ the headroom it recommends over a benchmark. The catalog
27618
+ is synced from OpenRouter, so the FEATURED short list is
27619
+ printed by default and --all prints every row (--json
27620
+ always carries every row). Off on this stand: it says so
27621
+ and exits 0 \u2014 build the feature without a model rather
27622
+ than promising a 404.
27623
+ genex llm bench "<prompt>" --max-credits <n> --user-approved [options]
27624
+ Run the real model through the development lane and
27625
+ report what each attempt really COST and was charged.
27626
+ Refused without both flags, before any network call:
27627
+ this spends YOUR OWN CREDITS, a spend the build's asset
27628
+ allowance does not count. Needs a folder linked to a
27629
+ game you own, and a balance that covers one attempt.
27403
27630
  genex llm price [--json] The recommendation from the last bench in this folder.
27404
27631
  Reads a file; spends nothing.
27405
27632
  genex llm status [--json] This project's open development calls: active, or
@@ -27416,29 +27643,32 @@ ${c.bold("Bench options")}
27416
27643
  --samples <n> How many real attempts (default ${DEFAULT_BENCH_SAMPLES}). More samples, better p95.
27417
27644
  --schema <file> A JSON file holding the output schema; implies JSON output.
27418
27645
  --json-output Ask for JSON without a schema. --text Ask for text (the default).
27419
- --max-coins <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own coin,
27646
+ --max-credits <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own credits,
27420
27647
  never a player's. The run's worst case is that number times --samples,
27421
- and it is printed before anything starts.
27648
+ and it is printed before anything starts. (--max-coins is its deprecated
27649
+ name, read as the same number.)
27422
27650
  --timeout <seconds> Bounds the wait for each attempt.
27423
27651
  --json One machine-readable object; every key is always present, null when
27424
27652
  the CLI could not learn it.
27425
27653
 
27426
27654
  ${c.bold("Why a benchmark and not an estimate")}
27427
- \`estimateCoins\` is a PRICE, not a guess: declaring it charges it, in full, even when the
27428
- attempt fails, is cancelled, or stops at its budget. And three layers sit between the
27429
- provider's rate card and that number \u2014 the provider's cost, the platform's tariff (already
27430
- inside every charged coin), and your headroom. The bench itself is billed to YOU, on the
27431
- development lane; the player-funded lane is what the published game uses. Adding a margin
27432
- to the provider's rate silently drops the tariff and under-prices every call, so the
27433
- recommendation is built from CHARGED COINS and the headroom comes from the server. A stand
27434
- that serves no headroom gets no recommendation, because the multiplier is not the CLI's to
27435
- invent.
27655
+ An in-game call is paid from the PLAYER's credits and billed as used: its real provider cost
27656
+ plus the platform fee, never more than the per-call CEILING the game declares (\`maxCredits\`,
27657
+ or \`perCallMaxCredits\` on a standing budget). The ceiling is not a price, but it is still a
27658
+ number with two jobs: it bounds what one call may cost, and it funds how LONG the answer may
27659
+ be. Three layers sit between the provider's rate card and it \u2014 the provider's cost for YOUR
27660
+ prompt, the platform fee, and your headroom \u2014 and only a real call measures the first. The
27661
+ bench is billed to YOU, on the development lane; the player-funded lane is what the
27662
+ published game uses. The recommendation is the p95 real cost with the server's fee and
27663
+ headroom applied, in whole credits; a stand that serves no headroom or no fee gets no
27664
+ recommendation, because those multipliers are not the CLI's to invent.
27436
27665
 
27437
- The declared price also decides how LONG the answer may be: it sizes each call's output
27438
- allowance. So the recommendation is never below the smallest price that leaves room for
27439
- the benchmarked answer, as the server computes it. A sample cut off at your --max-coins
27440
- (provider_token_limit) is not a sample \u2014 re-run with a higher --max-coins; an answer longer
27441
- than one call on this stand may produce gets no price at all \u2014 ask for a shorter one.
27666
+ Because the ceiling sizes each call's output allowance, the recommendation is never below
27667
+ the smallest ceiling that leaves room for the benchmarked answer, as the server computes it.
27668
+ A sample cut off at your --max-credits (provider_token_limit) is not a sample \u2014 re-run with a
27669
+ higher --max-credits; an answer longer than one call on this stand may produce gets no
27670
+ ceiling at all \u2014 ask for a shorter one. The run also prints the measured average per call,
27671
+ fee included, which is the honest estimate a budget's disclosure is built on.
27442
27672
 
27443
27673
  Results land in ${LLM_BENCH_FILE} \u2014 gitignored, machine-local, re-read by \`genex llm price\`.
27444
27674
  `,
@@ -27653,11 +27883,13 @@ function parseArgs(argv) {
27653
27883
  "--limit",
27654
27884
  // `genex budget --assets <credits>` — the allowance the player approved.
27655
27885
  "--assets",
27656
- // `genex llm bench` value flags. `--max-coins` is the COIN half of the
27657
- // same approval shape as `--assets` (a different economy, its own gate).
27886
+ // `genex llm bench` value flags. `--max-credits` is the same approval
27887
+ // shape as `--assets`, but its own gate: the asset allowance does not
27888
+ // count a bench. `--max-coins` is its deprecated name (same number).
27658
27889
  "--model",
27659
27890
  "--samples",
27660
27891
  "--schema",
27892
+ "--max-credits",
27661
27893
  "--max-coins",
27662
27894
  // `genex player install --label <name>` — what this machine is called in
27663
27895
  // the dashboard. Its own key rather than the generic 2nd positional,
@@ -28172,12 +28404,14 @@ function applyValueFlag(options, flag, value) {
28172
28404
  options.samples = n;
28173
28405
  break;
28174
28406
  }
28407
+ case "--max-credits":
28175
28408
  case "--max-coins": {
28176
28409
  const n = Number(value);
28177
28410
  if (!Number.isInteger(n) || n < 1) {
28178
- throw new Error(`Invalid --max-coins value: ${value} (whole coin, 1 or more)`);
28411
+ throw new Error(`Invalid ${flag} value: ${value} (whole credits, 1 or more)`);
28179
28412
  }
28180
- options.maxCoins = n;
28413
+ if (flag === "--max-credits") options.maxCredits = n;
28414
+ else options.maxCoins = n;
28181
28415
  break;
28182
28416
  }
28183
28417
  case "--timeout": {
@@ -28469,9 +28703,9 @@ async function main() {
28469
28703
  await runBudget(parsed.options);
28470
28704
  break;
28471
28705
  // The in-game model lane: is it live here, what does one call really
28472
- // cost, what should the game declare. `bench` is the only CLI command
28473
- // that spends COIN, so it carries its own --max-coins/--user-approved
28474
- // gate — the credits allowance cannot see that wallet.
28706
+ // cost, what ceiling should the game declare. `bench` spends the
28707
+ // builder's own credits OUTSIDE the asset allowance, so it carries its
28708
+ // own --max-credits/--user-approved gate.
28475
28709
  case "llm":
28476
28710
  await runLlm(parsed.options);
28477
28711
  break;