luxin-cli 0.2.3 → 0.2.4

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.4";
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,61 @@ 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
+ if (briefAspectRatio !== null && listedModels !== null) {
1937
+ // Two-pass: the first pass names the models worth asking about, then one
1938
+ // bounded round of detail lookups makes the second pass actually
1939
+ // capability-aware instead of guessing.
1940
+ const capabilityModels = await guideModelsWithAspectRatioCapabilities({
1941
+ models: listedModels,
1942
+ modelIds: guideAspectRatioProbeModelIds({
1943
+ models: listedModels,
1944
+ selected: selection?.model ?? null,
1945
+ intent: requestedIntent,
1946
+ }),
1947
+ apiBaseUrl,
1948
+ });
1949
+ if (capabilityModels !== listedModels) {
1950
+ selection = selectModel(capabilityModels);
1951
+ }
1952
+ }
1953
+ const selected = selection?.model ?? null;
1954
+ const selectedAspectRatio = createGuideSuggestedAspectRatio({
1955
+ model: selected,
1956
+ requestedAspectRatio: briefAspectRatio,
1957
+ });
1958
+ // The brief asked for a ratio and the selected model cannot express it, so no
1959
+ // model could. Say so instead of dropping it silently.
1960
+ const unsupportedAspectRatioWarning =
1961
+ briefAspectRatio !== null &&
1962
+ selectedAspectRatio === null &&
1963
+ selected !== null
1964
+ ? aspectRatioUnsupportedWarning({
1965
+ aspectRatio: briefAspectRatio,
1966
+ modelId: selected.id,
1763
1967
  })
1764
1968
  : null;
1765
- const selectedAspectRatio = createGuideSuggestedAspectRatio(selected);
1766
1969
  const pricingContext = {
1767
1970
  aspectRatio: selectedAspectRatio ?? "1:1",
1768
1971
  outputCount: 1,
@@ -1796,25 +1999,16 @@ async function createGuide(args, options = {}) {
1796
1999
  pricingContext,
1797
2000
  )
1798
2001
  : createGuideModelCreditPricing(selected);
1799
- const providerCostEstimate =
1800
- selected === null || !shouldPriceModelParameters
1801
- ? null
1802
- : createGuideProviderCostEstimateForModel(
1803
- selected,
1804
- defaultedModelParameters.modelParameters,
1805
- pricingContext,
1806
- );
1807
2002
  const estimatedCredits = pricing?.credits_required ?? null;
1808
- const estimatedProviderUsdPerImage =
1809
- providerCostEstimate?.estimated_provider_cost_usd ??
2003
+ // The guide publishes what the caller will be charged, never our provider
2004
+ // cost. `economics.estimated_usd_per_image` is the hosted charge per image.
2005
+ const estimatedDebitUsdPerImage =
2006
+ pricing?.estimated_charge_usd ??
1810
2007
  selected?.economics?.estimated_usd_per_image ??
1811
- pricing?.fallback_provider_cost_usd ??
1812
2008
  (typeof selected?.estimated_usd_per_image === "number"
1813
2009
  ? selected.estimated_usd_per_image
1814
2010
  : null) ??
1815
2011
  null;
1816
- const estimatedDebitUsdPerImage =
1817
- pricing?.estimated_revenue_usd ?? estimatedProviderUsdPerImage;
1818
2012
  const budgetGuard =
1819
2013
  maxEstimatedUsdPerImage ??
1820
2014
  estimatedDebitUsdPerImage ??
@@ -1867,6 +2061,14 @@ async function createGuide(args, options = {}) {
1867
2061
  quota,
1868
2062
  estimatedCredits,
1869
2063
  });
2064
+ // #2204-#2216: `--explain` opts back into the complete payload in every
2065
+ // stage; the compact default is for the agent that just wants the decision.
2066
+ // The payment/self-fund/recovery apparatus is correct when the guide is
2067
+ // blocked and noise when it is not, so shape on that state rather than on a
2068
+ // flag the caller has to know about.
2069
+ const explainOutputMode = flagBool(args, "explain");
2070
+ const readyCompact =
2071
+ stage === "ready_to_create" && blocker === null && !explainOutputMode;
1870
2072
  const nextCommand = createGuideNextCommand(stage, {
1871
2073
  prompt: trimmedPrompt,
1872
2074
  selected,
@@ -1925,11 +2127,21 @@ async function createGuide(args, options = {}) {
1925
2127
  noSpendNextCommandLabel,
1926
2128
  noSpendNextCommandEffect,
1927
2129
  });
1928
- const guideWarning = createGuideWarning(stage, {
2130
+ const baseGuideWarning = createGuideWarning(stage, {
1929
2131
  nextCommandEffect,
1930
2132
  paymentSummary,
1931
2133
  nextCommandCopyRunnable,
2134
+ readyCompact,
1932
2135
  });
2136
+ // Append, never replace: the stage warning carries the spend-safety contract
2137
+ // agents rely on, and the ratio note is additive context.
2138
+ const guideWarning =
2139
+ unsupportedAspectRatioWarning === null
2140
+ ? baseGuideWarning
2141
+ : {
2142
+ ...baseGuideWarning,
2143
+ warning: `${baseGuideWarning.warning} ${unsupportedAspectRatioWarning}`,
2144
+ };
1933
2145
  const quotaTopUp = createGuideQuotaTopUp(
1934
2146
  quota?.envelope.data?.top_up ?? null,
1935
2147
  );
@@ -1991,9 +2203,16 @@ async function createGuide(args, options = {}) {
1991
2203
  // dry-run command and the mutation proof, and keep the self-fund path
1992
2204
  // discoverable via self_fund_discovery instead of dominating the response.
1993
2205
  const noSpendOutputMode = flagBool(args, "no-spend");
1994
- const paymentSummaryOutput = noSpendOutputMode
1995
- ? { ...paymentSummary, suggested_commands: [] }
1996
- : paymentSummary;
2206
+ // Keep the pre-wall self-fund nudge whenever it is actually recommended.
2207
+ const selfFundRecommended = selfFundPreparation?.recommended === true;
2208
+ const paymentSummaryOutput =
2209
+ noSpendOutputMode || readyCompact
2210
+ ? {
2211
+ ...paymentSummary,
2212
+ suggested_commands: [],
2213
+ ...(readyCompact ? { preferred_method_summary: null } : {}),
2214
+ }
2215
+ : paymentSummary;
1997
2216
  const selfFundHandoffOutput = noSpendOutputMode ? null : selfFundHandoff;
1998
2217
  const selfFundPreparationOutput = noSpendOutputMode
1999
2218
  ? null
@@ -2030,7 +2249,7 @@ async function createGuide(args, options = {}) {
2030
2249
  },
2031
2250
  ),
2032
2251
  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).",
2252
+ note: noSpendDiscoveryNote(readyCompact),
2034
2253
  },
2035
2254
  }
