Skip to content

Commit 81143c0

Browse files
authored
Merge pull request #30 from shotstack/feat/generation-quotes
[2/2] Quote generations in place of the model price
2 parents 54905e8 + 3bd94e3 commit 81143c0

7 files changed

Lines changed: 63 additions & 62 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,18 @@ shotstack template delete <id>
143143

144144
### `shotstack models [id]`
145145

146-
Lists the generation models for prompt-driven image, video and audio assets, with each one's price in credits and whether your API key can use it. A model your plan doesn't include shows why. Pass a model id to see the options it accepts.
146+
Lists the generation models for prompt-driven image, video and audio assets, with whether your API key can use each one. A model your plan doesn't include shows why. Pass a model id to see the options it accepts.
147147

148148
```sh
149149
shotstack models
150-
# → nano-banana-2 image unavailable: AiCapabilityNotIncluded credits per generation by resolution: 0.5K 0.375, 1K 0.5 (default), …
151-
shotstack models seedance-2.0-text-to-video # price and the JSON Schema for its options
150+
# → nano-banana-2 image unavailable: AiCapabilityNotIncluded
151+
shotstack models seedance-2.0-text-to-video # the JSON Schema for its options
152152
shotstack models --output json
153153
```
154154

155155
### `shotstack generate <model> <prompt>`
156156

157-
Generates one image, video or audio asset from a prompt and prints its URL, without rendering an edit. `--options` takes the model's options as a JSON object (see `shotstack models <model>`). `--length` sets the clip length in seconds for models that generate to a duration. Generation is billed in credits on stage as well as v1. Identical requests share one job and its cached result, so repeating a request returns the same job instead of generating and charging again.
157+
Generates one image, video or audio asset from a prompt and prints its URL, without rendering an edit. `--options` takes the model's options as a JSON object (see `shotstack models <model>`). `--length` sets the clip length in seconds for models that generate to a duration. Generation is billed in credits on stage as well as v1; `--quote` prints what that exact request would cost, without generating or charging. Identical requests share one job and its cached result, so repeating a request returns the same job instead of generating and charging again.
158158

159159
```sh
160160
shotstack generate nano-banana-2 "A ceramic mug on a walnut desk, soft window light" --watch

‎skills/shotstack/SKILL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ If a check fails (no network, no npm, a sandbox), skip it and carry on.
2424

2525
**Open-ended first render? Ask before composing.** If the ask doesn't state what the video is (subject, format or destination) — e.g. "render my first video", "make me a video", "set up Shotstack" — read [`references/onboarding.md`](references/onboarding.md) and ask its discovery questions, then WAIT for answers. Do not write an Edit first. An open-ended ask is not a specification, and a stray JSON file in the project doesn't make the user experienced. Compose without asking only when the user has stated what the video is, or explicitly says to just show anything.
2626

27-
Commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done. `ingest` uploads your own local files (or fetches remote URLs) and hosts them so they can be referenced from an Edit. `validate` lints an Edit — run it before every render. `studio` opens an edit in the browser editor for a human to preview, tweak, and render. `template list|get|create|update|delete` reads and writes templates saved to the account: `list` shows saved templates (id + name); `get <id>` fetches a saved template's Edit JSON (e.g. one the user designed in Studio) so you can render or remix it; `create <file> --name <name>` saves an edit as a new template; `update <id> <file>` overwrites one; `delete <id>` removes one. `models` lists the generation models for prompt-driven assets with each one's price and whether this key can use it; `models <id>` prints the options a model accepts. `generate <model> "<prompt>"` generates one asset on its own and prints its URL.
27+
Commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done. `ingest` uploads your own local files (or fetches remote URLs) and hosts them so they can be referenced from an Edit. `validate` lints an Edit — run it before every render. `studio` opens an edit in the browser editor for a human to preview, tweak, and render. `template list|get|create|update|delete` reads and writes templates saved to the account: `list` shows saved templates (id + name); `get <id>` fetches a saved template's Edit JSON (e.g. one the user designed in Studio) so you can render or remix it; `create <file> --name <name>` saves an edit as a new template; `update <id> <file>` overwrites one; `delete <id>` removes one. `models` lists the generation models for prompt-driven assets and whether this key can use each one; `models <id>` prints the options a model accepts. `generate <model> "<prompt>"` generates one asset on its own and prints its URL.
2828

2929
**Default loop — no API key required:** compose → `validate` (offline lint) → `studio` (browser preview). Only the API-backed commands (`render`, `status`, `ingest`, `models`, `generate`) need a key. Reach for `render` when you specifically need a final MP4 exported in the cloud; reach for `studio` for everything else — it is the cheapest way to see an edit and the right default for any creative a human will review.
3030

@@ -96,7 +96,7 @@ Other ingest subcommands: `ingest fetch <url>` (host a copy of a remote URL), `i
9696

