luxin-cli 0.2.4 → 0.2.5

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 +20 -0
  2. package/bin/luxin.mjs +166 -30
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ provenance; this file is the human- and agent-readable release map.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.2.5 - 2026-08-09
10
+
11
+ - Release (correctness): `edit --guide` selects a model again. It gated on
12
+ `media.input.images.required`, which the hosted `/v1/models` summary does not
13
+ publish, so on 0.2.4 every edit brief returned `no_executable_model` and the
14
+ blocker named `create`. The predicate now reads `supports`, which the summary
15
+ does publish; measured on the live 220-model catalog the two express the same
16
+ 79-model set, so this drops a redundant conjunct rather than a guarantee. The
17
+ blocker also names the operation it actually failed on.
18
+ - Release (correctness): a plain video brief reports its aspect ratio again.
19
+ `selection.suggested_aspect_ratio` read the same unpublished block and
20
+ returned null on every real video call unless the brief happened to name a
21
+ ratio.
22
+ - Known issue (#2259): `create --guide` can quote a different credit figure than
23
+ the `--dry-run` it hands you, because it prices the model's catalog default
24
+ shape rather than the ratio the command actually sends. Measured against the
25
+ live API: 4 vs 5 with no ratio stated, 4 vs 3 at 16:9. The dry-run figure is
26
+ the authoritative one; budget from it, not from the guide. Unchanged from
27
+ 0.2.4; the fix landed server-side but not in this package.
28
+
9
29
  ## 0.2.4 - 2026-08-09
10
30
 
11
31
  - 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.5";
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,20 @@ async function createGuide(args, options = {}) {
1983
1993
  modelParameters: modelParameters.value ?? {},
1984
1994
  maxEstimatedUsdPerImage,
1985
1995
  });
1986
- const shouldPriceModelParameters =
1987
- selected !== null &&
1988
- !createGuideSelectedModelRequiresInputImage(selected) &&
1989
- createGuideCanPriceModelParameters(selected) &&
1990
- (defaultedModelParameters.defaultsApplied.length > 0 ||
1991
- Object.keys(modelParameters.value ?? {}).length > 0);
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.
1992
2002
  const pricing =
1993
- selected === null
1994
- ? null
1995
- : shouldPriceModelParameters
1996
- ? createGuidePricingForModel(
1997
- selected,
1998
- defaultedModelParameters.modelParameters,
1999
- pricingContext,
2000
- )
2001
- : createGuideModelCreditPricing(selected);
2003
+ selected === null || createGuideSelectedModelRequiresInputImage(selected)
2004
+ ? createGuideModelCreditPricing(selected)
2005
+ : createGuidePricingForModel(
2006
+ selected,
2007
+ defaultedModelParameters.modelParameters,
2008
+ pricingContext,
2009
+ );
2002
2010
  const estimatedCredits = pricing?.credits_required ?? null;
2003
2011
  // The guide publishes what the caller will be charged, never our provider
2004
2012
  // cost. `economics.estimated_usd_per_image` is the hosted charge per image.
@@ -2057,6 +2065,7 @@ async function createGuide(args, options = {}) {
2057
2065
  guideCommandPrefix,
2058
2066
  );