2036
2255
  : {
@@ -2046,7 +2265,49 @@ async function createGuide(args, options = {}) {
2046
2265
  escapeHatches,
2047
2266
  selfFundNextCommand: selfFundNextCommandOutput,
2048
2267
  });
2049
- return createGuideSuccess(command, quota?.envelope.actor ?? null, {
2268
+ // #2204-#2216: fields the compact ready payload drops. `self_fund_*` only
2269
+ // joins the list when the pre-wall top-up is not recommended.
2270
+ const compactOmittedFields = readyCompact
2271
+ ? COMPACT_OMITTABLE_GUIDE_FIELDS.filter(
2272
+ (field) =>
2273
+ selfFundRecommended === false ||
2274
+ !COMPACT_SELF_FUND_GUIDE_FIELDS.has(field),
2275
+ )
2276
+ : [];
2277
+ const compactCompactedFields = readyCompact
2278
+ ? [
2279
+ "checks.payments.preferred_method_summary",
2280
+ "checks.payments.suggested_commands",
2281
+ "checks.quota.top_up",
2282
+ ]
2283
+ : [];
2284
+ const outputModeData = {
2285
+ schema: "image-skill.guide-output-mode.v1",
2286
+ mode: readyCompact ? "ready_compact" : "full",
2287
+ reason: readyCompact
2288
+ ? "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."
2289
+ : explainOutputMode
2290
+ ? "--explain returns every guide field in every stage."
2291
+ : "the guide is not at an unblocked ready_to_create stage, so every field is returned.",
2292
+ omitted_fields: compactOmittedFields,
2293
+ compacted_fields: compactCompactedFields,
2294
+ full_output_command: renderGuideCommand(
2295
+ trimmedPrompt.length > 0 ? trimmedPrompt : "PROMPT",
2296
+ explicitApiBaseUrl(args),
2297
+ guideCommandPrefix,
2298
+ {
2299
+ operation: guideOperation,
2300
+ inputReference: options.inputReference,
2301
+ modelId: requestedModelId,
2302
+ providerId: requestedProviderId,
2303
+ intent: requestedIntentFlag,
2304
+ maxEstimatedUsdPerImage,
2305
+ modelParametersJson: requestedModelParametersJson,
2306
+ explain: true,
2307
+ },
2308
+ ),
2309
+ };
2310
+ const guideData = {
2050
2311
  schema:
2051
2312
  guideOperation === "edit"
2052
2313
  ? "image-skill.edit-guide.v1"
@@ -2086,7 +2347,7 @@ async function createGuide(args, options = {}) {
2086
2347
  required_credits: estimatedCredits,
2087
2348
  daily_jobs_remaining:
2088
2349
  quota?.envelope.data?.daily_jobs?.remaining ?? null,
2089
- top_up: quotaTopUp,
2350
+ top_up: readyCompact ? null : quotaTopUp,
2090
2351
  reason:
2091
2352
  quota === null
2092
2353
  ? "auth_required"
@@ -2109,20 +2370,28 @@ async function createGuide(args, options = {}) {
2109
2370
  modality: selected.modality ?? null,
2110
2371
  suggested_aspect_ratio: selectedAspectRatio,
2111
2372
  reason:
2112
- requestedModelId === null
2113
- ? createGuideSelectionReason(
2114
- selected,
2115
- trimmedPrompt,
2116
- requestedIntent,
2117
- guideOperation,
2118
- )
2119
- : createGuideRequestedSelectionReason(selected),
2373
+ requestedModelId !== null
2374
+ ? createGuideRequestedSelectionReason(selected)
2375
+ : selection?.aspectRatioRoutedFrom != null &&
2376
+ briefAspectRatio !== null
2377
+ ? aspectRatioRoutedCreateSelectionReason({
2378
+ aspectRatio: briefAspectRatio,
2379
+ skippedModelId: selection.aspectRatioRoutedFrom,
2380
+ })
2381
+ : selection?.aspectRatioCapableFallback === true &&
2382
+ briefAspectRatio !== null
2383
+ ? `guide selected an available model that supports the requested ${briefAspectRatio} aspect ratio`
2384
+ : createGuideSelectionReason(
2385
+ selected,
2386
+ trimmedPrompt,
2387
+ requestedIntent,
2388
+ guideOperation,
2389
+ ),
2120
2390
  },
2121
2391
  cost: {
2122
2392
  estimated_credits: estimatedCredits,
2123
2393
  estimated_usd_per_image: estimatedDebitUsdPerImage,
2124
2394
  estimated_debit_usd_per_image: estimatedDebitUsdPerImage,
2125
- estimated_provider_usd_per_image: estimatedProviderUsdPerImage,
2126
2395
  credit_unit_usd: pricing?.credit_unit_usd ?? CREDIT_UNIT_USD,
2127
2396
  pricing_confidence: pricing?.pricing_confidence ?? null,
2128
2397
  pricing_source: pricing?.pricing_source ?? null,
@@ -2152,6 +2421,7 @@ async function createGuide(args, options = {}) {
2152
2421
  auth_handoff: authHandoff,
2153
2422
  escape_hatches: escapeHatches,
2154
2423
  no_spend_output_mode: noSpendOutputModeData,
2424
+ output_mode: outputModeData,
2155
2425
  mutation: {
2156
2426
  provider_call: false,
2157
2427
  hosted_create: false,
@@ -2160,7 +2430,11 @@ async function createGuide(args, options = {}) {
2160
2430
  credit_debit: false,
2161
2431
  media_write: false,
2162
2432
  },
2163
- });
2433
+ };
2434
+ for (const field of compactOmittedFields) {
2435
+ delete guideData[field];
2436
+ }
2437
+ return createGuideSuccess(command, quota?.envelope.actor ?? null, guideData);
2164
2438
  }
2165
2439
 
2166
2440
  function createGuideSuccess(command, actor, data) {
@@ -2177,6 +2451,12 @@ function selectCreateGuideModel(
2177
2451
  prompt = "",
2178
2452
  intent = undefined,
2179
2453
  maxEstimatedUsdPerImage = null,
2454
+ /**
2455
+ * Aspect ratio explicitly requested by the brief, treated as a REQUIRED
2456
+ * capability during selection (#2213/#2203/#2209) the same way a
2457
+ * reproducibility need is. Null when the brief states no ratio.
2458
+ */
2459
+ requestedAspectRatio = null,
2180
2460
  } = {},
2181
2461
  ) {
2182
2462
  const isExecutableCreate = (model) =>
@@ -2194,10 +2474,18 @@ function selectCreateGuideModel(
2194
2474
  operation === "edit"
2195
2475
  ? isExecutableInputImageEdit(model)
2196
2476
  : isExecutableCreate(model) || isExecutableInputImageEdit(model);
2477
+ const requestedRatio = requestedAspectRatio ?? null;
2478
+ const chosen = (
2479
+ model,
2480
+ { aspectRatioRoutedFrom = null, aspectRatioCapableFallback = false } = {},
2481
+ ) =>
2482
+ model === undefined || model === null
2483
+ ? null
2484
+ : { model, aspectRatioRoutedFrom, aspectRatioCapableFallback };
2197
2485
  if (requestedModelId !== null) {
2198
2486
  const requested = resolveModelLookup(requestedModelId, models);
2199
2487
  return requested !== undefined && isExecutableGuideModel(requested)
2200
- ? requested
2488
+ ? chosen(requested)
2201
2489
  : null;
2202
2490
  }
2203
2491
  const candidates =
@@ -2214,7 +2502,7 @@ function selectCreateGuideModel(
2214
2502
  });
2215
2503
  const threeDimensional = eligible3d[0];
2216
2504
  if (threeDimensional !== undefined) {
2217
- return threeDimensional;
2505
+ return chosen(threeDimensional);
2218
2506
  }
2219
2507
  }
2220
2508
  const eligible = guideCandidatesWithinBudget({
@@ -2224,23 +2512,151 @@ function selectCreateGuideModel(
2224
2512
  if (createGuideImpliesAudio({ prompt, intent })) {
2225
2513
  const audio = eligible.find((model) => model?.modality === "audio");
2226
2514
  if (audio !== undefined) {
2227
- return audio;
2515
+ return chosen(audio);
2228
2516
  }
2229
2517
  }
2230
2518
  if (createGuideImpliesVideo({ prompt, intent })) {
2231
- const video = eligible.find((model) => model?.modality === "video");
2519
+ const videoCandidates = eligible.filter(
2520
+ (model) => model?.modality === "video",
2521
+ );
2522
+ const video =
2523
+ videoCandidates.find(
2524
+ (model) => !modelCannotExpressAspectRatio(model, requestedRatio),
2525
+ ) ?? videoCandidates[0];
2232
2526
  if (video !== undefined) {
2233
- return video;
2527
+ return chosen(video);
2234
2528
  }
2235
2529
  }
2236
2530
  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;
2531
+ const preferredCandidates = preferredCreateGuideModelIds(intentClass)
2532
+ .map((modelId) => eligible.find((model) => model?.id === modelId))
2533
+ .filter((model) => model !== undefined);
2534
+ const [topPreferred] = preferredCandidates;
2535
+
2536
+ // An explicitly requested aspect ratio is a REQUIRED capability, so walk the
2537
+ // curated preference list in strength order but skip models that are KNOWN
2538
+ // not to express the ratio through the normalized `--aspect-ratio` control
2539
+ // (#2213/#2203/#2209). Unknown capability is not a denial: see
2540
+ // `modelAspectRatioSupport`.
2541
+ const preferredSupportingRatio = preferredCandidates.find(
2542
+ (model) => !modelCannotExpressAspectRatio(model, requestedRatio),
2543
+ );
2544
+ if (preferredSupportingRatio !== undefined) {
2545
+ const routedForAspectRatio =
2546
+ requestedRatio !== null &&
2547
+ topPreferred !== undefined &&
2548
+ topPreferred.id !== preferredSupportingRatio.id;
2549
+ return chosen(preferredSupportingRatio, {
2550
+ aspectRatioRoutedFrom: routedForAspectRatio ? topPreferred.id : null,
2551
+ });
2552
+ }
2553
+
2554
+ // No curated model can express the ratio: fall out to the strongest
2555
+ // remaining executable candidate that provably can, before degrading to the
2556
+ // ratio-blind fallback.
2557
+ if (requestedRatio !== null) {
2558
+ const capable = eligible.find(
2559
+ (model) => modelAspectRatioSupport(model, requestedRatio) === true,
2560
+ );
2561
+ if (capable !== undefined) {
2562
+ return chosen(capable, {
2563
+ aspectRatioRoutedFrom: topPreferred?.id ?? null,
2564
+ aspectRatioCapableFallback: topPreferred === undefined,
2565
+ });
2241
2566
  }
2242
2567
  }
2243
- return eligible[0] ?? null;
2568
+
2569
+ // Nothing can express the requested ratio (or none was requested): keep the
2570
+ // ordinary selection exactly. The caller emits an honest guide_warning rather
2571
+ // than letting the ratio disappear silently.
2572
+ if (topPreferred !== undefined) {
2573
+ return chosen(topPreferred);
2574
+ }
2575
+ return chosen(eligible[0]);
2576
+ }
2577
+
2578
+ // The hosted `/v1/models` list is a compact summary and does not publish
2579
+ // `media.input.aspect_ratios.values`, so a stated ratio cannot be checked
2580
+ // against it. Probe the detail endpoint for the handful of models the guide
2581
+ // would actually route between -- never the whole catalog, whose
2582
+ // `?details=true` payload is multiple megabytes.
2583
+ //
2584
+ // Fail-open: an unreachable, errored, or malformed detail response leaves the
2585
+ // capability UNKNOWN, which never disqualifies a model and never triggers the
2586
+ // unsupported-ratio warning.
2587
+
2588
+ function guideAspectRatioProbeModelIds({ models, selected, intent }) {
2589
+ const ids = [];
2590
+ if (typeof selected?.id === "string") {
2591
+ ids.push(selected.id);
2592
+ }
2593
+ for (const modelId of preferredCreateGuideModelIds(
2594
+ createGuideIntentClass(intent),
2595
+ )) {
2596
+ if (models.some((model) => model?.id === modelId)) {
2597
+ ids.push(modelId);
2598
+ }
2599
+ }
2600
+ return [...new Set(ids)].slice(0, GUIDE_ASPECT_RATIO_PROBE_LIMIT);
2601
+ }
2602
+
2603
+ async function guideModelsWithAspectRatioCapabilities({
2604
+ models,
2605
+ modelIds,
2606
+ apiBaseUrl,
2607
+ }) {
2608
+ const missing = modelIds.filter((modelId) => {
2609
+ const model = models.find((candidate) => candidate?.id === modelId);
2610
+ return model !== undefined && guideModelAspectRatioValues(model) === null;
2611
+ });
2612
+ if (missing.length === 0) {
2613
+ return models;
2614
+ }
2615
+ const probes = await Promise.all(
2616
+ missing.map(async (modelId) => {
2617
+ try {
2618
+ const detail = await apiRequest({
2619
+ command: "luxin create --guide",
2620
+ method: "GET",
2621
+ apiBaseUrl,
2622
+ path: `/v1/models/${encodeURIComponent(modelId)}`,
2623
+ });
2624
+ const values = guideModelAspectRatioValues(
2625
+ detail?.envelope?.data?.model ?? null,
2626
+ );
2627
+ return values === null ? null : { modelId, values };
2628
+ } catch {
2629
+ return null;
2630
+ }
2631
+ }),
2632
+ );
2633
+ const resolved = new Map(
2634
+ probes
2635
+ .filter((probe) => probe !== null)
2636
+ .map((probe) => [probe.modelId, probe.values]),
2637
+ );
2638
+ if (resolved.size === 0) {
2639
+ return models;
2640
+ }
2641
+ return models.map((model) => {
2642
+ const values = resolved.get(model?.id);
2643
+ if (values === undefined) {
2644
+ return model;
2645
+ }
2646
+ return {
2647
+ ...model,
2648
+ media: {
2649
+ ...model?.media,
2650
+ input: {
2651
+ ...model?.media?.input,
2652
+ aspect_ratios: {
2653
+ ...model?.media?.input?.aspect_ratios,
2654
+ values,
2655
+ },
2656
+ },
2657
+ },
2658
+ };
2659
+ });
2244
2660
  }
2245
2661
 
2246
2662
  const MODEL_ALIAS_RULES = [
@@ -2412,29 +2828,47 @@ function createGuideIntentClass(intent) {
2412
2828
  return "general";
2413
2829
  }
2414
2830
 
2831
+ // Mirrors src/hosted-create-policy.ts (#2231). budget_draft is a superset of
2832
+ // general and both are ordered ascending by charged credits, so a
2833
+ // cost-minimizing intent can never select a model priced above the general
2834
+ // default. final keeps the quality-premium lead.
2415
2835
  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
- ];
2836
+ if (intentClass === "budget_draft") {
2837
+ return [
2838
+ "fal.flux-1-schnell",
2839
+ "fal.flux-1-dev",
2840
+ "xai.grok-imagine-image",
2841
+ "fal.bria-text-to-image-fast",
2842
+ "xai.grok-imagine-image-quality",
2843
+ "openai.gpt-image-2",
2844
+ ];
2845
+ }
2846
+ if (intentClass === "final") {
2847
+ return [
2848
+ "xai.grok-imagine-image-quality",
2849
+ "openai.gpt-image-2",
2850
+ "fal.flux-1-dev",
2851
+ "xai.grok-imagine-image",
2852
+ "fal.flux-1-schnell",
2853
+ "fal.bria-text-to-image-fast",
2854
+ ];
2855
+ }
2856
+ return [
2857
+ "fal.flux-1-dev",
2858
+ "xai.grok-imagine-image",
2859
+ "xai.grok-imagine-image-quality",
2860
+ "openai.gpt-image-2",
2861
+ "fal.flux-1-schnell",
2862
+ "fal.bria-text-to-image-fast",
2863
+ ];
2429
2864
  }
2430
2865
 
2431
2866
  function guideBudgetUsdForModel(model) {
2867
+ // Budget caps compare the credit debit -- what the caller pays.
2432
2868
  const pricing = createGuideModelCreditPricing(model);
2433
2869
  return (
2434
- pricing?.estimated_revenue_usd ??
2870
+ pricing?.estimated_charge_usd ??
2435
2871
  model?.economics?.estimated_usd_per_image ??
2436
- pricing?.estimated_provider_cost_usd ??
2437
- pricing?.fallback_provider_cost_usd ??
2438
2872
  (typeof model?.estimated_usd_per_image === "number"
2439
2873
  ? model.estimated_usd_per_image
2440
2874
  : null) ??
@@ -2450,7 +2884,7 @@ function createGuideDefaultModelParameters(input) {
2450
2884
  input.model?.id === "xai.grok-imagine-image-quality" &&
2451
2885
  modelParameters.resolution === undefined
2452
2886
  ) {
2453
- // Budget defaults must compare the credit debit (estimated_revenue_usd),
2887
+ // Budget defaults must compare the credit debit (estimated_charge_usd),
2454
2888
  // the same basis the hosted budget guard enforces, not the provider cost.
2455
2889
  const twoKPricing = createGuidePricingForModel(
2456
2890
  input.model,
@@ -2459,8 +2893,8 @@ function createGuideDefaultModelParameters(input) {
2459
2893
  );
2460
2894
  const twoKAllowedByBudget =
2461
2895
  input.maxEstimatedUsdPerImage === null ||
2462
- typeof twoKPricing?.estimated_revenue_usd !== "number" ||
2463
- twoKPricing.estimated_revenue_usd <= input.maxEstimatedUsdPerImage;
2896
+ typeof twoKPricing?.estimated_charge_usd !== "number" ||
2897
+ twoKPricing.estimated_charge_usd <= input.maxEstimatedUsdPerImage;
2464
2898
  const intentClass = createGuideIntentClass(input.intent);
2465
2899
  const resolution =
2466
2900
  intentClass !== "budget_draft" && twoKAllowedByBudget ? "2k" : "1k";
@@ -2490,8 +2924,8 @@ function createGuideDefaultModelParameters(input) {
2490
2924
  );
2491
2925
  const mediumAllowedByBudget =
2492
2926
  input.maxEstimatedUsdPerImage === null ||
2493
- typeof mediumPricing?.estimated_revenue_usd !== "number" ||
2494
- mediumPricing.estimated_revenue_usd <= input.maxEstimatedUsdPerImage;
2927
+ typeof mediumPricing?.estimated_charge_usd !== "number" ||
2928
+ mediumPricing.estimated_charge_usd <= input.maxEstimatedUsdPerImage;
2495
2929
  if (mediumAllowedByBudget) {
2496
2930
  modelParameters.quality = "medium";
2497
2931
  defaultsApplied.push("quality=medium");
@@ -2502,45 +2936,17 @@ function createGuideDefaultModelParameters(input) {
2502
2936
  }
2503
2937
 
2504
2938
  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);
2939
+ if (createGuideCanPriceModelParameters(model)) {
2940
+ return createGuideXaiImagePricing(model, modelParameters, context);
2512
2941
  }
2513
- return createGuideCreditPricingForProviderCost({
2514
- providerCostUsd: estimate.estimated_provider_cost_usd,
2515
- pricingConfidence: estimate.pricing_confidence,
2516
- pricingSource: estimate.pricing_source,
2517
- });
2942
+ return createGuideModelCreditPricing(model);
2518
2943
  }
2519
2944
 
2520
2945
  function createGuideCanPriceModelParameters(model) {
2521
2946
  return String(model?.id ?? "").startsWith("xai.grok-imagine-image");
2522
2947
  }
2523
2948
 
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);
2531
- }
2532
- 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",
2540
- };
2541
- }
2542
-
2543
- function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2949
+ function createGuideXaiImagePricing(model, modelParameters, context) {
2544
2950
  const modelId = String(model?.id ?? "");
2545
2951
  const quality = modelId.includes("-quality");
2546
2952
  const edit = modelId.endsWith("-edit");
@@ -2555,11 +2961,20 @@ function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2555
2961
  ? context.referenceAssetCount
2556
2962
  : 0;
2557
2963
  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
- }
2964
+ const sourceCredits = quality
2965
+ ? XAI_IMAGE_CREDIT_PRICES.source_image.quality
2966
+ : XAI_IMAGE_CREDIT_PRICES.source_image.standard;
2967
+ const outputCredits = quality
2968
+ ? XAI_IMAGE_CREDIT_PRICES.output_image.quality[resolution]
2969
+ : XAI_IMAGE_CREDIT_PRICES.output_image.standard;
2970
+ const creditsRequired = Math.max(
2971
+ 1,
2972
+ Math.ceil(
2973
+ roundUsdMicro(
2974
+ sourceCredits * sourceImageCount + outputCredits * outputImageCount,
2975
+ ),
2976
+ ),
2977
+ );
2563
2978
  const defaultResolution =
2564
2979
  modelParameters?.resolution === undefined ||
2565
2980
  modelParameters?.resolution === null ||
@@ -2568,67 +2983,12 @@ function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2568
2983
  defaultResolution &&
2569
2984
  outputImageCount === 1 &&
2570
2985
  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
2986
  return {
2615
2987
  credits_required: creditsRequired,
2616
2988
  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",
2989
+ estimated_charge_usd: roundUsd(creditsRequired * CREDIT_UNIT_USD),
2990
+ pricing_confidence: "known",
2991
+ pricing_source: defaultShape ? "model_registry" : "model_parameters",
2632
2992
  };
2633
2993
  }
2634
2994
 
@@ -2653,10 +3013,6 @@ function roundUsd(value) {
2653
3013
  return Math.round(value * 100) / 100;
2654
3014
  }
2655
3015
 
2656
- function roundRatio(value) {
2657
- return Math.round(value * 1000) / 1000;
2658
- }
2659
-
2660
3016
  function roundUsdMicro(value) {
2661
3017
  return Math.round(value * 1_000_000) / 1_000_000;
2662
3018
  }
@@ -2682,16 +3038,8 @@ function createGuideModelCreditPricing(model) {
2682
3038
  }
2683
3039
  return {
2684
3040
  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
3041
  credit_unit_usd: CREDIT_UNIT_USD,
3042
+ estimated_charge_usd: roundUsd(model.credits_required * CREDIT_UNIT_USD),
2695
3043
  pricing_confidence:
2696
3044
  typeof model?.pricing_confidence === "string"
2697
3045
  ? model.pricing_confidence
@@ -2726,14 +3074,124 @@ function createGuideImpliesVideo(input) {
2726
3074
  );
2727
3075
  }
2728
3076
 
2729
- function createGuideSuggestedAspectRatio(model) {
2730
- if (model?.modality !== "video") {
2731
- return null;
3077
+ // Extract an explicitly requested aspect ratio from a create/edit brief.
3078
+ //
3079
+ // Precedence:
3080
+ // 1. An explicit `W:H` token from EXPLICIT_ASPECT_RATIOS wins over any
3081
+ // orientation word -- it is the most specific statement of intent.
3082
+ // 2. Otherwise a bound orientation phrase is used.
3083
+ //
3084
+ // Conflicts resolve to null (no request) rather than to a guess: two different
3085
+ // explicit ratios, or two orientation phrases implying different shapes, mean
3086
+ // the brief is ambiguous and the guide should not pick a side.
3087
+ function classifyRequestedAspectRatio(input) {
3088
+ const text = `${input?.intent ?? ""} ${input?.prompt ?? ""}`.trim();
3089
+ if (text.length === 0) {
3090
+ return { aspectRatio: null, signal: null };
3091
+ }
3092
+
3093
+ const ratioTokenIsSuppressed = RATIO_SUPPRESSING_CONTEXT.some((re) =>
3094
+ re.test(text),
3095
+ );
3096
+
3097
+ const explicit = new Set();
3098
+ if (!ratioTokenIsSuppressed) {
3099
+ for (const match of text.matchAll(ASPECT_RATIO_TOKEN_RE)) {
3100
+ const token = `${match[1]}:${match[2]}`;
3101
+ if (EXPLICIT_ASPECT_RATIOS.includes(token)) {
3102
+ explicit.add(token);
3103
+ }
3104
+ }
3105
+ }
3106
+ if (explicit.size === 1) {
3107
+ const [only] = [...explicit];
3108
+ return { aspectRatio: only ?? null, signal: "explicit_ratio_token" };
3109
+ }
3110
+ if (explicit.size > 1) {
3111
+ // Contradictory explicit ratios: refuse to guess.
3112
+ return { aspectRatio: null, signal: null };
2732
3113
  }
3114
+
3115
+ const implied = new Set();
3116
+ for (const rule of ORIENTATION_RULES) {
3117
+ if (rule.re.test(text)) {
3118
+ implied.add(rule.ratio);
3119
+ }
3120
+ }
3121
+ if (implied.size === 1) {
3122
+ const [only] = [...implied];
3123
+ return { aspectRatio: only ?? null, signal: "orientation_phrase" };
3124
+ }
3125
+ return { aspectRatio: null, signal: null };
3126
+ }
3127
+
3128
+ function requestedAspectRatioFromBrief(input) {
3129
+ return classifyRequestedAspectRatio(input).aspectRatio;
3130
+ }
3131
+
3132
+ // The normalized ratios a model publishes, or null when the payload does not
3133
+ // say. The hosted `/v1/models` list is a compact summary and omits the `media`
3134
+ // block entirely, so "not published" is the common case here and must be kept
3135
+ // distinct from "published and does not include this ratio".
3136
+ function guideModelAspectRatioValues(model) {
2733
3137
  const values = Array.isArray(model?.media?.input?.aspect_ratios?.values)
2734
3138
  ? model.media.input.aspect_ratios.values
2735
3139
  : model?.aspect_ratios;
2736
- if (!Array.isArray(values)) {
3140
+ return Array.isArray(values) ? values : null;
3141
+ }
3142
+
3143
+ // Tri-state on purpose: true (published and supported), false (published and
3144
+ // NOT supported), null (capability unknown). Unknown must never be read as a
3145
+ // denial -- doing so would make every ratio request in production claim "no
3146
+ // available model supports this", which is worse than the bug being fixed.
3147
+ function modelAspectRatioSupport(model, aspectRatio) {
3148
+ if (aspectRatio === null || aspectRatio === undefined) {
3149
+ return true;
3150
+ }
3151
+ const values = guideModelAspectRatioValues(model);
3152
+ return values === null ? null : values.includes(aspectRatio);
3153
+ }
3154
+
3155
+ function modelCannotExpressAspectRatio(model, aspectRatio) {
3156
+ return modelAspectRatioSupport(model, aspectRatio) === false;
3157
+ }
3158
+
3159
+ // Honest capability note for the case where an explicitly requested aspect
3160
+ // ratio made the guide skip its normal top pick. Name the capability that
3161
+ // forced the hop so agents learn which model could not express the shape.
3162
+ function aspectRatioRoutedCreateSelectionReason(input) {
3163
+ return `guide selected a create model that supports the requested ${input.aspectRatio} aspect ratio because ${input.skippedModelId} does not expose ${input.aspectRatio}`;
3164
+ }
3165
+
3166
+ // Fail-honestly note for the case where NO available model can express the
3167
+ // requested ratio. The guide keeps its normal selection and says so rather than
3168
+ // silently dropping the ratio, which is the exact failure this fixes.
3169
+ function aspectRatioUnsupportedWarning(input) {
3170
+ 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.`;
3171
+ }
3172
+
3173
+ function createGuideSuggestedAspectRatio(input) {
3174
+ const model = input?.model ?? null;
3175
+ const requestedAspectRatio = input?.requestedAspectRatio ?? null;
3176
+ if (model === null) {
3177
+ return null;
3178
+ }
3179
+ // An explicit request wins for every modality, but not when the selected
3180
+ // model is KNOWN not to express it. In that case return null so
3181
+ // `next_command` stays runnable (`--aspect-ratio` would fail model
3182
+ // validation) and let the caller warn instead of silently pretending the
3183
+ // ratio was honored.
3184
+ if (requestedAspectRatio !== null) {
3185
+ return modelCannotExpressAspectRatio(model, requestedAspectRatio)
3186
+ ? null
3187
+ : requestedAspectRatio;
3188
+ }
3189
+ // No explicit request: preserve the existing video-only default.
3190
+ if (model?.modality !== "video") {
3191
+ return null;
3192
+ }
3193
+ const values = guideModelAspectRatioValues(model);
3194
+ if (values === null) {
2737
3195
  return null;
2738
3196
  }
2739
3197
  return values.includes("16:9") ? "16:9" : (values[0] ?? null);
@@ -2787,9 +3245,14 @@ function createGuideSelectionReason(
2787
3245
  model?.id,
2788
3246
  )
2789
3247
  ) {
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";
3248
+ const intentClass = createGuideIntentClass(intent);
3249
+ if (intentClass === "budget_draft") {
3250
+ return "guide selected the lowest-credit available create model for a draft/budget intent";
3251
+ }
3252
+ if (intentClass === "general") {
3253
+ return "guide selected a cost-efficient general-purpose create model for iteration; pass --intent final for the quality-first model";
3254
+ }
3255
+ return "guide selected the strongest currently available quality-first create model for this intent";
2793
3256
  }
2794
3257
  return operation === "edit"
2795
3258
  ? "guide selected the first available executable input-image edit model"
@@ -4072,14 +4535,22 @@ function createGuideWarning(stage, input) {
4072
4535
  warning,
4073
4536
  };
4074
4537
  }
4538
+ // #2204-#2216: name the field this response actually carries. The compact
4539
+ // ready payload drops the `recommended_no_spend_command` alias, so it points
4540
+ // at the base field instead; blocked and `--explain` payloads keep the alias
4541
+ // and keep naming it. Either way
4542
+ // `data[data.guide_warning.recommended_command_field]` resolves.
4543
+ const noSpendField =
4544
+ input.readyCompact === true
4545
+ ? "no_spend_next_command"
4546
+ : "recommended_no_spend_command";
4075
4547
  return {
4076
4548
  ...base,
4077
4549
  next_command_safety: "live_media_create_credit_debit",
4078
4550
  no_spend_safe: false,
4079
4551
  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.",
4552
+ recommended_command_field: noSpendField,
4553
+ 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
4554
  };
4084
4555
  }
4085
4556
 
@@ -4169,6 +4640,7 @@ function createGuideNextCommand(stage, input) {
4169
4640
  prompt: input.prompt,
4170
4641
  inputReference: input.inputReference,
4171
4642
  budgetGuard: input.budgetGuard,
4643
+ aspectRatio: input.aspectRatio,
4172
4644
  modelParametersJson: input.modelParametersJson,
4173
4645
  dryRun: false,
4174
4646
  idempotencyKey: `edit-guide-${Date.now()}-${randomBytes(4).toString("hex")}`,
@@ -4228,6 +4700,7 @@ function createGuideEscapeHatches(input) {
4228
4700
  prompt: input.prompt,
4229
4701
  inputReference: input.inputReference,
4230
4702
  budgetGuard: input.budgetGuard,
4703
+ aspectRatio: input.aspectRatio,
4231
4704
  modelParametersJson: input.modelParametersJson,
4232
4705
  dryRun: true,
4233
4706
  apiBaseUrl: input.apiBaseUrl,
@@ -4257,7 +4730,7 @@ function renderGuideCommand(
4257
4730
  const operation = options.operation ?? "create";
4258
4731
  return [
4259
4732
  commandPrefix,
4260
- `${operation} --guide --prompt`,
4733
+ `${operation} --guide${options.explain === true ? " --explain" : ""} --prompt`,
4261
4734
  shellQuote(prompt),
4262
4735
  ...(operation === "edit" &&
4263
4736
  typeof options.inputReference === "string" &&
@@ -4363,6 +4836,9 @@ function renderInputImageGuideCommand(input) {
4363
4836
  "--model",
4364
4837
  shellQuote(input.modelId),
4365
4838
  ...(promptless ? [] : ["--prompt", shellQuote(input.prompt)]),
4839
+ ...(input.aspectRatio === null || input.aspectRatio === undefined
4840
+ ? []
4841
+ : ["--aspect-ratio", shellQuote(input.aspectRatio)]),
4366
4842
  "--max-estimated-usd-per-image",
4367
4843
  shellQuote(formatUsd(input.budgetGuard)),
4368
4844
  ...(input.modelParametersJson === null ||
@@ -4517,6 +4993,12 @@ async function create(argv) {
4517
4993
  "create --no-spend is a guide output mode; combine it with --guide, or use create --dry-run for a no-spend planned job",
4518
4994
  );
4519
4995
  }
4996
+ if (flagBool(args, "explain")) {
4997
+ return invalid(
4998
+ "luxin create",
4999
+ "create --explain is a guide output mode; combine it with --guide to return every guide field",
5000
+ );
5001
+ }
4520
5002
  const prompt = await promptValue(args);
4521
5003
  if (!prompt.ok) {
4522
5004
  return prompt.result;
@@ -4670,6 +5152,12 @@ async function edit(argv) {
4670
5152
  "edit --no-spend is a guide output mode; combine it with --guide, or use edit --dry-run for a no-spend planned job",
4671
5153
  );
4672
5154
  }
5155
+ if (flagBool(args, "explain")) {
5156
+ return invalid(
5157
+ "luxin edit",
5158
+ "edit --explain is a guide output mode; combine it with --guide to return every guide field",
5159
+ );
5160
+ }
4673
5161
  if (input === undefined) {
4674
5162
  return invalid(
4675
5163
  "luxin edit",
@@ -4789,7 +5277,7 @@ async function assets(argv) {
4789
5277
  if (assetId === null) {
4790
5278
  return invalid(
4791
5279
  "luxin assets",
4792
- "assets currently supports Luxin asset ids and media.image-skill.com URLs",
5280
+ `assets currently supports Luxin asset ids and ${ACCEPTED_PUBLIC_MEDIA_HOSTS.join(" or ")} URLs`,
4793
5281
  );
4794
5282
  }
4795
5283
  const token = await resolveToken(args);
@@ -7224,7 +7712,7 @@ function assetIdFromReference(reference) {
7224
7712
  const url = new URL(reference);
7225
7713
  if (
7226
7714
  url.protocol !== "https:" ||
7227
- url.hostname !== "media.image-skill.com" ||
7715
+ !ACCEPTED_PUBLIC_MEDIA_HOSTS.includes(url.hostname.toLowerCase()) ||
7228
7716
  !url.pathname.startsWith("/a/")
7229
7717
  ) {
7230
7718
  return null;