9797
## Generating assets
9898

99-
An `image`, `video` or `audio` asset with a `prompt` and a `model` is generated by AI. `shotstack models` lists the models, whether this API key can use each one, and its price in credits. `shotstack models <id>` adds the JSON Schema for that model's `options`. Read it before setting `options`, because values outside it are rejected.
99+
An `image`, `video` or `audio` asset with a `prompt` and a `model` is generated by AI. `shotstack models` lists the models and whether this API key can use each one. `shotstack models <id>` adds the JSON Schema for that model's `options`. Read it before setting `options`, because values outside it are rejected.
100100

101101
```sh
102102
shotstack models --output json
@@ -115,7 +115,7 @@ shotstack generate seedance-2.0-text-to-video "Waves break over black sand at du
115115

116116
`--length` is the clip length in seconds. Models that generate to a duration use it in place of their own duration option.
117117

118-
**Generation costs credits on stage as well as v1.** Before generating, tell the user the model and its price from `shotstack models <id>`, and wait for a yes. Identical requests share one job and its cached result: repeating a request while it runs, or after it finishes, returns the same job without charging again. That is also how to pick up a job started without `--watch`. Sending a failed generation again retries it, and that is charged.
118+
**Generation costs credits on stage as well as v1.** Before generating, run the same command with `--quote`, tell the user the model and the credits it prints (it says "up to" when the final charge can be lower), and wait for a yes. Identical requests share one job and its cached result: repeating a request while it runs, or after it finishes, returns the same job without charging again. That is also how to pick up a job started without `--watch`. Sending a failed generation again retries it, and that is charged.
119119

120120
**Read [`references/generation.md`](references/generation.md) before writing a prompt.** It covers what each model responds to, when to put text in a `rich-text` layer instead of the image, and how to size and chain generations inside an edit.
121121

‎skills/shotstack/shared/agent-core.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ Set `prompt` and `model` on an `image`, `video` or `audio` asset and it is gener
252252
{ "asset": { "type": "video", "prompt": "Slow dolly across a rain-soaked neon street at night", "model": "seedance-2.0-text-to-video", "options": { "resolution": "720p" } }, "start": 0, "length": 5 }
253253
```
254254

255-
The models endpoint (`GET /models`) lists each model, whether the account can use it, its price, and the JSON Schema for its `options`. Options outside that schema are rejected. To animate a still, use an image-to-video model and put the image URL in `options.startSrc` (`inputSrc` on the original image-to-video models).
255+
The models endpoint (`GET /models`) lists each model, whether the account can use it, and the JSON Schema for its `options`. `POST /generate/quote` takes the same body as `POST /generate` and returns its cost in credits without charging. Options outside that schema are rejected. To animate a still, use an image-to-video model and put the image URL in `options.startSrc` (`inputSrc` on the original image-to-video models).
256256

257257
Generation is billed per asset **even on the stage endpoint**, which is otherwise free. Renders containing AI assets take longer, because the render waits for each generation.
258258

‎src/commands/generate.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { withRecording, commandArgv, type CommandResult } from "../recorder.ts";
88
import { fetchModel, formatAvailability, type GenerationModel } from "./models.ts";
99

1010
export type Generation = components["schemas"]["GenerationResponse"];
11+
export type GenerationQuote = components["schemas"]["GenerationQuote"];
1112

