Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,8 @@ status report instead of JSON. Single-file outputs get a per-file
task that is not yet SUCCEEDED yields `downloads.state: "not_ready"` with exit 0.

```bash
meshy-cli text-to-image create --ai-model nano-banana --prompt "a leaf" -o assets/leaf.jpeg
meshy-cli text-to-image create --ai-model nano-banana-2-lite --prompt "a leaf" -o assets/leaf.jpeg
meshy-cli text-to-image create --ai-model gpt-image-2-5-sunburst --aspect-ratio 3:2 --prompt "a leaf" -o assets/leaf-wide.png
meshy-cli image-to-3d wait <id> -o out/robot/
```

Expand Down
39 changes: 26 additions & 13 deletions src/internal/image-models.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,50 @@
/**
* Shared model/aspect-ratio table for the 2D image commands, so text-to-image
* and image-to-image can't drift apart as Meshy ships new models.
*
* The ratio rules mirror the server's own per-model check
* (meshyd pkg/server/api_image_generation_handlers.go): every GPT Image model
* accepts all seven ratios; the nano-banana family rejects 3:2 and 2:3.
*/

import { UsageError } from "./errors.js";

export const IMAGE_MODELS = [
"gpt-image-2",
"gpt-image-2-5-flare",
"gpt-image-2-5-sunburst",
"nano-banana-pro",
"nano-banana-2",
"nano-banana-2-lite",
"nano-banana",
] as const;

export const DEFAULT_IMAGE_MODEL = "gpt-image-2";

/** OpenAI GPT Image models of any generation — the ones that take every aspect ratio. */
export const GPT_IMAGE_MODELS = ["gpt-image-2", "gpt-image-2-5-flare", "gpt-image-2-5-sunburst"] as const;

export const IMAGE_MODEL_HELP =
"gpt-image-2 (default, best; ratios 1:1|16:9|9:16|3:2|2:3) | nano-banana-pro | nano-banana-2 | " +
"nano-banana-2-lite (fastest, cheapest) | nano-banana (legacy)";
"gpt-image-2 (default) | gpt-image-2-5-flare (GPT Image 2.5, fast tier) | " +
"gpt-image-2-5-sunburst (GPT Image 2.5, precision-edit tier) | nano-banana-pro | " +
"nano-banana-2 | nano-banana-2-lite (fastest, cheapest)";

export const ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] as const;

export const ASPECT_RATIO_HELP =
"default: 1:1. nano-banana*: 1:1|16:9|9:16|4:3|3:4. " +
"gpt-image-2: 1:1|16:9|9:16|3:2|2:3. " +
"default: 1:1. gpt-image-*: 1:1|16:9|9:16|4:3|3:4|3:2|2:3. " +
"nano-banana*: 1:1|16:9|9:16|4:3|3:4. " +
"Mutually exclusive with --generate-multi-view";

const GPT_IMAGE_2_RATIOS = new Set(["1:1", "16:9", "9:16", "3:2", "2:3"]);
const NANO_BANANA_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4"]);
const GPT_IMAGE_MODEL_SET: ReadonlySet<string> = new Set(GPT_IMAGE_MODELS);
const NANO_BANANA_RATIOS: ReadonlySet<string> = new Set(["1:1", "16:9", "9:16", "4:3", "3:4"]);

export function isGptImageModel(model: string): boolean {
return GPT_IMAGE_MODEL_SET.has(model);
}