2059
2067
  const blocker = createGuideBlocker(stage, {
2068
+ operation: guideOperation,
2060
2069
  requestedModelId,
2061
2070
  quota,
2062
2071
  estimatedCredits,
@@ -2467,8 +2476,6 @@ function selectCreateGuideModel(
2467
2476
  const isExecutableInputImageEdit = (model) =>
2468
2477
  model?.status === "available" &&
2469
2478
  guideModelExecutionStatus(model) === "executable" &&
2470
- Array.isArray(model?.supports) &&
2471
- (model.supports.includes("edit") || model.supports.includes("variation")) &&
2472
2479
  createGuideSelectedModelRequiresInputImage(model);
2473
2480
  const isExecutableGuideModel = (model) =>
2474
2481
  operation === "edit"
@@ -2585,16 +2592,21 @@ function selectCreateGuideModel(
2585
2592
  // capability UNKNOWN, which never disqualifies a model and never triggers the
2586
2593
  // unsupported-ratio warning.
2587
2594
 
2588
- function guideAspectRatioProbeModelIds({ models, selected, intent }) {
2595
+ function guideAspectRatioProbeModelIds({ models, selected, intent, routing }) {
2589
2596
  const ids = [];
2590
2597
  if (typeof selected?.id === "string") {
2591
2598
  ids.push(selected.id);
2592
2599
  }
2593
- for (const modelId of preferredCreateGuideModelIds(
2594
- createGuideIntentClass(intent),
2595
- )) {
2596
- if (models.some((model) => model?.id === modelId)) {
2597
- ids.push(modelId);
2600
+ // Only a stated ratio can make the guide hop between models. Without one the
2601
+ // sole open question is what the current pick can express, so asking about
2602
+ // the alternatives would be a round trip spent on an answer nothing reads.
2603
+ if (routing) {
2604
+ for (const modelId of preferredCreateGuideModelIds(
2605
+ createGuideIntentClass(intent),
2606
+ )) {
2607
+ if (models.some((model) => model?.id === modelId)) {
2608
+ ids.push(modelId);
2609
+ }
2598
2610
  }
2599
2611
  }
2600
2612
  return [...new Set(ids)].slice(0, GUIDE_ASPECT_RATIO_PROBE_LIMIT);
@@ -2939,7 +2951,49 @@ function createGuidePricingForModel(model, modelParameters, context = {}) {
2939
2951
  if (createGuideCanPriceModelParameters(model)) {
2940
2952
  return createGuideXaiImagePricing(model, modelParameters, context);
2941
2953
  }
2942
- return createGuideModelCreditPricing(model);
2954
+ return (
2955
+ createGuideAspectRatioCreditPricing(model, context) ??
2956
+ createGuideModelCreditPricing(model)
2957
+ );
2958
+ }
2959
+
2960
+ // The debit `create` will actually make at the ratio the guide is about to
2961
+ // emit, read from the model registry (#2259).
2962
+ //
2963
+ // `economics.credit_pricing` is the quote at the model's own default shape --
2964
+ // no aspect ratio, no policy model-parameter defaults. `create` never sends
2965
+ // that request: it always sends a concrete ratio (`1:1` unless the brief asked
2966
+ // otherwise) plus the hosted defaults. For any model priced from its output
2967
+ // shape or from a defaulted parameter the quote is therefore not the debit, and
2968
+ // this CLI has no pricing engine to recompute it -- so the registry publishes
2969
+ // the answer and this reads it. Absent field means the quote already is the
2970
+ // debit.
2971
+ function createGuideAspectRatioCreditPricing(model, context) {
2972
+ // Both shapes: `GET /v1/models` serves the COMPACT summary, which flattens
2973
+ // economics onto the row, and only `?details=true` (or a fixture built from
2974
+ // the full catalog) nests it. Reading only the nested one is how a fix passes
2975
+ // its own test against a payload production never sends.
2976
+ const byAspectRatio = isRecord(
2977
+ model?.economics?.create_credit_debit_by_aspect_ratio,
2978
+ )
2979
+ ? model.economics.create_credit_debit_by_aspect_ratio
2980
+ : model?.create_credit_debit_by_aspect_ratio;
2981
+ const aspectRatio = context?.aspectRatio;
2982
+ if (!isRecord(byAspectRatio) || typeof aspectRatio !== "string") {
2983
+ return null;
2984
+ }
2985
+ const credits = byAspectRatio[aspectRatio];
2986
+ if (!Number.isFinite(credits)) {
2987
+ return null;
2988
+ }
2989
+ const quoted = createGuideModelCreditPricing(model);
2990
+ return {
2991
+ ...(quoted ?? {}),
2992
+ credits_required: credits,
2993
+ credit_unit_usd: CREDIT_UNIT_USD,
2994
+ estimated_charge_usd: roundUsd(credits * CREDIT_UNIT_USD),
2995
+ pricing_confidence: quoted?.pricing_confidence ?? "known",
2996
+ };
2943
2997
  }
2944
2998
 
2945
2999
  function createGuideCanPriceModelParameters(model) {
@@ -3017,6 +3071,43 @@ function roundUsdMicro(value) {
3017
3071
  return Math.round(value * 1_000_000) / 1_000_000;
3018
3072
  }
3019
3073
 
3074
+ // ---------------------------------------------------------------------------
3075
+ // How the guide reads a model field. Read this before adding an accessor.
3076
+ //
3077
+ // There is NO normalizer. `createGuide` hands the rows from `GET /v1/models`
3078
+ // to these accessors verbatim -- `withModelSummary` is the `models list`
3079
+ // command's compactor and never runs on this path. So an accessor here has to
3080
+ // cope with BOTH published shapes itself:
3081
+ //
3082
+ // GET /v1/models (compact list) flat: model_execution_status,
3083
+ // credits_required, estimated_usd_per_image,
3084
+ // max_outputs_per_request, artifact_storage.
3085
+ // NO `media`, `execution`, or `economics`.
3086
+ // GET /v1/models/:id (detail) nested under execution/economics/media.
3087
+ //
3088
+ // The convention is therefore NESTED-OR-FLAT: try the detail shape, fall back
3089
+ // to the flat field the list actually publishes. That convention is the whole
3090
+ // safety property, and #2265 was one accessor quietly breaking it.
3091
+ // `createGuideSelectedModelRequiresInputImage` read
3092
+ // `media.input.images.required` with a "fallback" of `accepts_input_images` --
3093
+ // a field the API has never published -- so against a real list row it had no
3094
+ // fallback at all, and it failed closed.
3095
+ //
3096
+ // guideModelExecutionStatus execution.model_execution_status -> flat
3097
+ // createGuideModelCreditPricing economics.credit_pricing -> flat
3098
+ // guideBudgetUsdForModel economics.estimated_usd_per_image -> flat
3099
+ // guideModelAspectRatioValues media.input.aspect_ratios.values -> none;
3100
+ // fails open and is hydrated from the detail
3101
+ // endpoint on demand (see the note there)
3102
+ // ...SelectedModelRequiresInputImage reads `supports`, published on the list
3103
+ //
3104
+ // `max_outputs_per_request` and `artifact_storage` are published flat as well,
3105
+ // but nothing on the guide path reads them. Their only nested reads live in
3106
+ // `modelSummaryRow`/`modelSummaryTaskTags`, which `withModelSummary` skips
3107
+ // outright for a payload already marked `compact_model_summary` -- which the
3108
+ // hosted list is. Those eight `media.*` reads therefore never see a list row.
3109
+ // ---------------------------------------------------------------------------
3110
+
3020
3111
  function guideModelExecutionStatus(model) {
3021
3112
  if (
3022
3113
  isRecord(model?.execution) &&
@@ -3133,6 +3224,26 @@ function requestedAspectRatioFromBrief(input) {
3133
3224
  // say. The hosted `/v1/models` list is a compact summary and omits the `media`
3134
3225
  // block entirely, so "not published" is the common case here and must be kept
3135
3226
  // distinct from "published and does not include this ratio".
3227
+ //
3228
+ // #2265, worth stating because the next reader will assume otherwise: this is
3229
+ // ONE OF EXACTLY TWO `media.*` reads that run over the LIST payload, and the
3230
+ // two behave OPPOSITELY. `GET /v1/models/:id` does publish `media`; the compact
3231
+ // list does not. So a `media.*` read is only safe here if it either fails open
3232
+ // or arranges to be hydrated from the detail endpoint first.
3233
+ //
3234
+ // - This one FAILS OPEN. Unknown never disqualifies a model, so an unhydrated
3235
+ // read costs a hint, not a decision. That is why capability routing kept
3236
+ // working in production while `edit --guide` was dead.
3237
+ // - `createGuideSelectedModelRequiresInputImage` used to FAIL CLOSED: no
3238
+ // `media` meant no model could prove it accepts an input image, so `edit
3239
+ // --guide` selected nothing on every real call. It no longer reads `media`
3240
+ // at all -- see the note there.
3241
+ //
3242
+ // Failing open is not the same as being correct. `guideModelSuggestedAspectRatio`
3243
+ // consumes this to REPORT a value rather than to gate one, so before the probe
3244
+ // was extended to video picks it published `suggested_aspect_ratio: null` on
3245
+ // every video brief that did not state a ratio -- silently, since no blocker
3246
+ // fires. Fail-open degrades quietly; that is its failure mode, not its absence.
3136
3247
  function guideModelAspectRatioValues(model) {
3137
3248
  const values = Array.isArray(model?.media?.input?.aspect_ratios?.values)
3138
3249
  ? model.media.input.aspect_ratios.values
@@ -3197,10 +3308,31 @@ function createGuideSuggestedAspectRatio(input) {
3197
3308
  return values.includes("16:9") ? "16:9" : (values[0] ?? null);
3198
3309
  }
3199
3310
 
3311
+ // #2265: this must decide from what the hosted payload actually publishes.
3312
+ // `GET /v1/models` returns the compact summary, which omits the `media` block
3313
+ // entirely (and has never published `accepts_input_images`), so gating on
3314
+ // `media.input.images.required` disqualified every model on every real call and
3315
+ // `edit --guide` could not select one, ever.
3316
+ //
3317
+ // This read FAILED CLOSED, which is why it was catastrophic rather than merely
3318
+ // lossy: an absent field could not prove any model accepts an input image, so
3319
+ // the guide selected nothing and reported `no_executable_model`. The other
3320
+ // list-payload `media.*` read (`guideModelAspectRatioValues`) fails open and
3321
+ // only loses a hint. Do not assume the two are interchangeable -- see the note
3322
+ // there before adding a third.
3323
+ //
3324
+ // `supports` is published and carries the same fact: it is derived from the
3325
+ // capability's operations (`image.edit` -> "edit", `image.variation` ->
3326
+ // "variation"), and both are input-image operations by construction. On the
3327
+ // live 220-model catalog the old conjunction and this predicate select the
3328
+ // identical 79 models, so the dropped conjunct was doing no work -- while
3329
+ // costing the mirror the ability to decide at all.
3330
+ //
3331
+ // Keep this reading only summary fields. `src/` holds strictly more model data
3332
+ // than the mirror can ever see; every extra field the guide reads there is a
3333
+ // fresh chance for the two surfaces to decide differently.
3200
3334
  function createGuideSelectedModelRequiresInputImage(model) {
3201
3335
  return (
3202
- (model?.media?.input?.images?.required === true ||
3203
- model?.accepts_input_images === true) &&
3204
3336
  Array.isArray(model?.supports) &&
3205
3337
  (model.supports.includes("edit") || model.supports.includes("variation"))
3206
3338
  );
@@ -3891,12 +4023,16 @@ function createGuideBlocker(stage, input) {
3891
4023
  };
3892
4024
  }
3893
4025
  if (stage === "no_executable_model") {
4026
+ // #2265: name the operation that actually failed. Saying "create" when the
4027
+ // caller ran `edit --guide` made the one diagnostic an agent gets point at
4028
+ // the wrong half of the product.
4029
+ const operation = input.operation === "edit" ? "edit" : "create";
3894
4030
  return {
3895
4031
  code: "no_executable_model",
3896
4032
  message:
3897
4033
  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}`,
4034
+ ? `No available executable ${operation} model was found in the public registry.`
4035
+ : `Requested model is not currently an available executable ${operation} model: ${input.requestedModelId}`,
3900
4036
  };
3901
4037
  }
3902
4038
  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.5",
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,