1213
export interface GenerateInput {
1314
model: string;
@@ -26,20 +27,29 @@ export const generateCommand = new Command("generate")
2627
.option("--options <json>", "Model options as a JSON object (see `shotstack models <model>`)", parseOptionsJson)
2728
.option("--length <seconds>", "Length of the clip the asset fills; models that generate to a duration use it", parseSeconds)
2829
.option("--watch", "Poll until the asset is ready, then print its URL")
30+
.option("--quote", "Print the credits this exact request would cost, without generating or charging")
2931
.option(`--env <name>`, `Environment: ${ENV_NAMES.join(" | ")}`)
3032
.option("--output <format>", "Output format: text | json", "text")
3133
.action(
3234
async (
3335
model: string,
3436
prompt: string,
35-
options: { options?: Record<string, unknown>; length?: number; watch?: boolean; env?: string; output: string },
37+
options: {
38+
options?: Record<string, unknown>;
39+
length?: number;
40+
watch?: boolean;
41+
quote?: boolean;
42+
env?: string;
43+
output: string;
44+
},
3645
) => {
3746
await withRecording("generate", commandArgv("generate"), async () => {
3847
const format = parseOutputFormat(options.output);
3948
const env = resolveEnv(options.env);
4049
const apiKey = requireApiKey(env.name);
4150
const client = createClient({ apiKey, env });
4251
const input = { model, prompt, options: options.options, length: options.length };
52+
if (options.quote) return runQuote(client, input, format);
4353
return runGenerate(client, input, format, options.watch === true);
4454
});
4555
},
@@ -75,6 +85,19 @@ export async function runGenerate(
7585
return { renderId: final.id, response: final, exitCode: final.status === "failed" ? 1 : 0 };
7686
}
7787

88+
// The API refuses a quote for a model the key can't use, so there's no availability check here.
89+
export async function runQuote(client: Client, input: GenerateInput, format: OutputFormat): Promise<CommandResult> {
90+
const model = await fetchModel(client, input.model);
91+
const quote = await client.post<GenerationQuote>("/generate/quote", buildRequest(model, input));
92+
emit(format, quote, formatQuote(quote));
93+
return { response: quote };
94+
}
95+
96+
// `ceiling` means the duration isn't known yet, so the charge can come in lower.
97+
export function formatQuote(q: GenerationQuote): string {
98+
return `${q.ceiling ? "up to " : ""}${q.credits} ${q.credits === 1 ? "credit" : "credits"}`;
99+
}
100+
78101
export function buildRequest(model: GenerationModel, input: GenerateInput) {
79102
return {
80103
asset: {

‎src/commands/models.ts‎

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,9 @@ import { resolveEnv, ENV_NAMES } from "../http/env.ts";
66
import { emit, parseOutputFormat } from "../output.ts";
77
import { withRecording, commandArgv } from "../recorder.ts";
88

9-
// The published schema has no `pricing`; API deployments that predate generation quotes still send it.
10-
type GenerationModelPricing = {
11-
credits: number | Record<string, number>;
12-
tieredBy?: { option: string; default: string };
13-
quantity?: { measure: string; per: number };
14-
};
15-
export type GenerationModel = components["schemas"]["GenerationModel"] & { pricing?: GenerationModelPricing };
9+
export type GenerationModel = components["schemas"]["GenerationModel"];
1610
type GenerationModelList = components["schemas"]["GenerationModelListResponse"];
1711

18-
const UNIT_NOUNS: Record<string, string> = { clipSeconds: "second", promptCharacters: "character" };
19-
2012
export async function listModels(client: Client): Promise<GenerationModel[]> {
2113
const result = await client.get<GenerationModelList>("/models");
2214
return result.models;
@@ -28,7 +20,7 @@ export async function fetchModel(client: Client, id: string): Promise<Generation
2820
}
2921

3022
export const modelsCommand = new Command("models")
31-
.description("List generation models with availability and price, or show one model's options")
23+
.description("List generation models with availability, or show one model's options")
3224
.argument("[id]", "Model id; prints the JSON Schema for its options")
3325
.option(`--env <name>`, `Environment: ${ENV_NAMES.join(" | ")}`)
3426
.option("--output <format>", "Output format: text | json", "text")
@@ -58,32 +50,15 @@ export function formatAvailability(model: GenerationModel): string {
5850
return "availability unknown";
5951
}
6052

61-
export function formatPrice(pricing: GenerationModelPricing): string {
62-
const unit = formatUnit(pricing.quantity);
63-
if (typeof pricing.credits === "number") return `${pricing.credits} ${pricing.credits === 1 ? "credit" : "credits"} ${unit}`;
64-
const fallback = pricing.tieredBy?.default;
65-
const tiers = Object.entries(pricing.credits)
66-
.map(([tier, credits]) => `${tier} ${credits}${tier === fallback ? " (default)" : ""}`)
67-
.join(", ");
68-
return `credits ${unit} by ${pricing.tieredBy?.option ?? "option"}: ${tiers}`;
69-
}
70-
71-
function formatUnit(quantity: GenerationModelPricing["quantity"]): string {
72-
if (!quantity) return "per generation";
73-
const noun = UNIT_NOUNS[quantity.measure] ?? quantity.measure;
74-
return quantity.per === 1 ? `per ${noun}` : `per ${quantity.per} ${noun}s`;
75-
}
76-
7753
function formatModelTable(models: GenerationModel[]): string {
78-
const rows = models.map((m) => [m.model, m.type, formatAvailability(m), m.pricing ? formatPrice(m.pricing) : ""]);
54+
const rows = models.map((m) => [m.model, m.type, formatAvailability(m)]);
7955
const widths = rows[0]!.map((_, col) => Math.max(...rows.map((row) => row[col]!.length)));
8056
return rows.map((row) => row.map((cell, col) => cell.padEnd(widths[col]!)).join(" ").trimEnd()).join("\n");
8157
}
8258

8359
function formatModelDetail(model: GenerationModel): string {
8460
const lines = [`${model.model} ${model.type} ${formatAvailability(model)}`];
8561
if (model.name) lines.push(model.description ? `${model.name}: ${model.description}` : model.name);
86-
if (model.pricing) lines.push(`Price: ${formatPrice(model.pricing)}`);
8762
if (model.options) lines.push("Options:", JSON.stringify(model.options, null, 2));
8863
return lines.join("\n");
8964
}

‎tests/generate.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, test, expect, mock } from "bun:test";
22
import { InvalidArgumentError } from "commander";
3-
import { buildRequest, parseOptionsJson, parseSeconds, runGenerate, type Generation } from "../src/commands/generate.ts";
3+
import {
4+
buildRequest,
5+
formatQuote,
6+
parseOptionsJson,
7+
parseSeconds,
8+
runGenerate,
9+
runQuote,
10+
type Generation,
11+
} from "../src/commands/generate.ts";
412
import type { GenerationModel } from "../src/commands/models.ts";
513
import type { Client } from "../src/http/client.ts";
614

@@ -68,3 +76,21 @@ describe("runGenerate", () => {
6876
expect(result.exitCode).toBe(1);
6977
});
7078
});
79+
80+
describe("runQuote", () => {
81+
test("quotes the exact request generate would send, without generating", async () => {
82+
const post = mock(async () => ({ credits: 0.6038, ceiling: false }));
83+
const get = mock(async () => video);
84+
const client = { get, post } as unknown as Client;
85+
const input = { model: video.model, prompt: "waves", length: 5 };
86+
const result = await runQuote(client, input, "json");
87+
expect(post).toHaveBeenCalledTimes(1);
88+
expect(post).toHaveBeenCalledWith("/generate/quote", buildRequest(video, input));
89+
expect(result.response).toEqual({ credits: 0.6038, ceiling: false });
90+
});
91+
92+
test("says 'up to' when the quote is a ceiling", () => {
93+
expect(formatQuote({ credits: 2.5, ceiling: true })).toBe("up to 2.5 credits");
94+
expect(formatQuote({ credits: 1, ceiling: false })).toBe("1 credit");
95+
});
96+
});

‎tests/models.test.ts‎

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { fetchModel, formatAvailability, formatPrice, type GenerationModel } from "../src/commands/models.ts";
2+
import { fetchModel, formatAvailability, type GenerationModel } from "../src/commands/models.ts";
33
import type { Client } from "../src/http/client.ts";
44

55
const model = (fields: Partial<GenerationModel>): GenerationModel => ({ model: "m", type: "image", ...fields });
@@ -31,26 +31,3 @@ describe("formatAvailability", () => {
3131
expect(formatAvailability(model({ available: true }))).toBe("available");
3232
});
3333
});
34-
35-
describe("formatPrice", () => {
36-
test("flat rate per generation", () => {
37-
expect(formatPrice({ credits: 0.1, effectiveFrom: "legacy" })).toBe("0.1 credits per generation");
38-
});
39-
40-
test("rate per counted unit", () => {
41-
expect(
42-
formatPrice({ credits: 1, quantity: { measure: "promptCharacters", per: 1000 }, effectiveFrom: "legacy" }),
43-
).toBe("1 credit per 1000 characters");
44-
});
45-
46-
test("tiered rate names the option and marks its default", () => {
47-
expect(
48-
formatPrice({
49-
credits: { "480p": 0.9375, "720p": 1.8962 },
50-
tieredBy: { option: "resolution", default: "720p" },
51-
quantity: { measure: "clipSeconds", per: 1 },
52-
effectiveFrom: "2026-08-13",
53-
}),
54-
).toBe("credits per second by resolution: 480p 0.9375, 720p 1.8962 (default)");
55-
});
56-
});

0 commit comments

Comments
 (0)