luxin-cli 0.2.3 → 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.
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.3";
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";
@@ -32,6 +32,39 @@ const PROMPTLESS_EDIT_MODEL_IDS = new Set([
32
32
  // provider rejects any prompt, so the public CLI must not require/send one.
33
33
  "fal.trellis-image-to-3d",
34
34
  ]);
35
+ // #2204-#2216: the guide fields the `ready_compact` output mode drops. Kept as
36
+ // one list so the delete and `data.output_mode.omitted_fields` cannot drift.
37
+ const COMPACT_OMITTABLE_GUIDE_FIELDS = [
38
+ "auth_handoff",
39
+ "auth_ready",
40
+ "escape_hatches",
41
+ "guide_recovery",
42
+ "no_spend_evaluation",
43
+ "recommended_no_spend_command",
44
+ "recommended_no_spend_command_effect",
45
+ "recommended_no_spend_command_label",
46
+ "self_fund_handoff",
47
+ "self_fund_next_command",
48
+ "self_fund_next_command_label",
49
+ "self_fund_preparation",
50
+ ];
51
+ // Kept whenever the pre-wall top-up is recommended: dropping the funding nudge
52
+ // at ready_to_create would regress the self-fund wall work.
53
+ const COMPACT_SELF_FUND_GUIDE_FIELDS = new Set([
54
+ "self_fund_handoff",
55
+ "self_fund_next_command",
56
+ "self_fund_next_command_label",
57
+ "self_fund_preparation",
58
+ ]);
59
+
60
+ // #2194 + #2204-#2216: in `ready_compact` mode `escape_hatches` is omitted, so
61
+ // the no-spend discovery note points at the literal command instead of a field
62
+ // that is not in the response.
63
+ function noSpendDiscoveryNote(readyCompact) {
64
+ return readyCompact
65
+ ? "No-spend output mode omits live self-fund and payment command templates. Re-run this guide without --no-spend to surface them; self_fund_discovery.inspect_methods_command stays available for read-only, no-spend payment-method inspection. provider_call, credit_debit, and media_write remain false (see data.mutation). data.output_mode lists the fields this ready_to_create response also omits."
66
+ : "No-spend output mode omits live self-fund and payment command templates. Re-run this guide without --no-spend to surface them; data.escape_hatches.payment_methods stays available for read-only, no-spend payment-method inspection. provider_call, credit_debit, and media_write remain false (see data.mutation).";
67
+ }
35
68
  const DEFAULT_CONFIG_PATH = join(
36
69
  process.env.XDG_CONFIG_HOME ?? join(os.homedir(), ".config"),
37
70
  "luxin",
@@ -52,9 +85,28 @@ const AGENT_SKILL_URL = "https://luxin.sh/skill.md";
52
85
  const AGENT_LLMS_URL = "https://luxin.sh/llms.txt";
53
86
  const AGENT_CLI_DOCS_URL = "https://luxin.sh/cli.md";
54
87
  const CREDIT_UNIT_USD = 0.01;
55
- const TARGET_GROSS_MARGIN = 0.4;
56
- const PAYMENT_BACKED_CREDIT_PAYMENT_FEE_RATE = 0.015;
57
- const PAYMENT_BACKED_CREDIT_PAYMENT_FEE_MODEL = "stripe_stablecoin_usd_percent";
88
+ const CANONICAL_PUBLIC_MEDIA_HOST = "media.luxin.sh";
89
+ const LEGACY_PUBLIC_MEDIA_HOST = "media.image-skill.com";
90
+ // media.luxin.sh is the canonical media host. media.image-skill.com is the
91
+ // pre-rename host; it still serves the identical objects, so asset URLs handed
92
+ // out under it stay valid inputs forever.
93
+ const ACCEPTED_PUBLIC_MEDIA_HOSTS = [
94
+ CANONICAL_PUBLIC_MEDIA_HOST,
95
+ LEGACY_PUBLIC_MEDIA_HOST,
96
+ ];
97
+ // Credit prices for the xAI Grok Imagine image variants, in credits
98
+ // (1 credit = CREDIT_UNIT_USD). The guide prices model parameters locally so it
99
+ // can pick a resolution without a second round trip; the hosted API remains the
100
+ // authority. Exact fractions keep the local estimate identical to the hosted
101
+ // price. These are prices, not costs: the CLI never computes or publishes
102
+ // provider cost or margin.
103
+ const XAI_IMAGE_CREDIT_PRICES = {
104
+ source_image: { standard: 1 / 3, quality: 5 / 3 },
105
+ output_image: {
106
+ standard: 10 / 3,
107
+ quality: { "1k": 25 / 3, "2k": 35 / 3 },
108
+ },
109
+ };
58
110
  const MODALITY_COMMAND_ALIASES = new Map([
59
111
  ["image", { command: "create", intent: null }],
60
112
  ["video", { command: "create", intent: "video" }],
@@ -156,6 +208,111 @@ const GUIDE_NEXT_COMMAND_PLACEHOLDERS = [
156
208
  },
157
209
  ];
158
210
 
211
+ // ---------------------------------------------------------------------------
212
+ // Explicit aspect-ratio intent (#2213/#2203/#2209, hardened by #2243/#2252).
213
+ //
214
+ // An agent that briefs "a 16:9 cinematic widescreen poster" is stating a
215
+ // REQUIRED output shape, not decorating the prompt. This CLI used to ignore it
216
+ // entirely: `createGuideSuggestedAspectRatio` only ever suggested a ratio for
217
+ // video models, so `selection.suggested_aspect_ratio` was null for every image
218
+ // create and the emitted `next_command` carried no `--aspect-ratio`. The agent
219
+ // then got the model default with no signal that the ratio it asked for had
220
+ // been dropped.
221
+ //
222
+ // Aspect ratio is a TOP-LEVEL NORMALIZED control (`--aspect-ratio`, validated
223
+ // against `media.input.aspect_ratios.values`), NOT a `model_parameters` field.
224
+ //
225
+ // Conservative on purpose: a false negative degrades to the old behavior (no
226
+ // suggested ratio), while a false positive silently overrides the shape an
227
+ // agent actually wanted. So this fires ONLY on an explicit ratio token from a
228
+ // known allowlist, or on an orientation word BOUND to an orientation noun.
229
+ // Bare "portrait" and bare "landscape" are deliberately excluded -- "a portrait
230
+ // of a woman" and "a landscape at dusk" are subject/genre words.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ // Ratio tokens accepted verbatim from a brief. Deliberately limited to the
234
+ // unambiguous photographic/display ratios. `2:1` and `1:2` are NOT here: they
235
+ // collide with odds and mixing ratios far more often than they express a shape.
236
+ const EXPLICIT_ASPECT_RATIOS = [
237
+ "1:1",
238
+ "4:3",
239
+ "3:4",
240
+ "3:2",
241
+ "2:3",
242
+ "16:9",
243
+ "9:16",
244
+ "21:9",
245
+ "9:21",
246
+ ];
247
+
248
+ // A `W:H` token that is not part of a longer numeric run (so "16:9:30" and
249
+ // "1920:1080:2" do not match) and is not a clock time ("9:16 am").
250
+ const ASPECT_RATIO_TOKEN_RE =
251
+ /(?<![\d.:])(\d{1,2}):(\d{1,2})(?![\d.:])(?!\s*[ap]\.?m\.?\b)/gi;
252
+
253
+ // A `W:H` token can appear in a brief without being a shape request:
254
+ //
255
+ // "the ratio of sugar to flour is 3:2 in this recipe" -> must not fire
256
+ // "a train departing at 16:9" -> must not fire
257
+ //
258
+ // Deliberately narrow. These match the immediate context of the token only;
259
+ // they are not an attempt at natural-language understanding, and #2252 had to
260
+ // walk back a first cut that over-matched.
261
+ const RATIO_SUPPRESSING_CONTEXT = [
262
+ // Quantity ratios and mixes: "ratio of sugar to flour is 3:2", "a 2:3 mix",
263
+ // "dilute 1:1", "3:2 by weight/volume".
264
+ //
265
+ // Requires the "ratio of X TO Y" quantities construction, and exempts the
266
+ // shape vocabulary. A first cut matched any "ratio of ... N:M" and silently
267
+ // killed "an image with an aspect ratio of 16:9" -- the single most standard
268
+ // way to state a ratio in English, and a far worse failure than the recipe
269
+ // case it was written for (#2252).
270
+ /\bratios?\s+of\b(?![^.!?]*?\b(?:width|height|aspect)\b)[^.!?]*?\bto\b[^.!?]*?\d{1,2}:\d{1,2}/i,
271
+ /\d{1,2}:\d{1,2}\s*(?:dilution|mix(?:ture)?|blend|ratio\s+by|by\s+(?:weight|volume)|odds)\b/i,
272
+ /\b(?:dilut\w*|mix(?:ed|ing)?|blend\w*|steep\w*)\s+(?:it\s+|them\s+)?(?:at\s+|to\s+)?\d{1,2}:\d{1,2}/i,
273
+ // Clock times written without a leading zero: "a train departing at 16:9".
274
+ // `ASPECT_RATIO_TOKEN_RE` already rejects the "9:16 am" form; this covers the
275
+ // bare 24-hour reading.
276
+ //
277
+ // Keyed on a SCHEDULE VERB, not on a bare preposition. "at" alone is
278
+ // ambiguous and suppressing it broke a real request -- "render at 4:3 for the
279
+ // retro TV bumper" is a shape request and must keep firing.
280
+ //
281
+ // `leav` and `board` are deliberately NOT in this list: they collide with
282
+ // ordinary image nouns (autumn leaves, boardwalk, surfboard, keyboard,
283
+ // billboard), so "a photo of autumn leaves at 16:9" would lose its ratio.
284
+ /\b(?:depart|arriv|due|scheduled|timetabled)\w*\s+(?:at|by|around)?\s*\d{1,2}:\d{1,2}\b/i,
285
+ ];
286
+
287
+ // Orientation nouns that turn an ambiguous orientation adjective into an
288
+ // unambiguous shape request. "portrait orientation" / "vertical video" /
289
+ // "square canvas" are shape requests; "portrait of a woman" is not.
290
+ const ORIENTATION_NOUN =
291
+ "orientation|format|mode|aspect(?:\\s+ratio)?|ratio|crop|canvas|image|photo|picture|poster|banner|video|frame|composition|layout|thumbnail|cover";
292
+
293
+ function boundOrientationRe(adjective) {
294
+ // "<adj> <noun>" (e.g. "vertical video") or "in <adj>" / "<adj> orientation".
295
+ return new RegExp(
296
+ `\\b(?:in\\s+${adjective}\\b|${adjective}[\\s-]+(?:${ORIENTATION_NOUN})\\b)`,
297
+ "i",
298
+ );
299
+ }
300
+
301
+ const ORIENTATION_RULES = [
302
+ // "widescreen" and "ultrawide" are unambiguous on their own -- they have no
303
+ // competing subject/genre meaning.
304
+ { re: /\bultra[\s-]?wide(?:screen)?\b/i, ratio: "21:9" },
305
+ { re: /\bwide[\s-]?screen\b/i, ratio: "16:9" },
306
+ // These four need an orientation noun to bind to.
307
+ { re: boundOrientationRe("portrait"), ratio: "9:16" },
308
+ { re: boundOrientationRe("vertical"), ratio: "9:16" },
309
+ { re: boundOrientationRe("landscape"), ratio: "16:9" },
310
+ { re: boundOrientationRe("horizontal"), ratio: "16:9" },
311
+ { re: boundOrientationRe("square"), ratio: "1:1" },
312
+ ];
313
+
314
+ const GUIDE_ASPECT_RATIO_PROBE_LIMIT = 8;
315
+
159
316
  function agentSkillHint(commandPrefix = "luxin") {
160
317
  return {
161
318
  url: AGENT_SKILL_URL,
@@ -553,6 +710,7 @@ function commandHelpByKey(key) {
553
710
  optional_flags: [
554
711
  "--guide",
555
712
  "--no-spend",
713
+ "--explain",
556
714
  "--dry-run",
557
715
  "--model",
558
716
  "--aspect-ratio",
@@ -1753,16 +1911,71 @@ async function createGuide(args, options = {}) {
1753
1911
  return token.result;
1754
1912
  }
1755
1913
 
1756
- const selected =
1757
- models.envelope.ok && models.envelope.data?.models
1758
- ? selectCreateGuideModel(models.envelope.data.models, requestedModelId, {
1914
+ // Explicit aspect ratio stated in the brief, treated as a required capability
1915
+ // during model selection and propagated into the emitted commands
1916
+ // (#2213/#2203/#2209, guards corrected by #2252).
1917
+ const briefAspectRatio = requestedAspectRatioFromBrief({
1918
+ prompt: trimmedPrompt,
1919
+ intent: requestedIntentFlag,
1920
+ });
1921
+ const listedModels =
1922
+ models.envelope.ok && Array.isArray(models.envelope.data?.models)
1923
+ ? models.envelope.data.models
1924
+ : null;
1925
+ const selectModel = (candidateModels) =>
1926
+ candidateModels === null
1927
+ ? null
1928
+ : selectCreateGuideModel(candidateModels, requestedModelId, {
1759
1929
  operation: guideOperation,
1760
1930
  prompt: trimmedPrompt,
1761
1931
  intent: requestedIntent,
1762
1932
  maxEstimatedUsdPerImage,
1933
+ requestedAspectRatio: briefAspectRatio,
1934
+ });
1935
+ let selection = selectModel(listedModels);
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) {
1946
+ // Two-pass: the first pass names the models worth asking about, then one
1947
+ // bounded round of detail lookups makes the second pass actually
1948
+ // capability-aware instead of guessing.
1949
+ const capabilityModels = await guideModelsWithAspectRatioCapabilities({
1950
+ models: listedModels,
1951
+ modelIds: guideAspectRatioProbeModelIds({
1952
+ models: listedModels,
1953
+ selected: selection?.model ?? null,
1954
+ intent: requestedIntent,
1955
+ routing: briefAspectRatio !== null,
1956
+ }),
1957
+ apiBaseUrl,
1958
+ });
1959
+ if (capabilityModels !== listedModels) {
1960
+ selection = selectModel(capabilityModels);
1961
+ }
1962
+ }
1963
+ const selected = selection?.model ?? null;
1964
+ const selectedAspectRatio = createGuideSuggestedAspectRatio({
1965
+ model: selected,
1966
+ requestedAspectRatio: briefAspectRatio,
1967
+ });
1968
+ // The brief asked for a ratio and the selected model cannot express it, so no
1969
+ // model could. Say so instead of dropping it silently.
1970
+ const unsupportedAspectRatioWarning =
1971
+ briefAspectRatio !== null &&
1972
+ selectedAspectRatio === null &&
1973
+ selected !== null
1974
+ ? aspectRatioUnsupportedWarning({
1975
+ aspectRatio: briefAspectRatio,
1976
+ modelId: selected.id,
1763
1977
  })
1764
1978
  : null;
1765
- const selectedAspectRatio = createGuideSuggestedAspectRatio(selected);
1766
1979
  const pricingContext = {
1767
1980
  aspectRatio: selectedAspectRatio ?? "1:1",
1768
1981
  outputCount: 1,
@@ -1780,41 +1993,30 @@ async function createGuide(args, options = {}) {
1780
1993
  modelParameters: modelParameters.value ?? {},
1781
1994
  maxEstimatedUsdPerImage,
1782
1995
  });
1783
- const shouldPriceModelParameters =
1784
- selected !== null &&
1785
- !createGuideSelectedModelRequiresInputImage(selected) &&
1786
- createGuideCanPriceModelParameters(selected) &&
1787
- (defaultedModelParameters.defaultsApplied.length > 0 ||
1788
- 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.
1789
2002
  const pricing =
1790
- selected === null
1791
- ? null
1792
- : shouldPriceModelParameters
1793
- ? createGuidePricingForModel(
1794
- selected,
1795
- defaultedModelParameters.modelParameters,
1796
- pricingContext,
1797
- )
1798
- : createGuideModelCreditPricing(selected);
1799
- const providerCostEstimate =
1800
- selected === null || !shouldPriceModelParameters
1801
- ? null
1802
- : createGuideProviderCostEstimateForModel(
2003
+ selected === null || createGuideSelectedModelRequiresInputImage(selected)
2004
+ ? createGuideModelCreditPricing(selected)
2005
+ : createGuidePricingForModel(
1803
2006
  selected,
1804
2007
  defaultedModelParameters.modelParameters,
1805
2008
  pricingContext,
1806
2009
  );
1807
2010
  const estimatedCredits = pricing?.credits_required ?? null;
1808
- const estimatedProviderUsdPerImage =
1809
- providerCostEstimate?.estimated_provider_cost_usd ??
2011
+ // The guide publishes what the caller will be charged, never our provider
2012
+ // cost. `economics.estimated_usd_per_image` is the hosted charge per image.
2013
+ const estimatedDebitUsdPerImage =
2014
+ pricing?.estimated_charge_usd ??
1810
2015
  selected?.economics?.estimated_usd_per_image ??
1811
- pricing?.fallback_provider_cost_usd ??
1812
2016
  (typeof selected?.estimated_usd_per_image === "number"
1813
2017
  ? selected.estimated_usd_per_image
1814
2018
  : null) ??
1815
2019
  null;
1816
- const estimatedDebitUsdPerImage =
1817
- pricing?.estimated_revenue_usd ?? estimatedProviderUsdPerImage;
1818
2020
  const budgetGuard =
1819
2021
  maxEstimatedUsdPerImage ??
1820
2022
  estimatedDebitUsdPerImage ??
@@ -1863,10 +2065,19 @@ async function createGuide(args, options = {}) {
1863
2065
  guideCommandPrefix,
1864
2066
  );
1865
2067
  const blocker = createGuideBlocker(stage, {
2068
+ operation: guideOperation,
1866
2069
  requestedModelId,
1867
2070
  quota,
1868
2071
  estimatedCredits,
1869
2072
  });
2073
+ // #2204-#2216: `--explain` opts back into the complete payload in every
2074
+ // stage; the compact default is for the agent that just wants the decision.
2075
+ // The payment/self-fund/recovery apparatus is correct when the guide is
2076
+ // blocked and noise when it is not, so shape on that state rather than on a
2077
+ // flag the caller has to know about.
2078
+ const explainOutputMode = flagBool(args, "explain");
2079
+ const readyCompact =
2080
+ stage === "ready_to_create" && blocker === null && !explainOutputMode;
1870
2081
  const nextCommand = createGuideNextCommand(stage, {
1871
2082
  prompt: trimmedPrompt,
1872
2083
  selected,
@@ -1925,11 +2136,21 @@ async function createGuide(args, options = {}) {
1925
2136
  noSpendNextCommandLabel,
1926
2137
  noSpendNextCommandEffect,
1927
2138
  });
1928
- const guideWarning = createGuideWarning(stage, {
2139
+ const baseGuideWarning = createGuideWarning(stage, {
1929
2140
  nextCommandEffect,
1930
2141
  paymentSummary,
1931
2142
  nextCommandCopyRunnable,
2143
+ readyCompact,
1932
2144
  });
2145
+ // Append, never replace: the stage warning carries the spend-safety contract
2146
+ // agents rely on, and the ratio note is additive context.
2147
+ const guideWarning =
2148
+ unsupportedAspectRatioWarning === null
2149
+ ? baseGuideWarning
2150
+ : {
2151
+ ...baseGuideWarning,
2152
+ warning: `${baseGuideWarning.warning} ${unsupportedAspectRatioWarning}`,
2153
+ };
1933
2154
  const quotaTopUp = createGuideQuotaTopUp(
1934
2155
  quota?.envelope.data?.top_up ?? null,
1935
2156
  );
@@ -1991,9 +2212,16 @@ async function createGuide(args, options = {}) {
1991
2212
  // dry-run command and the mutation proof, and keep the self-fund path
1992
2213
  // discoverable via self_fund_discovery instead of dominating the response.
1993
2214
  const noSpendOutputMode = flagBool(args, "no-spend");
1994
- const paymentSummaryOutput = noSpendOutputMode
1995
- ? { ...paymentSummary, suggested_commands: [] }
1996
- : paymentSummary;
2215
+ // Keep the pre-wall self-fund nudge whenever it is actually recommended.
2216
+ const selfFundRecommended = selfFundPreparation?.recommended === true;
2217
+ const paymentSummaryOutput =
2218
+ noSpendOutputMode || readyCompact
2219
+ ? {
2220
+ ...paymentSummary,
2221
+ suggested_commands: [],
2222
+ ...(readyCompact ? { preferred_method_summary: null } : {}),
2223
+ }
2224
+ : paymentSummary;
1997
2225
  const selfFundHandoffOutput = noSpendOutputMode ? null : selfFundHandoff;
1998
2226
  const selfFundPreparationOutput = noSpendOutputMode
1999
2227
  ? null
@@ -2030,7 +2258,7 @@ async function createGuide(args, options = {}) {
2030
2258
  },
2031
2259
  ),
2032
2260
  inspect_methods_command: escapeHatches.payment_methods,
2033
- note: "No-spend output mode omits live self-fund and payment command templates. Re-run this guide without --no-spend to surface them; data.escape_hatches.payment_methods stays available for read-only, no-spend payment-method inspection. provider_call, credit_debit, and media_write remain false (see data.mutation).",
2261
+ note: noSpendDiscoveryNote(readyCompact),
2034
2262
  },
2035
2263
  }
2036
2264
  : {
@@ -2046,7 +2274,49 @@ async function createGuide(args, options = {}) {
2046
2274
  escapeHatches,
2047
2275
  selfFundNextCommand: selfFundNextCommandOutput,
2048
2276
  });
2049
- return createGuideSuccess(command, quota?.envelope.actor ?? null, {
2277
+ // #2204-#2216: fields the compact ready payload drops. `self_fund_*` only
2278
+ // joins the list when the pre-wall top-up is not recommended.
2279
+ const compactOmittedFields = readyCompact
2280
+ ? COMPACT_OMITTABLE_GUIDE_FIELDS.filter(
2281
+ (field) =>
2282
+ selfFundRecommended === false ||
2283
+ !COMPACT_SELF_FUND_GUIDE_FIELDS.has(field),
2284
+ )
2285
+ : [];
2286
+ const compactCompactedFields = readyCompact
2287
+ ? [
2288
+ "checks.payments.preferred_method_summary",
2289
+ "checks.payments.suggested_commands",
2290
+ "checks.quota.top_up",
2291
+ ]
2292
+ : [];
2293
+ const outputModeData = {
2294
+ schema: "image-skill.guide-output-mode.v1",
2295
+ mode: readyCompact ? "ready_compact" : "full",
2296
+ reason: readyCompact
2297
+ ? "stage is ready_to_create with no blocker, so the guide returns the decision, the cost, and the no-spend proof. Payment, self-fund, auth-handoff, and recovery detail belong to blocked stages and are omitted here."
2298
+ : explainOutputMode
2299
+ ? "--explain returns every guide field in every stage."
2300
+ : "the guide is not at an unblocked ready_to_create stage, so every field is returned.",
2301
+ omitted_fields: compactOmittedFields,
2302
+ compacted_fields: compactCompactedFields,
2303
+ full_output_command: renderGuideCommand(
2304
+ trimmedPrompt.length > 0 ? trimmedPrompt : "PROMPT",
2305
+ explicitApiBaseUrl(args),
2306
+ guideCommandPrefix,
2307
+ {
2308
+ operation: guideOperation,
2309
+ inputReference: options.inputReference,
2310
+ modelId: requestedModelId,
2311
+ providerId: requestedProviderId,
2312
+ intent: requestedIntentFlag,
2313
+ maxEstimatedUsdPerImage,
2314
+ modelParametersJson: requestedModelParametersJson,
2315
+ explain: true,
2316
+ },
2317
+ ),
2318
+ };
2319
+ const guideData = {
2050
2320
  schema:
2051
2321
  guideOperation === "edit"
2052
2322
  ? "image-skill.edit-guide.v1"
@@ -2086,7 +2356,7 @@ async function createGuide(args, options = {}) {
2086
2356
  required_credits: estimatedCredits,
2087
2357
  daily_jobs_remaining:
2088
2358
  quota?.envelope.data?.daily_jobs?.remaining ?? null,
2089
- top_up: quotaTopUp,
2359
+ top_up: readyCompact ? null : quotaTopUp,
2090
2360
  reason:
2091
2361
  quota === null
2092
2362
  ? "auth_required"
@@ -2109,20 +2379,28 @@ async function createGuide(args, options = {}) {
2109
2379
  modality: selected.modality ?? null,
2110
2380
  suggested_aspect_ratio: selectedAspectRatio,
2111
2381
  reason:
2112
- requestedModelId === null
2113
- ? createGuideSelectionReason(
2114
- selected,
2115
- trimmedPrompt,
2116
- requestedIntent,
2117
- guideOperation,
2118
- )
2119
- : createGuideRequestedSelectionReason(selected),
2382
+ requestedModelId !== null
2383
+ ? createGuideRequestedSelectionReason(selected)
2384
+ : selection?.aspectRatioRoutedFrom != null &&
2385
+ briefAspectRatio !== null
2386
+ ? aspectRatioRoutedCreateSelectionReason({
2387
+ aspectRatio: briefAspectRatio,
2388
+ skippedModelId: selection.aspectRatioRoutedFrom,
2389
+ })
2390
+ : selection?.aspectRatioCapableFallback === true &&
2391
+ briefAspectRatio !== null
2392
+ ? `guide selected an available model that supports the requested ${briefAspectRatio} aspect ratio`
2393
+ : createGuideSelectionReason(
2394
+ selected,
2395
+ trimmedPrompt,
2396
+ requestedIntent,
2397
+ guideOperation,
2398
+ ),
2120
2399
  },
2121
2400
  cost: {
2122
2401
  estimated_credits: estimatedCredits,
2123
2402
  estimated_usd_per_image: estimatedDebitUsdPerImage,
2124
2403
  estimated_debit_usd_per_image: estimatedDebitUsdPerImage,
2125
- estimated_provider_usd_per_image: estimatedProviderUsdPerImage,
2126
2404
  credit_unit_usd: pricing?.credit_unit_usd ?? CREDIT_UNIT_USD,
2127
2405
  pricing_confidence: pricing?.pricing_confidence ?? null,
2128
2406
  pricing_source: pricing?.pricing_source ?? null,
@@ -2152,6 +2430,7 @@ async function createGuide(args, options = {}) {
2152
2430
  auth_handoff: authHandoff,
2153
2431
  escape_hatches: escapeHatches,
2154
2432
  no_spend_output_mode: noSpendOutputModeData,
2433
+ output_mode: outputModeData,
2155
2434
  mutation: {
2156
2435
  provider_call: false,
2157
2436
  hosted_create: false,
@@ -2160,7 +2439,11 @@ async function createGuide(args, options = {}) {
2160
2439
  credit_debit: false,
2161
2440
  media_write: false,
2162
2441
  },
2163
- });
2442
+ };
2443
+ for (const field of compactOmittedFields) {
2444
+ delete guideData[field];
2445
+ }
2446
+ return createGuideSuccess(command, quota?.envelope.actor ?? null, guideData);
2164
2447
  }
2165
2448
 
2166
2449
  function createGuideSuccess(command, actor, data) {
@@ -2177,6 +2460,12 @@ function selectCreateGuideModel(
2177
2460
  prompt = "",
2178
2461
  intent = undefined,
2179
2462
  maxEstimatedUsdPerImage = null,
2463
+ /**
2464
+ * Aspect ratio explicitly requested by the brief, treated as a REQUIRED
2465
+ * capability during selection (#2213/#2203/#2209) the same way a
2466
+ * reproducibility need is. Null when the brief states no ratio.
2467
+ */
2468
+ requestedAspectRatio = null,
2180
2469
  } = {},
2181
2470
  ) {
2182
2471
  const isExecutableCreate = (model) =>
@@ -2187,17 +2476,23 @@ function selectCreateGuideModel(
2187
2476
  const isExecutableInputImageEdit = (model) =>
2188
2477
  model?.status === "available" &&
2189
2478
  guideModelExecutionStatus(model) === "executable" &&
2190
- Array.isArray(model?.supports) &&
2191
- (model.supports.includes("edit") || model.supports.includes("variation")) &&
2192
2479
  createGuideSelectedModelRequiresInputImage(model);
2193
2480
  const isExecutableGuideModel = (model) =>
2194
2481
  operation === "edit"
2195
2482
  ? isExecutableInputImageEdit(model)
2196
2483
  : isExecutableCreate(model) || isExecutableInputImageEdit(model);
2484
+ const requestedRatio = requestedAspectRatio ?? null;
2485
+ const chosen = (
2486
+ model,
2487
+ { aspectRatioRoutedFrom = null, aspectRatioCapableFallback = false } = {},
2488
+ ) =>
2489
+ model === undefined || model === null
2490
+ ? null
2491
+ : { model, aspectRatioRoutedFrom, aspectRatioCapableFallback };
2197
2492
  if (requestedModelId !== null) {
2198
2493
  const requested = resolveModelLookup(requestedModelId, models);
2199
2494
  return requested !== undefined && isExecutableGuideModel(requested)
2200
- ? requested
2495
+ ? chosen(requested)
2201
2496
  : null;
2202
2497
  }
2203
2498
  const candidates =
@@ -2214,7 +2509,7 @@ function selectCreateGuideModel(
2214
2509
  });
2215
2510
  const threeDimensional = eligible3d[0];
2216
2511
  if (threeDimensional !== undefined) {
2217
- return threeDimensional;
2512
+ return chosen(threeDimensional);
2218
2513
  }
2219
2514
  }
2220
2515
  const eligible = guideCandidatesWithinBudget({
@@ -2224,23 +2519,156 @@ function selectCreateGuideModel(
2224
2519
  if (createGuideImpliesAudio({ prompt, intent })) {
2225
2520
  const audio = eligible.find((model) => model?.modality === "audio");
2226
2521
  if (audio !== undefined) {
2227
- return audio;
2522
+ return chosen(audio);
2228
2523
  }
2229
2524
  }
2230
2525
  if (createGuideImpliesVideo({ prompt, intent })) {
2231
- const video = eligible.find((model) => model?.modality === "video");
2526
+ const videoCandidates = eligible.filter(
2527
+ (model) => model?.modality === "video",
2528
+ );
2529
+ const video =
2530
+ videoCandidates.find(
2531
+ (model) => !modelCannotExpressAspectRatio(model, requestedRatio),
2532
+ ) ?? videoCandidates[0];
2232
2533
  if (video !== undefined) {
2233
- return video;
2534
+ return chosen(video);
2234
2535
  }
2235
2536
  }
2236
2537
  const intentClass = createGuideIntentClass(intent);
2237
- for (const modelId of preferredCreateGuideModelIds(intentClass)) {
2238
- const preferred = eligible.find((model) => model?.id === modelId);
2239
- if (preferred !== undefined) {
2240
- return preferred;
2538
+ const preferredCandidates = preferredCreateGuideModelIds(intentClass)
2539
+ .map((modelId) => eligible.find((model) => model?.id === modelId))
2540
+ .filter((model) => model !== undefined);
2541
+ const [topPreferred] = preferredCandidates;
2542
+
2543
+ // An explicitly requested aspect ratio is a REQUIRED capability, so walk the
2544
+ // curated preference list in strength order but skip models that are KNOWN
2545
+ // not to express the ratio through the normalized `--aspect-ratio` control
2546
+ // (#2213/#2203/#2209). Unknown capability is not a denial: see
2547
+ // `modelAspectRatioSupport`.
2548
+ const preferredSupportingRatio = preferredCandidates.find(
2549
+ (model) => !modelCannotExpressAspectRatio(model, requestedRatio),
2550
+ );
2551
+ if (preferredSupportingRatio !== undefined) {
2552
+ const routedForAspectRatio =
2553
+ requestedRatio !== null &&
2554
+ topPreferred !== undefined &&
2555
+ topPreferred.id !== preferredSupportingRatio.id;
2556
+ return chosen(preferredSupportingRatio, {
2557
+ aspectRatioRoutedFrom: routedForAspectRatio ? topPreferred.id : null,
2558
+ });
2559
+ }
2560
+
2561
+ // No curated model can express the ratio: fall out to the strongest
2562
+ // remaining executable candidate that provably can, before degrading to the
2563
+ // ratio-blind fallback.
2564
+ if (requestedRatio !== null) {
2565
+ const capable = eligible.find(
2566
+ (model) => modelAspectRatioSupport(model, requestedRatio) === true,
2567
+ );
2568
+ if (capable !== undefined) {
2569
+ return chosen(capable, {
2570
+ aspectRatioRoutedFrom: topPreferred?.id ?? null,
2571
+ aspectRatioCapableFallback: topPreferred === undefined,
2572
+ });
2573
+ }
2574
+ }
2575
+
2576
+ // Nothing can express the requested ratio (or none was requested): keep the
2577
+ // ordinary selection exactly. The caller emits an honest guide_warning rather
2578
+ // than letting the ratio disappear silently.
2579
+ if (topPreferred !== undefined) {
2580
+ return chosen(topPreferred);
2581
+ }
2582
+ return chosen(eligible[0]);
2583
+ }
2584
+
2585
+ // The hosted `/v1/models` list is a compact summary and does not publish
2586
+ // `media.input.aspect_ratios.values`, so a stated ratio cannot be checked
2587
+ // against it. Probe the detail endpoint for the handful of models the guide
2588
+ // would actually route between -- never the whole catalog, whose
2589
+ // `?details=true` payload is multiple megabytes.
2590
+ //
2591
+ // Fail-open: an unreachable, errored, or malformed detail response leaves the
2592
+ // capability UNKNOWN, which never disqualifies a model and never triggers the
2593
+ // unsupported-ratio warning.
2594
+
2595
+ function guideAspectRatioProbeModelIds({ models, selected, intent, routing }) {
2596
+ const ids = [];
2597
+ if (typeof selected?.id === "string") {
2598
+ ids.push(selected.id);
2599
+ }
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
+ }
2241
2610
  }
2242
2611
  }
2243
- return eligible[0] ?? null;
2612
+ return [...new Set(ids)].slice(0, GUIDE_ASPECT_RATIO_PROBE_LIMIT);
2613
+ }
2614
+
2615
+ async function guideModelsWithAspectRatioCapabilities({
2616
+ models,
2617
+ modelIds,
2618
+ apiBaseUrl,
2619
+ }) {
2620
+ const missing = modelIds.filter((modelId) => {
2621
+ const model = models.find((candidate) => candidate?.id === modelId);
2622
+ return model !== undefined && guideModelAspectRatioValues(model) === null;
2623
+ });
2624
+ if (missing.length === 0) {
2625
+ return models;
2626
+ }
2627
+ const probes = await Promise.all(
2628
+ missing.map(async (modelId) => {
2629
+ try {
2630
+ const detail = await apiRequest({
2631
+ command: "luxin create --guide",
2632
+ method: "GET",
2633
+ apiBaseUrl,
2634
+ path: `/v1/models/${encodeURIComponent(modelId)}`,
2635
+ });
2636
+ const values = guideModelAspectRatioValues(
2637
+ detail?.envelope?.data?.model ?? null,
2638
+ );
2639
+ return values === null ? null : { modelId, values };
2640
+ } catch {
2641
+ return null;
2642
+ }
2643
+ }),
2644
+ );
2645
+ const resolved = new Map(
2646
+ probes
2647
+ .filter((probe) => probe !== null)
2648
+ .map((probe) => [probe.modelId, probe.values]),
2649
+ );
2650
+ if (resolved.size === 0) {
2651
+ return models;
2652
+ }
2653
+ return models.map((model) => {
2654
+ const values = resolved.get(model?.id);
2655
+ if (values === undefined) {
2656
+ return model;
2657
+ }
2658
+ return {
2659
+ ...model,
2660
+ media: {
2661
+ ...model?.media,
2662
+ input: {
2663
+ ...model?.media?.input,
2664
+ aspect_ratios: {
2665
+ ...model?.media?.input?.aspect_ratios,
2666
+ values,
2667
+ },
2668
+ },
2669
+ },
2670
+ };
2671
+ });
2244
2672
  }
2245
2673
 
2246
2674
  const MODEL_ALIAS_RULES = [
@@ -2412,29 +2840,47 @@ function createGuideIntentClass(intent) {
2412
2840
  return "general";
2413
2841
  }
2414
2842
 
2843
+ // Mirrors src/hosted-create-policy.ts (#2231). budget_draft is a superset of
2844
+ // general and both are ordered ascending by charged credits, so a
2845
+ // cost-minimizing intent can never select a model priced above the general
2846
+ // default. final keeps the quality-premium lead.
2415
2847
  function preferredCreateGuideModelIds(intentClass) {
2416
- return intentClass === "budget_draft"
2417
- ? [
2418
- "fal.flux-dev",
2419
- "xai.grok-imagine-image-quality",
2420
- "xai.grok-imagine-image",
2421
- "openai.gpt-image-2",
2422
- ]
2423
- : [
2424
- "xai.grok-imagine-image-quality",
2425
- "fal.flux-dev",
2426
- "xai.grok-imagine-image",
2427
- "openai.gpt-image-2",
2428
- ];
2848
+ if (intentClass === "budget_draft") {
2849
+ return [
2850
+ "fal.flux-1-schnell",
2851
+ "fal.flux-1-dev",
2852
+ "xai.grok-imagine-image",
2853
+ "fal.bria-text-to-image-fast",
2854
+ "xai.grok-imagine-image-quality",
2855
+ "openai.gpt-image-2",
2856
+ ];
2857
+ }
2858
+ if (intentClass === "final") {
2859
+ return [
2860
+ "xai.grok-imagine-image-quality",
2861
+ "openai.gpt-image-2",
2862
+ "fal.flux-1-dev",
2863
+ "xai.grok-imagine-image",
2864
+ "fal.flux-1-schnell",
2865
+ "fal.bria-text-to-image-fast",
2866
+ ];
2867
+ }
2868
+ return [
2869
+ "fal.flux-1-dev",
2870
+ "xai.grok-imagine-image",
2871
+ "xai.grok-imagine-image-quality",
2872
+ "openai.gpt-image-2",
2873
+ "fal.flux-1-schnell",
2874
+ "fal.bria-text-to-image-fast",
2875
+ ];
2429
2876
  }
2430
2877
 
2431
2878
  function guideBudgetUsdForModel(model) {
2879
+ // Budget caps compare the credit debit -- what the caller pays.
2432
2880
  const pricing = createGuideModelCreditPricing(model);
2433
2881
  return (
2434
- pricing?.estimated_revenue_usd ??
2882
+ pricing?.estimated_charge_usd ??
2435
2883
  model?.economics?.estimated_usd_per_image ??
2436
- pricing?.estimated_provider_cost_usd ??
2437
- pricing?.fallback_provider_cost_usd ??
2438
2884
  (typeof model?.estimated_usd_per_image === "number"
2439
2885
  ? model.estimated_usd_per_image
2440
2886
  : null) ??
@@ -2450,7 +2896,7 @@ function createGuideDefaultModelParameters(input) {
2450
2896
  input.model?.id === "xai.grok-imagine-image-quality" &&
2451
2897
  modelParameters.resolution === undefined
2452
2898
  ) {
2453
- // Budget defaults must compare the credit debit (estimated_revenue_usd),
2899
+ // Budget defaults must compare the credit debit (estimated_charge_usd),
2454
2900
  // the same basis the hosted budget guard enforces, not the provider cost.
2455
2901
  const twoKPricing = createGuidePricingForModel(
2456
2902
  input.model,
@@ -2459,8 +2905,8 @@ function createGuideDefaultModelParameters(input) {
2459
2905
  );
2460
2906
  const twoKAllowedByBudget =
2461
2907
  input.maxEstimatedUsdPerImage === null ||
2462
- typeof twoKPricing?.estimated_revenue_usd !== "number" ||
2463
- twoKPricing.estimated_revenue_usd <= input.maxEstimatedUsdPerImage;
2908
+ typeof twoKPricing?.estimated_charge_usd !== "number" ||
2909
+ twoKPricing.estimated_charge_usd <= input.maxEstimatedUsdPerImage;
2464
2910
  const intentClass = createGuideIntentClass(input.intent);
2465
2911
  const resolution =
2466
2912
  intentClass !== "budget_draft" && twoKAllowedByBudget ? "2k" : "1k";
@@ -2490,8 +2936,8 @@ function createGuideDefaultModelParameters(input) {
2490
2936
  );
2491
2937
  const mediumAllowedByBudget =
2492
2938
  input.maxEstimatedUsdPerImage === null ||
2493
- typeof mediumPricing?.estimated_revenue_usd !== "number" ||
2494
- mediumPricing.estimated_revenue_usd <= input.maxEstimatedUsdPerImage;
2939
+ typeof mediumPricing?.estimated_charge_usd !== "number" ||
2940
+ mediumPricing.estimated_charge_usd <= input.maxEstimatedUsdPerImage;
2495
2941
  if (mediumAllowedByBudget) {
2496
2942
  modelParameters.quality = "medium";
2497
2943
  defaultsApplied.push("quality=medium");
@@ -2502,45 +2948,59 @@ function createGuideDefaultModelParameters(input) {
2502
2948
  }
2503
2949
 
2504
2950
  function createGuidePricingForModel(model, modelParameters, context = {}) {
2505
- const estimate = createGuideProviderCostEstimateForModel(
2506
- model,
2507
- modelParameters,
2508
- context,
2509
- );
2510
- if (estimate.estimated_provider_cost_usd === null) {
2511
- return createGuideModelCreditPricing(model);
2951
+ if (createGuideCanPriceModelParameters(model)) {
2952
+ return createGuideXaiImagePricing(model, modelParameters, context);
2512
2953
  }
2513
- return createGuideCreditPricingForProviderCost({
2514
- providerCostUsd: estimate.estimated_provider_cost_usd,
2515
- pricingConfidence: estimate.pricing_confidence,
2516
- pricingSource: estimate.pricing_source,
2517
- });
2518
- }
2519
-
2520
- function createGuideCanPriceModelParameters(model) {
2521
- return String(model?.id ?? "").startsWith("xai.grok-imagine-image");
2954
+ return (
2955
+ createGuideAspectRatioCreditPricing(model, context) ??
2956
+ createGuideModelCreditPricing(model)
2957
+ );
2522
2958
  }
2523
2959
 
2524
- function createGuideProviderCostEstimateForModel(
2525
- model,
2526
- modelParameters = {},
2527
- context = {},
2528
- ) {
2529
- if (String(model?.id ?? "").startsWith("xai.grok-imagine-image")) {
2530
- return createGuideXaiImageCostEstimate(model, modelParameters, context);
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;
2531
2988
  }
2989
+ const quoted = createGuideModelCreditPricing(model);
2532
2990
  return {
2533
- estimated_provider_cost_usd:
2534
- typeof model?.economics?.estimated_usd_per_image === "number"
2535
- ? model.economics.estimated_usd_per_image
2536
- : (createGuideModelCreditPricing(model)?.estimated_provider_cost_usd ??
2537
- null),
2538
- pricing_source: "model_registry",
2539
- pricing_confidence: "known",
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",
2540
2996
  };
2541
2997
  }
2542
2998
 
2543
- function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2999
+ function createGuideCanPriceModelParameters(model) {
3000
+ return String(model?.id ?? "").startsWith("xai.grok-imagine-image");
3001
+ }
3002
+
3003
+ function createGuideXaiImagePricing(model, modelParameters, context) {
2544
3004
  const modelId = String(model?.id ?? "");
2545
3005
  const quality = modelId.includes("-quality");
2546
3006
  const edit = modelId.endsWith("-edit");
@@ -2555,11 +3015,20 @@ function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2555
3015
  ? context.referenceAssetCount
2556
3016
  : 0;
2557
3017
  const sourceImageCount = edit ? 1 + referenceAssetCount : 0;
2558
- const inputUsdPerImage = quality ? 0.01 : 0.002;
2559
- let outputUsdPerImage = 0.02;
2560
- if (quality) {
2561
- outputUsdPerImage = resolution === "2k" ? 0.07 : 0.05;
2562
- }
3018
+ const sourceCredits = quality
3019
+ ? XAI_IMAGE_CREDIT_PRICES.source_image.quality
3020
+ : XAI_IMAGE_CREDIT_PRICES.source_image.standard;
3021
+ const outputCredits = quality
3022
+ ? XAI_IMAGE_CREDIT_PRICES.output_image.quality[resolution]
3023
+ : XAI_IMAGE_CREDIT_PRICES.output_image.standard;
3024
+ const creditsRequired = Math.max(
3025
+ 1,
3026
+ Math.ceil(
3027
+ roundUsdMicro(
3028
+ sourceCredits * sourceImageCount + outputCredits * outputImageCount,
3029
+ ),
3030
+ ),
3031
+ );
2563
3032
  const defaultResolution =
2564
3033
  modelParameters?.resolution === undefined ||
2565
3034
  modelParameters?.resolution === null ||
@@ -2568,67 +3037,12 @@ function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2568
3037
  defaultResolution &&
2569
3038
  outputImageCount === 1 &&
2570
3039
  sourceImageCount === (edit ? 1 : 0);
2571
- return {
2572
- estimated_provider_cost_usd: roundUsdMicro(
2573
- inputUsdPerImage * sourceImageCount +
2574
- outputUsdPerImage * outputImageCount,
2575
- ),
2576
- pricing_source: defaultShape ? "model_registry" : "model_parameters",
2577
- pricing_confidence: "known",
2578
- };
2579
- }
2580
-
2581
- function createGuideCreditPricingForProviderCost(input) {
2582
- const providerCostUsd = roundUsdMicro(input.providerCostUsd);
2583
- const creditsRequired = Math.max(
2584
- 1,
2585
- Math.ceil(
2586
- roundUsdMicro(
2587
- providerCostUsd / (1 - TARGET_GROSS_MARGIN) / CREDIT_UNIT_USD,
2588
- ),
2589
- ),
2590
- );
2591
- const estimatedRevenueUsd = roundUsd(creditsRequired * CREDIT_UNIT_USD);
2592
- const estimatedPaymentFeeUsd = roundUsdMicro(
2593
- estimatedRevenueUsd * PAYMENT_BACKED_CREDIT_PAYMENT_FEE_RATE,
2594
- );
2595
- const estimatedNetRevenueUsd = roundUsdMicro(
2596
- estimatedRevenueUsd - estimatedPaymentFeeUsd,
2597
- );
2598
- const estimatedGrossMargin =
2599
- estimatedRevenueUsd > 0
2600
- ? roundRatio(
2601
- (estimatedRevenueUsd - providerCostUsd) / estimatedRevenueUsd,
2602
- )
2603
- : null;
2604
- const estimatedFeeAdjustedMargin =
2605
- estimatedRevenueUsd > 0
2606
- ? roundRatio(
2607
- (estimatedNetRevenueUsd - providerCostUsd) / estimatedRevenueUsd,
2608
- )
2609
- : null;
2610
- const selfFundBlockReason =
2611
- estimatedNetRevenueUsd + 1e-9 < providerCostUsd
2612
- ? "payment_fee_margin_negative"
2613
- : null;
2614
3040
  return {
2615
3041
  credits_required: creditsRequired,
2616
3042
  credit_unit_usd: CREDIT_UNIT_USD,
2617
- estimated_provider_cost_usd: providerCostUsd,
2618
- fallback_provider_cost_usd: null,
2619
- estimated_revenue_usd: estimatedRevenueUsd,
2620
- estimated_gross_margin: estimatedGrossMargin,
2621
- payment_fee_rate: PAYMENT_BACKED_CREDIT_PAYMENT_FEE_RATE,
2622
- payment_fee_model: PAYMENT_BACKED_CREDIT_PAYMENT_FEE_MODEL,
2623
- estimated_payment_fee_usd: estimatedPaymentFeeUsd,
2624
- estimated_net_revenue_usd: estimatedNetRevenueUsd,
2625
- estimated_fee_adjusted_margin: estimatedFeeAdjustedMargin,
2626
- self_fundable: selfFundBlockReason === null,
2627
- self_fund_block_reason: selfFundBlockReason,
2628
- target_gross_margin: TARGET_GROSS_MARGIN,
2629
- pricing_confidence: input.pricingConfidence,
2630
- pricing_source: input.pricingSource,
2631
- margin_model: "provider_cost_plus_margin",
3043
+ estimated_charge_usd: roundUsd(creditsRequired * CREDIT_UNIT_USD),
3044
+ pricing_confidence: "known",
3045
+ pricing_source: defaultShape ? "model_registry" : "model_parameters",
2632
3046
  };
2633
3047
  }
2634
3048
 
@@ -2653,14 +3067,47 @@ function roundUsd(value) {
2653
3067
  return Math.round(value * 100) / 100;
2654
3068
  }
2655
3069
 
2656
- function roundRatio(value) {
2657
- return Math.round(value * 1000) / 1000;
2658
- }
2659
-
2660
3070
  function roundUsdMicro(value) {
2661
3071
  return Math.round(value * 1_000_000) / 1_000_000;
2662
3072
  }
2663
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
+
2664
3111
  function guideModelExecutionStatus(model) {
2665
3112
  if (
2666
3113
  isRecord(model?.execution) &&
@@ -2682,16 +3129,8 @@ function createGuideModelCreditPricing(model) {
2682
3129
  }
2683
3130
  return {
2684
3131
  credits_required: model.credits_required,
2685
- estimated_revenue_usd:
2686
- typeof model.credits_required === "number"
2687
- ? model.credits_required * CREDIT_UNIT_USD
2688
- : null,
2689
- estimated_provider_cost_usd:
2690
- typeof model?.estimated_usd_per_image === "number"
2691
- ? model.estimated_usd_per_image
2692
- : null,
2693
- fallback_provider_cost_usd: null,
2694
3132
  credit_unit_usd: CREDIT_UNIT_USD,
3133
+ estimated_charge_usd: roundUsd(model.credits_required * CREDIT_UNIT_USD),
2695
3134
  pricing_confidence:
2696
3135
  typeof model?.pricing_confidence === "string"
2697
3136
  ? model.pricing_confidence
@@ -2726,23 +3165,174 @@ function createGuideImpliesVideo(input) {
2726
3165
  );
2727
3166
  }
2728
3167
 
2729
- function createGuideSuggestedAspectRatio(model) {
2730
- if (model?.modality !== "video") {
2731
- return null;
3168
+ // Extract an explicitly requested aspect ratio from a create/edit brief.
3169
+ //
3170
+ // Precedence:
3171
+ // 1. An explicit `W:H` token from EXPLICIT_ASPECT_RATIOS wins over any
3172
+ // orientation word -- it is the most specific statement of intent.
3173
+ // 2. Otherwise a bound orientation phrase is used.
3174
+ //
3175
+ // Conflicts resolve to null (no request) rather than to a guess: two different
3176
+ // explicit ratios, or two orientation phrases implying different shapes, mean
3177
+ // the brief is ambiguous and the guide should not pick a side.
3178
+ function classifyRequestedAspectRatio(input) {
3179
+ const text = `${input?.intent ?? ""} ${input?.prompt ?? ""}`.trim();
3180
+ if (text.length === 0) {
3181
+ return { aspectRatio: null, signal: null };
2732
3182
  }
3183
+
3184
+ const ratioTokenIsSuppressed = RATIO_SUPPRESSING_CONTEXT.some((re) =>
3185
+ re.test(text),
3186
+ );
3187
+
3188
+ const explicit = new Set();
3189
+ if (!ratioTokenIsSuppressed) {
3190
+ for (const match of text.matchAll(ASPECT_RATIO_TOKEN_RE)) {
3191
+ const token = `${match[1]}:${match[2]}`;
3192
+ if (EXPLICIT_ASPECT_RATIOS.includes(token)) {
3193
+ explicit.add(token);
3194
+ }
3195
+ }
3196
+ }
3197
+ if (explicit.size === 1) {
3198
+ const [only] = [...explicit];
3199
+ return { aspectRatio: only ?? null, signal: "explicit_ratio_token" };
3200
+ }
3201
+ if (explicit.size > 1) {
3202
+ // Contradictory explicit ratios: refuse to guess.
3203
+ return { aspectRatio: null, signal: null };
3204
+ }
3205
+
3206
+ const implied = new Set();
3207
+ for (const rule of ORIENTATION_RULES) {
3208
+ if (rule.re.test(text)) {
3209
+ implied.add(rule.ratio);
3210
+ }
3211
+ }
3212
+ if (implied.size === 1) {
3213
+ const [only] = [...implied];
3214
+ return { aspectRatio: only ?? null, signal: "orientation_phrase" };
3215
+ }
3216
+ return { aspectRatio: null, signal: null };
3217
+ }
3218
+
3219
+ function requestedAspectRatioFromBrief(input) {
3220
+ return classifyRequestedAspectRatio(input).aspectRatio;
3221
+ }
3222
+
3223
+ // The normalized ratios a model publishes, or null when the payload does not
3224
+ // say. The hosted `/v1/models` list is a compact summary and omits the `media`
3225
+ // block entirely, so "not published" is the common case here and must be kept
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.
3247
+ function guideModelAspectRatioValues(model) {
2733
3248
  const values = Array.isArray(model?.media?.input?.aspect_ratios?.values)
2734
3249
  ? model.media.input.aspect_ratios.values
2735
3250
  : model?.aspect_ratios;
2736
- if (!Array.isArray(values)) {
3251
+ return Array.isArray(values) ? values : null;
3252
+ }
3253
+
3254
+ // Tri-state on purpose: true (published and supported), false (published and
3255
+ // NOT supported), null (capability unknown). Unknown must never be read as a
3256
+ // denial -- doing so would make every ratio request in production claim "no
3257
+ // available model supports this", which is worse than the bug being fixed.
3258
+ function modelAspectRatioSupport(model, aspectRatio) {
3259
+ if (aspectRatio === null || aspectRatio === undefined) {
3260
+ return true;
3261
+ }
3262
+ const values = guideModelAspectRatioValues(model);
3263
+ return values === null ? null : values.includes(aspectRatio);
3264
+ }
3265
+
3266
+ function modelCannotExpressAspectRatio(model, aspectRatio) {
3267
+ return modelAspectRatioSupport(model, aspectRatio) === false;
3268
+ }
3269
+
3270
+ // Honest capability note for the case where an explicitly requested aspect
3271
+ // ratio made the guide skip its normal top pick. Name the capability that
3272
+ // forced the hop so agents learn which model could not express the shape.
3273
+ function aspectRatioRoutedCreateSelectionReason(input) {
3274
+ return `guide selected a create model that supports the requested ${input.aspectRatio} aspect ratio because ${input.skippedModelId} does not expose ${input.aspectRatio}`;
3275
+ }
3276
+
3277
+ // Fail-honestly note for the case where NO available model can express the
3278
+ // requested ratio. The guide keeps its normal selection and says so rather than
3279
+ // silently dropping the ratio, which is the exact failure this fixes.
3280
+ function aspectRatioUnsupportedWarning(input) {
3281
+ return `The requested ${input.aspectRatio} aspect ratio is not supported by any available model, so data.next_command omits --aspect-ratio and ${input.modelId} will use its own default shape. Run the model_inspection_command to see the ratios it does support.`;
3282
+ }
3283
+
3284
+ function createGuideSuggestedAspectRatio(input) {
3285
+ const model = input?.model ?? null;
3286
+ const requestedAspectRatio = input?.requestedAspectRatio ?? null;
3287
+ if (model === null) {
3288
+ return null;
3289
+ }
3290
+ // An explicit request wins for every modality, but not when the selected
3291
+ // model is KNOWN not to express it. In that case return null so
3292
+ // `next_command` stays runnable (`--aspect-ratio` would fail model
3293
+ // validation) and let the caller warn instead of silently pretending the
3294
+ // ratio was honored.
3295
+ if (requestedAspectRatio !== null) {
3296
+ return modelCannotExpressAspectRatio(model, requestedAspectRatio)
3297
+ ? null
3298
+ : requestedAspectRatio;
3299
+ }
3300
+ // No explicit request: preserve the existing video-only default.
3301
+ if (model?.modality !== "video") {
3302
+ return null;
3303
+ }
3304
+ const values = guideModelAspectRatioValues(model);
3305
+ if (values === null) {
2737
3306
  return null;
2738
3307
  }
2739
3308
  return values.includes("16:9") ? "16:9" : (values[0] ?? null);
2740
3309
  }
2741
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.
2742
3334
  function createGuideSelectedModelRequiresInputImage(model) {
2743
3335
  return (
2744
- (model?.media?.input?.images?.required === true ||
2745
- model?.accepts_input_images === true) &&
2746
3336
  Array.isArray(model?.supports) &&
2747
3337
  (model.supports.includes("edit") || model.supports.includes("variation"))
2748
3338
  );
@@ -2787,9 +3377,14 @@ function createGuideSelectionReason(
2787
3377
  model?.id,
2788
3378
  )
2789
3379
  ) {
2790
- return createGuideIntentClass(intent) === "budget_draft"
2791
- ? "guide selected a draft/budget create model with high-definition defaults"
2792
- : "guide selected the strongest currently available quality-first create model for this intent";
3380
+ const intentClass = createGuideIntentClass(intent);
3381
+ if (intentClass === "budget_draft") {
3382
+ return "guide selected the lowest-credit available create model for a draft/budget intent";
3383
+ }
3384
+ if (intentClass === "general") {
3385
+ return "guide selected a cost-efficient general-purpose create model for iteration; pass --intent final for the quality-first model";
3386
+ }
3387
+ return "guide selected the strongest currently available quality-first create model for this intent";
2793
3388
  }
2794
3389
  return operation === "edit"
2795
3390
  ? "guide selected the first available executable input-image edit model"
@@ -3428,12 +4023,16 @@ function createGuideBlocker(stage, input) {
3428
4023
  };
3429
4024
  }
3430
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";
3431
4030
  return {
3432
4031
  code: "no_executable_model",
3433
4032
  message:
3434
4033
  input.requestedModelId === null
3435
- ? "No available executable create model was found in the public registry."
3436
- : `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}`,
3437
4036
  };
3438
4037
  }
3439
4038
  if (stage === "auth_required") {
@@ -4072,14 +4671,22 @@ function createGuideWarning(stage, input) {
4072
4671
  warning,
4073
4672
  };
4074
4673
  }
4674
+ // #2204-#2216: name the field this response actually carries. The compact
4675
+ // ready payload drops the `recommended_no_spend_command` alias, so it points
4676
+ // at the base field instead; blocked and `--explain` payloads keep the alias
4677
+ // and keep naming it. Either way
4678
+ // `data[data.guide_warning.recommended_command_field]` resolves.
4679
+ const noSpendField =
4680
+ input.readyCompact === true
4681
+ ? "no_spend_next_command"
4682
+ : "recommended_no_spend_command";
4075
4683
  return {
4076
4684
  ...base,
4077
4685
  next_command_safety: "live_media_create_credit_debit",
4078
4686
  no_spend_safe: false,
4079
4687
  spend_required: true,
4080
- recommended_command_field: "recommended_no_spend_command",
4081
- warning:
4082
- "data.next_command is a live media create that can call a provider, debit credits, and create media. Run it only when media spend is allowed; otherwise run data.recommended_no_spend_command.",
4688
+ recommended_command_field: noSpendField,
4689
+ warning: `data.next_command is a live media create that can call a provider, debit credits, and create media. Run it only when media spend is allowed; otherwise run data.${noSpendField}.`,
4083
4690
  };
4084
4691
  }
4085
4692
 
@@ -4169,6 +4776,7 @@ function createGuideNextCommand(stage, input) {
4169
4776
  prompt: input.prompt,
4170
4777
  inputReference: input.inputReference,
4171
4778
  budgetGuard: input.budgetGuard,
4779
+ aspectRatio: input.aspectRatio,
4172
4780
  modelParametersJson: input.modelParametersJson,
4173
4781
  dryRun: false,
4174
4782
  idempotencyKey: `edit-guide-${Date.now()}-${randomBytes(4).toString("hex")}`,
@@ -4228,6 +4836,7 @@ function createGuideEscapeHatches(input) {
4228
4836
  prompt: input.prompt,
4229
4837
  inputReference: input.inputReference,
4230
4838
  budgetGuard: input.budgetGuard,
4839
+ aspectRatio: input.aspectRatio,
4231
4840
  modelParametersJson: input.modelParametersJson,
4232
4841
  dryRun: true,
4233
4842
  apiBaseUrl: input.apiBaseUrl,
@@ -4257,7 +4866,7 @@ function renderGuideCommand(
4257
4866
  const operation = options.operation ?? "create";
4258
4867
  return [
4259
4868
  commandPrefix,
4260
- `${operation} --guide --prompt`,
4869
+ `${operation} --guide${options.explain === true ? " --explain" : ""} --prompt`,
4261
4870
  shellQuote(prompt),
4262
4871
  ...(operation === "edit" &&
4263
4872
  typeof options.inputReference === "string" &&
@@ -4363,6 +4972,9 @@ function renderInputImageGuideCommand(input) {
4363
4972
  "--model",
4364
4973
  shellQuote(input.modelId),
4365
4974
  ...(promptless ? [] : ["--prompt", shellQuote(input.prompt)]),
4975
+ ...(input.aspectRatio === null || input.aspectRatio === undefined
4976
+ ? []
4977
+ : ["--aspect-ratio", shellQuote(input.aspectRatio)]),
4366
4978
  "--max-estimated-usd-per-image",
4367
4979
  shellQuote(formatUsd(input.budgetGuard)),
4368
4980
  ...(input.modelParametersJson === null ||
@@ -4517,6 +5129,12 @@ async function create(argv) {
4517
5129
  "create --no-spend is a guide output mode; combine it with --guide, or use create --dry-run for a no-spend planned job",
4518
5130
  );
4519
5131
  }
5132
+ if (flagBool(args, "explain")) {
5133
+ return invalid(
5134
+ "luxin create",
5135
+ "create --explain is a guide output mode; combine it with --guide to return every guide field",
5136
+ );
5137
+ }
4520
5138
  const prompt = await promptValue(args);
4521
5139
  if (!prompt.ok) {
4522
5140
  return prompt.result;
@@ -4670,6 +5288,12 @@ async function edit(argv) {
4670
5288
  "edit --no-spend is a guide output mode; combine it with --guide, or use edit --dry-run for a no-spend planned job",
4671
5289
  );
4672
5290
  }
5291
+ if (flagBool(args, "explain")) {
5292
+ return invalid(
5293
+ "luxin edit",
5294
+ "edit --explain is a guide output mode; combine it with --guide to return every guide field",
5295
+ );
5296
+ }
4673
5297
  if (input === undefined) {
4674
5298
  return invalid(
4675
5299
  "luxin edit",
@@ -4789,7 +5413,7 @@ async function assets(argv) {
4789
5413
  if (assetId === null) {
4790
5414
  return invalid(
4791
5415
  "luxin assets",
4792
- "assets currently supports Luxin asset ids and media.image-skill.com URLs",
5416
+ `assets currently supports Luxin asset ids and ${ACCEPTED_PUBLIC_MEDIA_HOSTS.join(" or ")} URLs`,
4793
5417
  );
4794
5418
  }
4795
5419
  const token = await resolveToken(args);
@@ -7224,7 +7848,7 @@ function assetIdFromReference(reference) {
7224
7848
  const url = new URL(reference);
7225
7849
  if (
7226
7850
  url.protocol !== "https:" ||
7227
- url.hostname !== "media.image-skill.com" ||
7851
+ !ACCEPTED_PUBLIC_MEDIA_HOSTS.includes(url.hostname.toLowerCase()) ||
7228
7852
  !url.pathname.startsWith("/a/")
7229
7853
  ) {
7230
7854
  return null;