luxin-cli 0.2.2 → 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.2";
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,
@@ -552,6 +709,8 @@ function commandHelpByKey(key) {
552
709
  docs_url: "https://luxin.sh/cli.md#luxin-create",
553
710
  optional_flags: [
554
711
  "--guide",
712
+ "--no-spend",
713
+ "--explain",
555
714
  "--dry-run",
556
715
  "--model",
557
716
  "--aspect-ratio",
@@ -1752,16 +1911,61 @@ async function createGuide(args, options = {}) {
1752
1911
  return token.result;
1753
1912
  }
1754
1913
 
1755
- const selected =
1756
- models.envelope.ok && models.envelope.data?.models
1757
- ? 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, {
1758
1929
  operation: guideOperation,
1759
1930
  prompt: trimmedPrompt,
1760
1931
  intent: requestedIntent,
1761
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,
1762
1967
  })
1763
1968
  : null;
1764
- const selectedAspectRatio = createGuideSuggestedAspectRatio(selected);
1765
1969
  const pricingContext = {
1766
1970
  aspectRatio: selectedAspectRatio ?? "1:1",
1767
1971
  outputCount: 1,
@@ -1795,25 +1999,16 @@ async function createGuide(args, options = {}) {
1795
1999
  pricingContext,
1796
2000
  )
1797
2001
  : createGuideModelCreditPricing(selected);
1798
- const providerCostEstimate =
1799
- selected === null || !shouldPriceModelParameters
1800
- ? null
1801
- : createGuideProviderCostEstimateForModel(
1802
- selected,
1803
- defaultedModelParameters.modelParameters,
1804
- pricingContext,
1805
- );
1806
2002
  const estimatedCredits = pricing?.credits_required ?? null;
1807
- const estimatedProviderUsdPerImage =
1808
- 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 ??
1809
2007
  selected?.economics?.estimated_usd_per_image ??
1810
- pricing?.fallback_provider_cost_usd ??
1811
2008
  (typeof selected?.estimated_usd_per_image === "number"
1812
2009
  ? selected.estimated_usd_per_image
1813
2010
  : null) ??
1814
2011
  null;
1815
- const estimatedDebitUsdPerImage =
1816
- pricing?.estimated_revenue_usd ?? estimatedProviderUsdPerImage;
1817
2012
  const budgetGuard =
1818
2013
  maxEstimatedUsdPerImage ??
1819
2014
  estimatedDebitUsdPerImage ??
@@ -1866,6 +2061,14 @@ async function createGuide(args, options = {}) {
1866
2061
  quota,
1867
2062
  estimatedCredits,
1868
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;
1869
2072
  const nextCommand = createGuideNextCommand(stage, {
1870
2073
  prompt: trimmedPrompt,
1871
2074
  selected,
@@ -1924,11 +2127,21 @@ async function createGuide(args, options = {}) {
1924
2127
  noSpendNextCommandLabel,
1925
2128
  noSpendNextCommandEffect,
1926
2129
  });
1927
- const guideWarning = createGuideWarning(stage, {
2130
+ const baseGuideWarning = createGuideWarning(stage, {
1928
2131
  nextCommandEffect,
1929
2132
  paymentSummary,
1930
2133
  nextCommandCopyRunnable,
2134
+ readyCompact,
1931
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
+ };
1932
2145
  const quotaTopUp = createGuideQuotaTopUp(
1933
2146
  quota?.envelope.data?.top_up ?? null,
1934
2147
  );
@@ -1984,15 +2197,117 @@ async function createGuide(args, options = {}) {
1984
2197
  paymentSummary,
1985
2198
  selfFundPreparation,
1986
2199
  );
2200
+ // #2194: no-spend output mode. Omit the live self-fund and payment command
2201
+ // templates when the agent opts into a no-spend/dry-run study so the safe
2202
+ // dry-run is not buried under quote/buy/payment workflow commands. Retain the
2203
+ // dry-run command and the mutation proof, and keep the self-fund path
2204
+ // discoverable via self_fund_discovery instead of dominating the response.
2205
+ const noSpendOutputMode = flagBool(args, "no-spend");
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;
2216
+ const selfFundHandoffOutput = noSpendOutputMode ? null : selfFundHandoff;
2217
+ const selfFundPreparationOutput = noSpendOutputMode
2218
+ ? null
2219
+ : selfFundPreparation;
2220
+ const selfFundNextCommandOutput = noSpendOutputMode
2221
+ ? null
2222
+ : selfFundNextCommand;
2223
+ const selfFundNextCommandLabelOutput = noSpendOutputMode
2224
+ ? null
2225
+ : selfFundNextCommandLabel;
2226
+ const noSpendOutputModeData = noSpendOutputMode
2227
+ ? {
2228
+ enabled: true,
2229
+ suppressed_fields: [
2230
+ "self_fund_next_command",
2231
+ "self_fund_next_command_label",
2232
+ "self_fund_handoff",
2233
+ "self_fund_preparation",
2234
+ "checks.payments.suggested_commands",
2235
+ ],
2236
+ self_fund_discovery: {
2237
+ rerun_with_funding_command: renderGuideCommand(
2238
+ trimmedPrompt.length > 0 ? trimmedPrompt : "PROMPT",
2239
+ explicitApiBaseUrl(args),
2240
+ guideCommandPrefix,
2241
+ {
2242
+ operation: guideOperation,
2243
+ inputReference: options.inputReference,
2244
+ modelId: requestedModelId,
2245
+ providerId: requestedProviderId,
2246
+ intent: requestedIntentFlag,
2247
+ maxEstimatedUsdPerImage,
2248
+ modelParametersJson: requestedModelParametersJson,
2249
+ },
2250
+ ),
2251
+ inspect_methods_command: escapeHatches.payment_methods,
2252
+ note: noSpendDiscoveryNote(readyCompact),
2253
+ },
2254
+ }
2255
+ : {
2256
+ enabled: false,
2257
+ suppressed_fields: [],
2258
+ self_fund_discovery: null,
2259
+ };
1987
2260
  const guideRecovery = createGuideRecovery(stage, {
1988
2261
  blocker,
1989
2262
  nextCommand,
1990
2263
  noSpendNextCommand,
1991
2264
  afterNext,
1992
2265
  escapeHatches,
1993
- selfFundNextCommand,
2266
+ selfFundNextCommand: selfFundNextCommandOutput,
1994
2267
  });
1995
- 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 = {
1996
2311
  schema:
1997
2312
  guideOperation === "edit"
1998
2313
  ? "image-skill.edit-guide.v1"
@@ -2032,7 +2347,7 @@ async function createGuide(args, options = {}) {
2032
2347
  required_credits: estimatedCredits,
2033
2348
  daily_jobs_remaining:
2034
2349
  quota?.envelope.data?.daily_jobs?.remaining ?? null,
2035
- top_up: quotaTopUp,
2350
+ top_up: readyCompact ? null : quotaTopUp,
2036
2351
  reason:
2037
2352
  quota === null
2038
2353
  ? "auth_required"
@@ -2040,7 +2355,7 @@ async function createGuide(args, options = {}) {
2040
2355
  ? null
2041
2356
  : (quota.envelope.error?.code ?? "quota_unavailable"),
2042
2357
  },
2043
- payments: paymentSummary,
2358
+ payments: paymentSummaryOutput,
2044
2359
  },
2045
2360
  selection:
2046
2361
  selected === null
@@ -2055,20 +2370,28 @@ async function createGuide(args, options = {}) {
2055
2370
  modality: selected.modality ?? null,
2056
2371
  suggested_aspect_ratio: selectedAspectRatio,
2057
2372
  reason:
2058
- requestedModelId === null
2059
- ? createGuideSelectionReason(
2060
- selected,
2061
- trimmedPrompt,
2062
- requestedIntent,
2063
- guideOperation,
2064
- )
2065
- : 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
+ ),
2066
2390
  },
2067
2391
  cost: {
2068
2392
  estimated_credits: estimatedCredits,
2069
2393
  estimated_usd_per_image: estimatedDebitUsdPerImage,
2070
2394
  estimated_debit_usd_per_image: estimatedDebitUsdPerImage,
2071
- estimated_provider_usd_per_image: estimatedProviderUsdPerImage,
2072
2395
  credit_unit_usd: pricing?.credit_unit_usd ?? CREDIT_UNIT_USD,
2073
2396
  pricing_confidence: pricing?.pricing_confidence ?? null,
2074
2397
  pricing_source: pricing?.pricing_source ?? null,
@@ -2090,13 +2413,15 @@ async function createGuide(args, options = {}) {
2090
2413
  recommended_no_spend_command: noSpendNextCommand,
2091
2414
  recommended_no_spend_command_label: noSpendNextCommandLabel,
2092
2415
  recommended_no_spend_command_effect: noSpendNextCommandEffect,
2093
- self_fund_next_command: selfFundNextCommand,
2094
- self_fund_next_command_label: selfFundNextCommandLabel,
2095
- self_fund_handoff: selfFundHandoff,
2096
- self_fund_preparation: selfFundPreparation,
2416
+ self_fund_next_command: selfFundNextCommandOutput,
2417
+ self_fund_next_command_label: selfFundNextCommandLabelOutput,
2418
+ self_fund_handoff: selfFundHandoffOutput,
2419
+ self_fund_preparation: selfFundPreparationOutput,
2097
2420
  after_next: afterNext,
2098
2421
  auth_handoff: authHandoff,
2099
2422
  escape_hatches: escapeHatches,
2423
+ no_spend_output_mode: noSpendOutputModeData,
2424
+ output_mode: outputModeData,
2100
2425
  mutation: {
2101
2426
  provider_call: false,
2102
2427
  hosted_create: false,
@@ -2105,7 +2430,11 @@ async function createGuide(args, options = {}) {
2105
2430
  credit_debit: false,
2106
2431
  media_write: false,
2107
2432
  },
2108
- });
2433
+ };
2434
+ for (const field of compactOmittedFields) {
2435
+ delete guideData[field];
2436
+ }
2437
+ return createGuideSuccess(command, quota?.envelope.actor ?? null, guideData);
2109
2438
  }
2110
2439
 
2111
2440
  function createGuideSuccess(command, actor, data) {
@@ -2122,6 +2451,12 @@ function selectCreateGuideModel(
2122
2451
  prompt = "",
2123
2452
  intent = undefined,
2124
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,
2125
2460
  } = {},
2126
2461
  ) {
2127
2462
  const isExecutableCreate = (model) =>
@@ -2139,10 +2474,18 @@ function selectCreateGuideModel(
2139
2474
  operation === "edit"
2140
2475
  ? isExecutableInputImageEdit(model)
2141
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 };
2142
2485
  if (requestedModelId !== null) {
2143
2486
  const requested = resolveModelLookup(requestedModelId, models);
2144
2487
  return requested !== undefined && isExecutableGuideModel(requested)
2145
- ? requested
2488
+ ? chosen(requested)
2146
2489
  : null;
2147
2490
  }
2148
2491
  const candidates =
@@ -2159,7 +2502,7 @@ function selectCreateGuideModel(
2159
2502
  });
2160
2503
  const threeDimensional = eligible3d[0];
2161
2504
  if (threeDimensional !== undefined) {
2162
- return threeDimensional;
2505
+ return chosen(threeDimensional);
2163
2506
  }
2164
2507
  }
2165
2508
  const eligible = guideCandidatesWithinBudget({
@@ -2169,23 +2512,151 @@ function selectCreateGuideModel(
2169
2512
  if (createGuideImpliesAudio({ prompt, intent })) {
2170
2513
  const audio = eligible.find((model) => model?.modality === "audio");
2171
2514
  if (audio !== undefined) {
2172
- return audio;
2515
+ return chosen(audio);
2173
2516
  }
2174
2517
  }
2175
2518
  if (createGuideImpliesVideo({ prompt, intent })) {
2176
- 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];
2177
2526
  if (video !== undefined) {
2178
- return video;
2527
+ return chosen(video);
2179
2528
  }
2180
2529
  }
2181
2530
  const intentClass = createGuideIntentClass(intent);
2182
- for (const modelId of preferredCreateGuideModelIds(intentClass)) {
2183
- const preferred = eligible.find((model) => model?.id === modelId);
2184
- if (preferred !== undefined) {
2185
- 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
+ });
2186
2566
  }
2187
2567
  }
2188
- 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
+ });
2189
2660
  }
2190
2661
 
2191
2662
  const MODEL_ALIAS_RULES = [
@@ -2357,29 +2828,47 @@ function createGuideIntentClass(intent) {
2357
2828
  return "general";
2358
2829
  }
2359
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.
2360
2835
  function preferredCreateGuideModelIds(intentClass) {
2361
- return intentClass === "budget_draft"
2362
- ? [
2363
- "fal.flux-dev",
2364
- "xai.grok-imagine-image-quality",
2365
- "xai.grok-imagine-image",
2366
- "openai.gpt-image-2",
2367
- ]
2368
- : [
2369
- "xai.grok-imagine-image-quality",
2370
- "fal.flux-dev",
2371
- "xai.grok-imagine-image",
2372
- "openai.gpt-image-2",
2373
- ];
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
+ ];
2374
2864
  }
2375
2865
 
2376
2866
  function guideBudgetUsdForModel(model) {
2867
+ // Budget caps compare the credit debit -- what the caller pays.
2377
2868
  const pricing = createGuideModelCreditPricing(model);
2378
2869
  return (
2379
- pricing?.estimated_revenue_usd ??
2870
+ pricing?.estimated_charge_usd ??
2380
2871
  model?.economics?.estimated_usd_per_image ??
2381
- pricing?.estimated_provider_cost_usd ??
2382
- pricing?.fallback_provider_cost_usd ??
2383
2872
  (typeof model?.estimated_usd_per_image === "number"
2384
2873
  ? model.estimated_usd_per_image
2385
2874
  : null) ??
@@ -2395,7 +2884,7 @@ function createGuideDefaultModelParameters(input) {
2395
2884
  input.model?.id === "xai.grok-imagine-image-quality" &&
2396
2885
  modelParameters.resolution === undefined
2397
2886
  ) {
2398
- // Budget defaults must compare the credit debit (estimated_revenue_usd),
2887
+ // Budget defaults must compare the credit debit (estimated_charge_usd),
2399
2888
  // the same basis the hosted budget guard enforces, not the provider cost.
2400
2889
  const twoKPricing = createGuidePricingForModel(
2401
2890
  input.model,
@@ -2404,8 +2893,8 @@ function createGuideDefaultModelParameters(input) {
2404
2893
  );
2405
2894
  const twoKAllowedByBudget =
2406
2895
  input.maxEstimatedUsdPerImage === null ||
2407
- typeof twoKPricing?.estimated_revenue_usd !== "number" ||
2408
- twoKPricing.estimated_revenue_usd <= input.maxEstimatedUsdPerImage;
2896
+ typeof twoKPricing?.estimated_charge_usd !== "number" ||
2897
+ twoKPricing.estimated_charge_usd <= input.maxEstimatedUsdPerImage;
2409
2898
  const intentClass = createGuideIntentClass(input.intent);
2410
2899
  const resolution =
2411
2900
  intentClass !== "budget_draft" && twoKAllowedByBudget ? "2k" : "1k";
@@ -2435,8 +2924,8 @@ function createGuideDefaultModelParameters(input) {
2435
2924
  );
2436
2925
  const mediumAllowedByBudget =
2437
2926
  input.maxEstimatedUsdPerImage === null ||
2438
- typeof mediumPricing?.estimated_revenue_usd !== "number" ||
2439
- mediumPricing.estimated_revenue_usd <= input.maxEstimatedUsdPerImage;
2927
+ typeof mediumPricing?.estimated_charge_usd !== "number" ||
2928
+ mediumPricing.estimated_charge_usd <= input.maxEstimatedUsdPerImage;
2440
2929
  if (mediumAllowedByBudget) {
2441
2930
  modelParameters.quality = "medium";
2442
2931
  defaultsApplied.push("quality=medium");
@@ -2447,45 +2936,17 @@ function createGuideDefaultModelParameters(input) {
2447
2936
  }
2448
2937
 
2449
2938
  function createGuidePricingForModel(model, modelParameters, context = {}) {
2450
- const estimate = createGuideProviderCostEstimateForModel(
2451
- model,
2452
- modelParameters,
2453
- context,
2454
- );
2455
- if (estimate.estimated_provider_cost_usd === null) {
2456
- return createGuideModelCreditPricing(model);
2939
+ if (createGuideCanPriceModelParameters(model)) {
2940
+ return createGuideXaiImagePricing(model, modelParameters, context);
2457
2941
  }
2458
- return createGuideCreditPricingForProviderCost({
2459
- providerCostUsd: estimate.estimated_provider_cost_usd,
2460
- pricingConfidence: estimate.pricing_confidence,
2461
- pricingSource: estimate.pricing_source,
2462
- });
2942
+ return createGuideModelCreditPricing(model);
2463
2943
  }
2464
2944
 
2465
2945
  function createGuideCanPriceModelParameters(model) {
2466
2946
  return String(model?.id ?? "").startsWith("xai.grok-imagine-image");
2467
2947
  }
2468
2948
 
2469
- function createGuideProviderCostEstimateForModel(
2470
- model,
2471
- modelParameters = {},
2472
- context = {},
2473
- ) {
2474
- if (String(model?.id ?? "").startsWith("xai.grok-imagine-image")) {
2475
- return createGuideXaiImageCostEstimate(model, modelParameters, context);
2476
- }
2477
- return {
2478
- estimated_provider_cost_usd:
2479
- typeof model?.economics?.estimated_usd_per_image === "number"
2480
- ? model.economics.estimated_usd_per_image
2481
- : (createGuideModelCreditPricing(model)?.estimated_provider_cost_usd ??
2482
- null),
2483
- pricing_source: "model_registry",
2484
- pricing_confidence: "known",
2485
- };
2486
- }
2487
-
2488
- function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2949
+ function createGuideXaiImagePricing(model, modelParameters, context) {
2489
2950
  const modelId = String(model?.id ?? "");
2490
2951
  const quality = modelId.includes("-quality");
2491
2952
  const edit = modelId.endsWith("-edit");
@@ -2500,11 +2961,20 @@ function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2500
2961
  ? context.referenceAssetCount
2501
2962
  : 0;
2502
2963
  const sourceImageCount = edit ? 1 + referenceAssetCount : 0;
2503
- const inputUsdPerImage = quality ? 0.01 : 0.002;
2504
- let outputUsdPerImage = 0.02;
2505
- if (quality) {
2506
- outputUsdPerImage = resolution === "2k" ? 0.07 : 0.05;
2507
- }
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
+ );
2508
2978
  const defaultResolution =
2509
2979
  modelParameters?.resolution === undefined ||
2510
2980
  modelParameters?.resolution === null ||
@@ -2513,67 +2983,12 @@ function createGuideXaiImageCostEstimate(model, modelParameters, context) {
2513
2983
  defaultResolution &&
2514
2984
  outputImageCount === 1 &&
2515
2985
  sourceImageCount === (edit ? 1 : 0);
2516
- return {
2517
- estimated_provider_cost_usd: roundUsdMicro(
2518
- inputUsdPerImage * sourceImageCount +
2519
- outputUsdPerImage * outputImageCount,
2520
- ),
2521
- pricing_source: defaultShape ? "model_registry" : "model_parameters",
2522
- pricing_confidence: "known",
2523
- };
2524
- }
2525
-
2526
- function createGuideCreditPricingForProviderCost(input) {
2527
- const providerCostUsd = roundUsdMicro(input.providerCostUsd);
2528
- const creditsRequired = Math.max(
2529
- 1,
2530
- Math.ceil(
2531
- roundUsdMicro(
2532
- providerCostUsd / (1 - TARGET_GROSS_MARGIN) / CREDIT_UNIT_USD,
2533
- ),
2534
- ),
2535
- );
2536
- const estimatedRevenueUsd = roundUsd(creditsRequired * CREDIT_UNIT_USD);
2537
- const estimatedPaymentFeeUsd = roundUsdMicro(
2538
- estimatedRevenueUsd * PAYMENT_BACKED_CREDIT_PAYMENT_FEE_RATE,
2539
- );
2540
- const estimatedNetRevenueUsd = roundUsdMicro(
2541
- estimatedRevenueUsd - estimatedPaymentFeeUsd,
2542
- );
2543
- const estimatedGrossMargin =
2544
- estimatedRevenueUsd > 0
2545
- ? roundRatio(
2546
- (estimatedRevenueUsd - providerCostUsd) / estimatedRevenueUsd,
2547
- )
2548
- : null;
2549
- const estimatedFeeAdjustedMargin =
2550
- estimatedRevenueUsd > 0
2551
- ? roundRatio(
2552
- (estimatedNetRevenueUsd - providerCostUsd) / estimatedRevenueUsd,
2553
- )
2554
- : null;
2555
- const selfFundBlockReason =
2556
- estimatedNetRevenueUsd + 1e-9 < providerCostUsd
2557
- ? "payment_fee_margin_negative"
2558
- : null;
2559
2986
  return {
2560
2987
  credits_required: creditsRequired,
2561
2988
  credit_unit_usd: CREDIT_UNIT_USD,
2562
- estimated_provider_cost_usd: providerCostUsd,
2563
- fallback_provider_cost_usd: null,
2564
- estimated_revenue_usd: estimatedRevenueUsd,
2565
- estimated_gross_margin: estimatedGrossMargin,
2566
- payment_fee_rate: PAYMENT_BACKED_CREDIT_PAYMENT_FEE_RATE,
2567
- payment_fee_model: PAYMENT_BACKED_CREDIT_PAYMENT_FEE_MODEL,
2568
- estimated_payment_fee_usd: estimatedPaymentFeeUsd,
2569
- estimated_net_revenue_usd: estimatedNetRevenueUsd,
2570
- estimated_fee_adjusted_margin: estimatedFeeAdjustedMargin,
2571
- self_fundable: selfFundBlockReason === null,
2572
- self_fund_block_reason: selfFundBlockReason,
2573
- target_gross_margin: TARGET_GROSS_MARGIN,
2574
- pricing_confidence: input.pricingConfidence,
2575
- pricing_source: input.pricingSource,
2576
- 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",
2577
2992
  };
2578
2993
  }
2579
2994
 
@@ -2598,10 +3013,6 @@ function roundUsd(value) {
2598
3013
  return Math.round(value * 100) / 100;
2599
3014
  }
2600
3015
 
2601
- function roundRatio(value) {
2602
- return Math.round(value * 1000) / 1000;
2603
- }
2604
-
2605
3016
  function roundUsdMicro(value) {
2606
3017
  return Math.round(value * 1_000_000) / 1_000_000;
2607
3018
  }
@@ -2627,16 +3038,8 @@ function createGuideModelCreditPricing(model) {
2627
3038
  }
2628
3039
  return {
2629
3040
  credits_required: model.credits_required,
2630
- estimated_revenue_usd:
2631
- typeof model.credits_required === "number"
2632
- ? model.credits_required * CREDIT_UNIT_USD
2633
- : null,
2634
- estimated_provider_cost_usd:
2635
- typeof model?.estimated_usd_per_image === "number"
2636
- ? model.estimated_usd_per_image
2637
- : null,
2638
- fallback_provider_cost_usd: null,
2639
3041
  credit_unit_usd: CREDIT_UNIT_USD,
3042
+ estimated_charge_usd: roundUsd(model.credits_required * CREDIT_UNIT_USD),
2640
3043
  pricing_confidence:
2641
3044
  typeof model?.pricing_confidence === "string"
2642
3045
  ? model.pricing_confidence
@@ -2671,14 +3074,124 @@ function createGuideImpliesVideo(input) {
2671
3074
  );
2672
3075
  }
2673
3076
 
2674
- function createGuideSuggestedAspectRatio(model) {
2675
- if (model?.modality !== "video") {
2676
- 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 };
2677
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 };
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) {
2678
3137
  const values = Array.isArray(model?.media?.input?.aspect_ratios?.values)
2679
3138
  ? model.media.input.aspect_ratios.values
2680
3139
  : model?.aspect_ratios;
2681
- 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) {
2682
3195
  return null;
2683
3196
  }
2684
3197
  return values.includes("16:9") ? "16:9" : (values[0] ?? null);
@@ -2732,9 +3245,14 @@ function createGuideSelectionReason(
2732
3245
  model?.id,
2733
3246
  )
2734
3247
  ) {
2735
- return createGuideIntentClass(intent) === "budget_draft"
2736
- ? "guide selected a draft/budget create model with high-definition defaults"
2737
- : "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";
2738
3256
  }
2739
3257
  return operation === "edit"
2740
3258
  ? "guide selected the first available executable input-image edit model"
@@ -3389,7 +3907,24 @@ function createGuideBlocker(stage, input) {
3389
3907
  };
3390
3908
  }
3391
3909
  if (stage === "quota_required") {
3392
- const remaining = quotaRemainingCredits(input.quota?.envelope.data ?? null);
3910
+ const quotaData = input.quota?.envelope.data ?? null;
3911
+ const remaining = quotaRemainingCredits(quotaData);
3912
+ // Mirror createGuideStage's credit-first ordering: credits only outrank the
3913
+ // daily-job cap when they are genuinely short. Agents that hold spare
3914
+ // credits but hit the daily allowance (issues #2103, #2148) must be told the
3915
+ // honest trigger, not a credits-shortfall line that reads as wrong to them
3916
+ // (same confusion class as #1193).
3917
+ const creditsShort =
3918
+ input.estimatedCredits !== null &&
3919
+ remaining !== null &&
3920
+ remaining < input.estimatedCredits;
3921
+ const dailyJobs = quotaData?.daily_jobs;
3922
+ if (!creditsShort && dailyJobs !== undefined && dailyJobs.remaining <= 0) {
3923
+ return {
3924
+ code: "quota_required",
3925
+ message: `You've reached today's job allowance (${dailyJobs.cap}/day); a top-up raises your daily allowance so you can keep creating now.`,
3926
+ };
3927
+ }
3393
3928
  return {
3394
3929
  code: "quota_required",
3395
3930
  message: `Selected first image requires ${input.estimatedCredits ?? "unknown"} credits; current remaining credits are ${remaining ?? "unknown"}.`,
@@ -4000,14 +4535,22 @@ function createGuideWarning(stage, input) {
4000
4535
  warning,
4001
4536
  };
4002
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";
4003
4547
  return {
4004
4548
  ...base,
4005
4549
  next_command_safety: "live_media_create_credit_debit",
4006
4550
  no_spend_safe: false,
4007
4551
  spend_required: true,
4008
- recommended_command_field: "recommended_no_spend_command",
4009
- warning:
4010
- "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}.`,
4011
4554
  };
4012
4555
  }
4013
4556
 
@@ -4097,6 +4640,7 @@ function createGuideNextCommand(stage, input) {
4097
4640
  prompt: input.prompt,
4098
4641
  inputReference: input.inputReference,
4099
4642
  budgetGuard: input.budgetGuard,
4643
+ aspectRatio: input.aspectRatio,
4100
4644
  modelParametersJson: input.modelParametersJson,
4101
4645
  dryRun: false,
4102
4646
  idempotencyKey: `edit-guide-${Date.now()}-${randomBytes(4).toString("hex")}`,
@@ -4156,6 +4700,7 @@ function createGuideEscapeHatches(input) {
4156
4700
  prompt: input.prompt,
4157
4701
  inputReference: input.inputReference,
4158
4702
  budgetGuard: input.budgetGuard,
4703
+ aspectRatio: input.aspectRatio,
4159
4704
  modelParametersJson: input.modelParametersJson,
4160
4705
  dryRun: true,
4161
4706
  apiBaseUrl: input.apiBaseUrl,
@@ -4185,7 +4730,7 @@ function renderGuideCommand(
4185
4730
  const operation = options.operation ?? "create";
4186
4731
  return [
4187
4732
  commandPrefix,
4188
- `${operation} --guide --prompt`,
4733
+ `${operation} --guide${options.explain === true ? " --explain" : ""} --prompt`,
4189
4734
  shellQuote(prompt),
4190
4735
  ...(operation === "edit" &&
4191
4736
  typeof options.inputReference === "string" &&
@@ -4291,6 +4836,9 @@ function renderInputImageGuideCommand(input) {
4291
4836
  "--model",
4292
4837
  shellQuote(input.modelId),
4293
4838
  ...(promptless ? [] : ["--prompt", shellQuote(input.prompt)]),
4839
+ ...(input.aspectRatio === null || input.aspectRatio === undefined
4840
+ ? []
4841
+ : ["--aspect-ratio", shellQuote(input.aspectRatio)]),
4294
4842
  "--max-estimated-usd-per-image",
4295
4843
  shellQuote(formatUsd(input.budgetGuard)),
4296
4844
  ...(input.modelParametersJson === null ||
@@ -4439,6 +4987,18 @@ async function create(argv) {
4439
4987
  if (flagBool(args, "guide")) {
4440
4988
  return createGuide(args);
4441
4989
  }
4990
+ if (flagBool(args, "no-spend")) {
4991
+ return invalid(
4992
+ "luxin create",
4993
+ "create --no-spend is a guide output mode; combine it with --guide, or use create --dry-run for a no-spend planned job",
4994
+ );
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
+ }
4442
5002
  const prompt = await promptValue(args);
4443
5003
  if (!prompt.ok) {
4444
5004
  return prompt.result;
@@ -4586,6 +5146,18 @@ async function edit(argv) {
4586
5146
  inputReference: input,
4587
5147
  });
4588
5148
  }
5149
+ if (flagBool(args, "no-spend")) {
5150
+ return invalid(
5151
+ "luxin edit",
5152
+ "edit --no-spend is a guide output mode; combine it with --guide, or use edit --dry-run for a no-spend planned job",
5153
+ );
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
+ }
4589
5161
  if (input === undefined) {
4590
5162
  return invalid(
4591
5163
  "luxin edit",
@@ -4705,7 +5277,7 @@ async function assets(argv) {
4705
5277
  if (assetId === null) {
4706
5278
  return invalid(
4707
5279
  "luxin assets",
4708
- "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`,
4709
5281
  );
4710
5282
  }
4711
5283
  const token = await resolveToken(args);
@@ -6967,6 +7539,16 @@ function quotaRecoveryWithCopyRunnableCommands(recovery, commandPrefix) {
6967
7539
  ),
6968
7540
  }
6969
7541
  : {}),
7542
+ ...(recovery.human_handoff &&
7543
+ typeof recovery.human_handoff === "object" &&
7544
+ typeof recovery.human_handoff.command === "string"
7545
+ ? {
7546
+ human_handoff: {
7547
+ ...recovery.human_handoff,
7548
+ command: render(recovery.human_handoff.command),
7549
+ },
7550
+ }
7551
+ : {}),
6970
7552
  top_up: quotaTopUpWithCopyRunnableCommands(recovery.top_up, commandPrefix),
6971
7553
  };
6972
7554
  }
@@ -7130,7 +7712,7 @@ function assetIdFromReference(reference) {
7130
7712
  const url = new URL(reference);
7131
7713
  if (
7132
7714
  url.protocol !== "https:" ||
7133
- url.hostname !== "media.image-skill.com" ||
7715
+ !ACCEPTED_PUBLIC_MEDIA_HOSTS.includes(url.hostname.toLowerCase()) ||
7134
7716
  !url.pathname.startsWith("/a/")
7135
7717
  ) {
7136
7718
  return null;