luxin-cli 0.2.4 → 0.2.6

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/bin/luxin.mjs +210 -32
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ provenance; this file is the human- and agent-readable release map.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.2.6 - 2026-08-09
10
+
11
+ - Release (trust): `create --guide` now says when it cannot price the request it
12
+ is planning, instead of presenting the model's default-shape quote as the
13
+ planned debit. `selection.planned_debit_source` reports which of the two you
14
+ got, and the unpriceable case carries a warning pointing at the dry-run.
15
+ - This closes a gap that was invisible rather than wrong: the hosted API
16
+ published per-aspect-ratio create debits only when they differed from the
17
+ quote, so an absent field could mean either "the quote already is the debit"
18
+ or "this server is older than your CLI". The npm package and the hosted API
19
+ deploy independently, so that second case is permanent, not a one-off, and it
20
+ under-quoted silently during any deploy gap or rollback. The API now publishes
21
+ the debit for every create-capable model (107 of 107 live rows), so absence
22
+ means exactly one thing, and the CLI now treats it that way.
23
+ - Carries the correction to the 0.2.5 note below: the "known issue" that shipped
24
+ in the 0.2.5 tarball describes behaviour that does not exist.
25
+
26
+ ## 0.2.5 - 2026-08-09
27
+
28
+ - Release (correctness): `edit --guide` selects a model again. It gated on
29
+ `media.input.images.required`, which the hosted `/v1/models` summary does not
30
+ publish, so on 0.2.4 every edit brief returned `no_executable_model` and the
31
+ blocker named `create`. The predicate now reads `supports`, which the summary
32
+ does publish; measured on the live 220-model catalog the two express the same
33
+ 79-model set, so this drops a redundant conjunct rather than a guarantee. The
34
+ blocker also names the operation it actually failed on.
35
+ - Release (correctness): a plain video brief reports its aspect ratio again.
36
+ `selection.suggested_aspect_ratio` read the same unpublished block and
37
+ returned null on every real video call unless the brief happened to name a
38
+ ratio.
39
+ - Correction (added after 0.2.5 shipped): the 0.2.5 tarball carries a "known
40
+ issue" note claiming `create --guide` can quote a different credit figure than
41
+ the `--dry-run` it hands you. **That note is wrong and the behaviour it
42
+ describes does not exist.** It was written from a measurement taken during a
43
+ deploy-skew window, minutes after the fix merged and before the hosted API had
44
+ deployed its half. Measured after both halves landed, the guide and the dry-run
45
+ agree: 5 and 5 with no ratio stated, 3 and 3 at 16:9. #2259 is fixed and
46
+ closed. Budgeting from the dry-run figure remains sound advice, but it is not a
47
+ workaround for anything.
48
+
9
49
  ## 0.2.4 - 2026-08-09
10
50
 
11
51
  - Release (correctness): the guide honors an aspect ratio stated in the brief
package/bin/luxin.mjs CHANGED
@@ -15,7 +15,7 @@ import { Readable } from "node:stream";
15
15
  import { pipeline } from "node:stream/promises";
16
16
  import os from "node:os";
17
17
 
18
- const VERSION = "0.2.4";
18
+ const VERSION = "0.2.6";
19
19
  const PACKAGE_NAME = "luxin";
20
20
  const DEFAULT_API_BASE_URL = "https://api.luxin.sh";
21
21
  const DEFAULT_DOCS_BASE_URL = "https://luxin.sh";
@@ -1933,7 +1933,16 @@ async function createGuide(args, options = {}) {
1933
1933
  requestedAspectRatio: briefAspectRatio,
1934
1934
  });
1935
1935
  let selection = selectModel(listedModels);