/**
* Cross-validate model × aspect-ratio × multi-view before any credits are
* spent; the API's own 400 is less specific. When the model is unknown at
* spent, with a message that names the fix. When the model is unknown at
* parse time (driven via --data), the ratio check is left to the server.
*/
export function checkImageCombo(opts: {
Expand All @@ -44,12 +57,12 @@ export function checkImageCombo(opts: {
}
const ratio = opts.aspectRatio;
if (!ratio || !opts.aiModel) return;
const supported = opts.aiModel === "gpt-image-2" ? GPT_IMAGE_2_RATIOS : NANO_BANANA_RATIOS;
if (!supported.has(ratio)) {
// GPT Image models (2 and 2.5) accept every ratio the flag offers; only the
// nano-banana family is narrower.
if (isGptImageModel(opts.aiModel)) return;
if (!NANO_BANANA_RATIOS.has(ratio)) {
throw new UsageError(
opts.aiModel === "gpt-image-2"
? `--aspect-ratio ${ratio} is not supported by gpt-image-2 (use 1:1, 16:9, 9:16, 3:2, or 2:3)`
: `--aspect-ratio ${ratio} is only supported by gpt-image-2`,
`--aspect-ratio ${ratio} is only supported by the GPT Image models (${GPT_IMAGE_MODELS.join(", ")})`,
);
}
}
56 changes: 40 additions & 16 deletions tests/image-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,65 @@

import test from "node:test";
import assert from "node:assert/strict";
import { checkImageCombo, DEFAULT_IMAGE_MODEL, IMAGE_MODELS } from "../src/internal/image-models.js";
import {
ASPECT_RATIOS,
checkImageCombo,
DEFAULT_IMAGE_MODEL,
GPT_IMAGE_MODELS,
IMAGE_MODELS,
isGptImageModel,
} from "../src/internal/image-models.js";
import { UsageError } from "../src/internal/errors.js";

const NANO_BANANA_MODELS = ["nano-banana-2", "nano-banana-2-lite", "nano-banana-pro"];

test("gpt-image-2 is the default model and part of the model list", () => {
assert.equal(DEFAULT_IMAGE_MODEL, "gpt-image-2");
assert.ok((IMAGE_MODELS as readonly string[]).includes(DEFAULT_IMAGE_MODEL));
});

test("the model list covers both GPT Image 2.5 tiers", () => {
for (const model of ["gpt-image-2-5-flare", "gpt-image-2-5-sunburst"]) {
assert.ok((IMAGE_MODELS as readonly string[]).includes(model), `missing ${model}`);
assert.ok((GPT_IMAGE_MODELS as readonly string[]).includes(model), `${model} is not a GPT Image model`);
assert.ok(isGptImageModel(model));
}
// Every model is classified exactly one way, so a new entry can't fall
// through the ratio check unnoticed.
for (const model of IMAGE_MODELS) {
assert.equal(isGptImageModel(model), !NANO_BANANA_MODELS.includes(model), model);
}
});

test("checkImageCombo — multi-view excludes aspect-ratio", () => {
assert.throws(
() => checkImageCombo({ aiModel: "nano-banana", aspectRatio: "1:1", generateMultiView: true }),
() => checkImageCombo({ aiModel: "nano-banana-2-lite", aspectRatio: "1:1", generateMultiView: true }),
UsageError,
);
// multi-view without a ratio is fine
checkImageCombo({ aiModel: "nano-banana", generateMultiView: true });
checkImageCombo({ aiModel: "nano-banana-2-lite", generateMultiView: true });
});

test("checkImageCombo — gpt-image-2 accepts square, widescreen, and native ratios", () => {
for (const ratio of ["1:1", "16:9", "9:16", "3:2", "2:3"]) {
checkImageCombo({ aiModel: "gpt-image-2", aspectRatio: ratio });
}
for (const ratio of ["4:3", "3:4"]) {
assert.throws(
() => checkImageCombo({ aiModel: "gpt-image-2", aspectRatio: ratio }),
/not supported by gpt-image-2/,
);
test("checkImageCombo — every GPT Image model accepts every aspect ratio the flag offers", () => {
// Mirrors meshyd: the server's per-model check lets gpt-image-2 and both 2.5
// tiers through for 1:1/16:9/9:16/4:3/3:4/3:2/2:3 (verified live: gpt-image-2
// + 4:3 is accepted with 202).
for (const model of GPT_IMAGE_MODELS) {
for (const ratio of ASPECT_RATIOS) {
checkImageCombo({ aiModel: model, aspectRatio: ratio });
}
}
});

test("checkImageCombo — nano-banana family accepts photo/video ratios, not gpt-only ones", () => {
for (const model of ["nano-banana", "nano-banana-2", "nano-banana-2-lite", "nano-banana-pro"]) {
test("checkImageCombo — nano-banana family accepts photo/video ratios, not the GPT-only ones", () => {
for (const model of NANO_BANANA_MODELS) {
for (const ratio of ["1:1", "16:9", "9:16", "4:3", "3:4"]) {
checkImageCombo({ aiModel: model, aspectRatio: ratio });
}
for (const ratio of ["3:2", "2:3"]) {
assert.throws(
() => checkImageCombo({ aiModel: model, aspectRatio: ratio }),
/only supported by gpt-image-2/,
/only supported by the GPT Image models \(gpt-image-2, gpt-image-2-5-flare, gpt-image-2-5-sunburst\)/,
);
}
}
Expand All @@ -58,6 +79,9 @@ test("checkImageCombo — unknown model (driven via --data) defers the ratio che
assert.throws(() => checkImageCombo({ aspectRatio: "16:9", generateMultiView: true }), UsageError);
});

test("the model list includes nano-banana-2-lite", () => {
test("the model list includes nano-banana-2-lite and no longer offers the original nano-banana", () => {
assert.ok((IMAGE_MODELS as readonly string[]).includes("nano-banana-2-lite"));
// The first-generation model was dropped from the flag surface on 2026-09-15;
// the API still accepts it, so --data '{"ai_model":"nano-banana"}' remains the escape hatch.
assert.ok(!(IMAGE_MODELS as readonly string[]).includes("nano-banana"));
});
3 changes: 3 additions & 0 deletions tests/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ test("2D image commands default to gpt-image-2 and share the aspect-ratio surfac
assert.equal(model?.defaultValue, undefined, resource);
assert.match(model?.description ?? "", /gpt-image-2 \(default/, resource);
assert.ok(model?.argChoices?.includes("nano-banana-2-lite"), `${resource} lost nano-banana-2-lite`);
for (const tier of ["gpt-image-2-5-flare", "gpt-image-2-5-sunburst"]) {
assert.ok(model?.argChoices?.includes(tier), `${resource} is missing ${tier}`);
}
assert.ok(createFlags(resource).has("--aspect-ratio"), `${resource} is missing --aspect-ratio`);
}
});
Expand Down