1936
- if (briefAspectRatio !== null && listedModels !== null) {
1936
+ // The compact hosted list omits `media.input.aspect_ratios` entirely, so the
1937
+ // ratio capability can only come from the detail endpoint. Probe when the
1938
+ // brief named a ratio (selection itself has to be capability-aware) and also
1939
+ // when the pick is a video model: `selection.suggested_aspect_ratio` is
1940
+ // video-only, and without the probe it reported null on every real video
1941
+ // brief that did not state a ratio -- the same
1942
+ // published-payload-omits-the-field defect as #2265, one field over.
1943
+ const needsAspectRatioCapabilities =
1944
+ briefAspectRatio !== null || selection?.model?.modality === "video";
1945
+ if (needsAspectRatioCapabilities && listedModels !== null) {
1937
1946
  // Two-pass: the first pass names the models worth asking about, then one
1938
1947
  // bounded round of detail lookups makes the second pass actually
1939
1948
  // capability-aware instead of guessing.
@@ -1943,6 +1952,7 @@ async function createGuide(args, options = {}) {
1943
1952
  models: listedModels,
1944
1953
  selected: selection?.model ?? null,
1945
1954
  intent: requestedIntent,
1955
+ routing: briefAspectRatio !== null,
1946
1956
  }),
1947
1957
  apiBaseUrl,
1948
1958
  });
@@ -1983,22 +1993,30 @@ async function createGuide(args, options = {}) {
1983
1993
  modelParameters: modelParameters.value ?? {},
1984
1994
  maxEstimatedUsdPerImage,
1985
1995
  });
1986
- const shouldPriceModelParameters =
1996
+ // Price the request the guide is about to emit, at the ratio it will emit it
1997
+ // with -- not the registry's default-shape quote, which describes a request
1998
+ // `create` never makes (#2259). Models priced from input-image dimensions stay
1999
+ // on the quote: the guide uploads nothing, so there are no dimensions to price
2000
+ // from. `tests/hosted-create-estimate-parity.test.ts` pins this against the
2001
+ // source dry-run so the two implementations cannot drift apart again.
2002
+ const pricing =
2003
+ selected === null || createGuideSelectedModelRequiresInputImage(selected)
2004
+ ? createGuideModelCreditPricing(selected)
2005
+ : createGuidePricingForModel(
2006
+ selected,
2007
+ defaultedModelParameters.modelParameters,
2008
+ pricingContext,
2009
+ );
2010
+ // The quote is not the debit, and against a server that does not publish
2011
+ // debits this CLI cannot compute one -- it has no pricing engine, by design.
2012
+ // Say that plainly instead of letting the caller budget against a number the
2013
+ // product does not charge (#2259, #2272).
2014
+ const plannedDebitUnavailable =
1987
2015
  selected !== null &&
2016
+ createGuideModelSupportsCreate(selected) &&
1988
2017
  !createGuideSelectedModelRequiresInputImage(selected) &&
1989
- createGuideCanPriceModelParameters(selected) &&
1990
- (defaultedModelParameters.defaultsApplied.length > 0 ||
1991
- Object.keys(modelParameters.value ?? {}).length > 0);
1992
- const pricing =
1993
- selected === null
1994
- ? null
1995
- : shouldPriceModelParameters
1996
- ? createGuidePricingForModel(
1997
- selected,
1998
- defaultedModelParameters.modelParameters,
1999
- pricingContext,
2000
- )
2001
- : createGuideModelCreditPricing(selected);
2018
+ !createGuideCanPriceModelParameters(selected) &&
2019
+ !createGuideRegistryPublishesCreditDebit(selected);
2002
2020
  const estimatedCredits = pricing?.credits_required ?? null;
2003
2021
  // The guide publishes what the caller will be charged, never our provider
2004
2022
  // cost. `economics.estimated_usd_per_image` is the hosted charge per image.
@@ -2057,6 +2075,7 @@ async function createGuide(args, options = {}) {
2057
2075
  guideCommandPrefix,
2058
2076
  );
2059
2077
  const blocker = createGuideBlocker(stage, {
2078
+ operation: guideOperation,
2060
2079
  requestedModelId,
2061
2080
  quota,
2062
2081
  estimatedCredits,
@@ -2397,6 +2416,9 @@ async function createGuide(args, options = {}) {
2397
2416
  pricing_source: pricing?.pricing_source ?? null,
2398
2417
  model_parameter_defaults_applied:
2399
2418
  defaultedModelParameters.defaultsApplied,
2419
+ planned_debit_source: plannedDebitUnavailable
2420
+ ? "create_credit_debit_unavailable"
2421
+ : "registry_create_credit_debit",
2400
2422
  },
2401
2423
  blocker,
2402
2424
  guide_warning: guideWarning,
@@ -2434,11 +2456,20 @@ async function createGuide(args, options = {}) {
2434
2456
  for (const field of compactOmittedFields) {
2435
2457
  delete guideData[field];
2436
2458
  }
2437
- return createGuideSuccess(command, quota?.envelope.actor ?? null, guideData);
2459
+ return createGuideSuccess(
2460
+ command,
2461
+ quota?.envelope.actor ?? null,
2462
+ guideData,
2463
+ plannedDebitUnavailable
2464
+ ? [
2465
+ "create_credit_debit_unavailable: this hosted API does not publish per-aspect-ratio create debits, so the credits above are the model's default-shape quote and may not be what create debits. Run the dry_run command in escape_hatches for the exact figure before budgeting.",
2466
+ ]
2467
+ : [],
2468
+ );
2438
2469
  }
2439
2470
 
2440
- function createGuideSuccess(command, actor, data) {
2441
- const result = success(command, data);
2471
+ function createGuideSuccess(command, actor, data, warnings = []) {
2472
+ const result = success(command, data, warnings);
2442
2473
  result.envelope.actor = actor;
2443
2474
  return result;
2444
2475
  }
@@ -2467,8 +2498,6 @@ function selectCreateGuideModel(
2467
2498
  const isExecutableInputImageEdit = (model) =>
2468
2499
  model?.status === "available" &&
2469
2500
  guideModelExecutionStatus(model) === "executable" &&
2470
- Array.isArray(model?.supports) &&
2471
- (model.supports.includes("edit") || model.supports.includes("variation")) &&
2472
2501
  createGuideSelectedModelRequiresInputImage(model);
2473
2502
  const isExecutableGuideModel = (model) =>
2474
2503
  operation === "edit"
@@ -2585,16 +2614,21 @@ function selectCreateGuideModel(
2585
2614
  // capability UNKNOWN, which never disqualifies a model and never triggers the
2586
2615
  // unsupported-ratio warning.
2587
2616
 
2588
- function guideAspectRatioProbeModelIds({ models, selected, intent }) {
2617
+ function guideAspectRatioProbeModelIds({ models, selected, intent, routing }) {
2589
2618
  const ids = [];
2590
2619
  if (typeof selected?.id === "string") {
2591
2620
  ids.push(selected.id);
2592
2621
  }
2593
- for (const modelId of preferredCreateGuideModelIds(
2594
- createGuideIntentClass(intent),
2595
- )) {
2596
- if (models.some((model) => model?.id === modelId)) {
2597
- ids.push(modelId);
2622
+ // Only a stated ratio can make the guide hop between models. Without one the
2623
+ // sole open question is what the current pick can express, so asking about
2624
+ // the alternatives would be a round trip spent on an answer nothing reads.
2625
+ if (routing) {
2626
+ for (const modelId of preferredCreateGuideModelIds(
2627
+ createGuideIntentClass(intent),
2628
+ )) {
2629
+ if (models.some((model) => model?.id === modelId)) {
2630
+ ids.push(modelId);
2631
+ }
2598
2632
  }
2599
2633
  }
2600
2634
  return [...new Set(ids)].slice(0, GUIDE_ASPECT_RATIO_PROBE_LIMIT);
@@ -2939,7 +2973,69 @@ function createGuidePricingForModel(model, modelParameters, context = {}) {
2939
2973
  if (createGuideCanPriceModelParameters(model)) {
2940
2974
  return createGuideXaiImagePricing(model, modelParameters, context);
2941
2975
  }
2942
- return createGuideModelCreditPricing(model);
2976
+ return (
2977
+ createGuideAspectRatioCreditPricing(model, context) ??
2978
+ createGuideModelCreditPricing(model)
2979
+ );
2980
+ }
2981
+
2982
+ // The debit `create` will actually make at the ratio the guide is about to
2983
+ // emit, read from the model registry (#2259).
2984
+ //
2985
+ // `economics.credit_pricing` is the quote at the model's own default shape --
2986
+ // no aspect ratio, no policy model-parameter defaults. `create` never sends
2987
+ // that request: it always sends a concrete ratio (`1:1` unless the brief asked
2988
+ // otherwise) plus the hosted defaults. For any model priced from its output
2989
+ // shape or from a defaulted parameter the quote is therefore not the debit, and
2990
+ // this CLI has no pricing engine to recompute it -- so the registry publishes
2991
+ // the answer and this reads it. Absent field means the quote already is the
2992
+ // debit.
2993
+ // Does this registry response publish create debits at all?
2994
+ //
2995
+ // The hosted API and this CLI ship independently, so a client can always be
2996
+ // talking to a server older than itself. `create_credit_debit_by_aspect_ratio`
2997
+ // is published for EVERY create-capable model precisely so that absence has one
2998
+ // meaning -- "this server cannot tell me the debit" -- rather than being
2999
+ // confusable with "the debit happens to equal the quote". When it is absent the
3000
+ // honest answer is to say so and point at the dry-run, not to present the
3001
+ // model's default-shape quote as the planned debit (#2259, #2272).
3002
+ function createGuideRegistryPublishesCreditDebit(model) {
3003
+ return (
3004
+ isRecord(model?.economics?.create_credit_debit_by_aspect_ratio) ||
3005
+ isRecord(model?.create_credit_debit_by_aspect_ratio)
3006
+ );
3007
+ }
3008
+
3009
+ function createGuideModelSupportsCreate(model) {
3010
+ return Array.isArray(model?.supports) && model.supports.includes("create");
3011
+ }
3012
+
3013
+ function createGuideAspectRatioCreditPricing(model, context) {
3014
+ // Both shapes: `GET /v1/models` serves the COMPACT summary, which flattens
3015
+ // economics onto the row, and only `?details=true` (or a fixture built from
3016
+ // the full catalog) nests it. Reading only the nested one is how a fix passes
3017
+ // its own test against a payload production never sends.
3018
+ const byAspectRatio = isRecord(
3019
+ model?.economics?.create_credit_debit_by_aspect_ratio,
3020
+ )
3021
+ ? model.economics.create_credit_debit_by_aspect_ratio
3022
+ : model?.create_credit_debit_by_aspect_ratio;
3023
+ const aspectRatio = context?.aspectRatio;
3024
+ if (!isRecord(byAspectRatio) || typeof aspectRatio !== "string") {
3025
+ return null;
3026
+ }
3027
+ const credits = byAspectRatio[aspectRatio];
3028
+ if (!Number.isFinite(credits)) {
3029
+ return null;
3030
+ }
3031
+ const quoted = createGuideModelCreditPricing(model);
3032
+ return {
3033
+ ...(quoted ?? {}),
3034
+ credits_required: credits,
3035
+ credit_unit_usd: CREDIT_UNIT_USD,
3036
+ estimated_charge_usd: roundUsd(credits * CREDIT_UNIT_USD),
3037
+ pricing_confidence: quoted?.pricing_confidence ?? "known",
3038
+ };
2943
3039
  }
2944
3040
 
2945
3041
  function createGuideCanPriceModelParameters(model) {
@@ -3017,6 +3113,43 @@ function roundUsdMicro(value) {
3017
3113
  return Math.round(value * 1_000_000) / 1_000_000;
3018
3114
  }
3019
3115
 
3116
+ // ---------------------------------------------------------------------------
3117
+ // How the guide reads a model field. Read this before adding an accessor.
3118
+ //
3119
+ // There is NO normalizer. `createGuide` hands the rows from `GET /v1/models`
3120
+ // to these accessors verbatim -- `withModelSummary` is the `models list`
3121
+ // command's compactor and never runs on this path. So an accessor here has to
3122
+ // cope with BOTH published shapes itself:
3123
+ //
3124
+ // GET /v1/models (compact list) flat: model_execution_status,
3125
+ // credits_required, estimated_usd_per_image,
3126
+ // max_outputs_per_request, artifact_storage.
3127
+ // NO `media`, `execution`, or `economics`.
3128
+ // GET /v1/models/:id (detail) nested under execution/economics/media.
3129
+ //
3130
+ // The convention is therefore NESTED-OR-FLAT: try the detail shape, fall back
3131
+ // to the flat field the list actually publishes. That convention is the whole
3132
+ // safety property, and #2265 was one accessor quietly breaking it.
3133
+ // `createGuideSelectedModelRequiresInputImage` read
3134
+ // `media.input.images.required` with a "fallback" of `accepts_input_images` --
3135
+ // a field the API has never published -- so against a real list row it had no
3136
+ // fallback at all, and it failed closed.
3137
+ //
3138
+ // guideModelExecutionStatus execution.model_execution_status -> flat
3139
+ // createGuideModelCreditPricing economics.credit_pricing -> flat
3140
+ // guideBudgetUsdForModel economics.estimated_usd_per_image -> flat
3141
+ // guideModelAspectRatioValues media.input.aspect_ratios.values -> none;
3142
+ // fails open and is hydrated from the detail
3143
+ // endpoint on demand (see the note there)
3144
+ // ...SelectedModelRequiresInputImage reads `supports`, published on the list
3145
+ //
3146
+ // `max_outputs_per_request` and `artifact_storage` are published flat as well,
3147
+ // but nothing on the guide path reads them. Their only nested reads live in
3148
+ // `modelSummaryRow`/`modelSummaryTaskTags`, which `withModelSummary` skips
3149
+ // outright for a payload already marked `compact_model_summary` -- which the
3150
+ // hosted list is. Those eight `media.*` reads therefore never see a list row.
3151
+ // ---------------------------------------------------------------------------
3152
+
3020
3153
  function guideModelExecutionStatus(model) {
3021
3154
  if (
3022
3155
  isRecord(model?.execution) &&
@@ -3133,6 +3266,26 @@ function requestedAspectRatioFromBrief(input) {
3133
3266
  // say. The hosted `/v1/models` list is a compact summary and omits the `media`
3134
3267
  // block entirely, so "not published" is the common case here and must be kept
3135
3268
  // distinct from "published and does not include this ratio".
3269
+ //
3270
+ // #2265, worth stating because the next reader will assume otherwise: this is
3271
+ // ONE OF EXACTLY TWO `media.*` reads that run over the LIST payload, and the
3272
+ // two behave OPPOSITELY. `GET /v1/models/:id` does publish `media`; the compact
3273
+ // list does not. So a `media.*` read is only safe here if it either fails open
3274
+ // or arranges to be hydrated from the detail endpoint first.
3275
+ //
3276
+ // - This one FAILS OPEN. Unknown never disqualifies a model, so an unhydrated
3277
+ // read costs a hint, not a decision. That is why capability routing kept
3278
+ // working in production while `edit --guide` was dead.
3279
+ // - `createGuideSelectedModelRequiresInputImage` used to FAIL CLOSED: no
3280
+ // `media` meant no model could prove it accepts an input image, so `edit
3281
+ // --guide` selected nothing on every real call. It no longer reads `media`
3282
+ // at all -- see the note there.
3283
+ //
3284
+ // Failing open is not the same as being correct. `guideModelSuggestedAspectRatio`
3285
+ // consumes this to REPORT a value rather than to gate one, so before the probe
3286
+ // was extended to video picks it published `suggested_aspect_ratio: null` on
3287
+ // every video brief that did not state a ratio -- silently, since no blocker
3288
+ // fires. Fail-open degrades quietly; that is its failure mode, not its absence.
3136
3289
  function guideModelAspectRatioValues(model) {
3137
3290
  const values = Array.isArray(model?.media?.input?.aspect_ratios?.values)
3138
3291
  ? model.media.input.aspect_ratios.values
@@ -3197,10 +3350,31 @@ function createGuideSuggestedAspectRatio(input) {
3197
3350
  return values.includes("16:9") ? "16:9" : (values[0] ?? null);
3198
3351
  }
3199
3352
 
3353
+ // #2265: this must decide from what the hosted payload actually publishes.
3354
+ // `GET /v1/models` returns the compact summary, which omits the `media` block
3355
+ // entirely (and has never published `accepts_input_images`), so gating on
3356
+ // `media.input.images.required` disqualified every model on every real call and
3357
+ // `edit --guide` could not select one, ever.
3358
+ //
3359
+ // This read FAILED CLOSED, which is why it was catastrophic rather than merely
3360
+ // lossy: an absent field could not prove any model accepts an input image, so
3361
+ // the guide selected nothing and reported `no_executable_model`. The other
3362
+ // list-payload `media.*` read (`guideModelAspectRatioValues`) fails open and
3363
+ // only loses a hint. Do not assume the two are interchangeable -- see the note
3364
+ // there before adding a third.
3365
+ //
3366
+ // `supports` is published and carries the same fact: it is derived from the
3367
+ // capability's operations (`image.edit` -> "edit", `image.variation` ->
3368
+ // "variation"), and both are input-image operations by construction. On the
3369
+ // live 220-model catalog the old conjunction and this predicate select the
3370
+ // identical 79 models, so the dropped conjunct was doing no work -- while
3371
+ // costing the mirror the ability to decide at all.
3372
+ //
3373
+ // Keep this reading only summary fields. `src/` holds strictly more model data
3374
+ // than the mirror can ever see; every extra field the guide reads there is a
3375
+ // fresh chance for the two surfaces to decide differently.
3200
3376
  function createGuideSelectedModelRequiresInputImage(model) {
3201
3377
  return (
3202
- (model?.media?.input?.images?.required === true ||
3203
- model?.accepts_input_images === true) &&
3204
3378
  Array.isArray(model?.supports) &&
3205
3379
  (model.supports.includes("edit") || model.supports.includes("variation"))
3206
3380
  );
@@ -3891,12 +4065,16 @@ function createGuideBlocker(stage, input) {
3891
4065
  };
3892
4066
  }
3893
4067
  if (stage === "no_executable_model") {
4068
+ // #2265: name the operation that actually failed. Saying "create" when the
4069
+ // caller ran `edit --guide` made the one diagnostic an agent gets point at
4070
+ // the wrong half of the product.
4071
+ const operation = input.operation === "edit" ? "edit" : "create";
3894
4072
  return {
3895
4073
  code: "no_executable_model",
3896
4074
  message:
3897
4075
  input.requestedModelId === null
3898
- ? "No available executable create model was found in the public registry."
3899
- : `Requested model is not currently an available executable create model: ${input.requestedModelId}`,
4076
+ ? `No available executable ${operation} model was found in the public registry.`
4077
+ : `Requested model is not currently an available executable ${operation} model: ${input.requestedModelId}`,
3900
4078
  };
3901
4079
  }
3902
4080
  if (stage === "auth_required") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luxin-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Image generation, image editing, video, audio, and image-to-3D for agents, with no API key to obtain first. The agent signs itself up, starts on free preview credits, and gets hosted media URLs, recoverable jobs, and cost receipts.",
5
5
  "type": "module",
6
6
  "private": false,