Skip to content

Commit bc6523e

Browse files
committed
feat: add a models command listing generation models, prices and availability
1 parent 925cca2 commit bc6523e

5 files changed

Lines changed: 167 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ shotstack template update <id> my-template.json --name "Promo v2"
141141
shotstack template delete <id>
142142
```
143143

144+
### `shotstack models [id]`
145+
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.
147+
148+
```sh
149+
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
152+
shotstack models --output json
153+
```
154+
144155
### `shotstack login` / `shotstack logout`
145156

146157
Saves (or removes) your API key so it persists across shell sessions. Keys are stored per environment in `~/.shotstack/credentials.json`.

‎skills/shotstack/SKILL.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ This skill loads in **terminal-based AI agents** (Claude Code, Cursor, Codex CLI
1515

1616
**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.
1717

18-
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.
18+
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.
1919

20-
**Default loop — no API key required:** compose → `validate` (offline lint) → `studio` (browser preview). Only the API-backed commands (`render`, `status`, `ingest`) 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.
20+
**Default loop — no API key required:** compose → `validate` (offline lint) → `studio` (browser preview). Only the API-backed commands (`render`, `status`, `ingest`, `models`) 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.
2121

2222
## Authentication
2323

@@ -36,7 +36,7 @@ Or set an environment variable (overrides the saved key — best for CI):
3636
export SHOTSTACK_API_KEY=...
3737
```
3838

39-
Resolution precedence: **`SHOTSTACK_API_KEY` env var → saved key for the target `--env` → error.** Get a key at <https://app.shotstack.io>. **`validate` and `studio` need no key** — only the API-backed commands (`render`, `status`, `ingest`) require one; without a key *those* exit 1, while compose → validate → studio keeps working.
39+
Resolution precedence: **`SHOTSTACK_API_KEY` env var → saved key for the target `--env` → error.** Get a key at <https://app.shotstack.io>. **`validate` and `studio` need no key** — only the API-backed commands (`render`, `status`, `ingest`, `models`) require one; without a key *those* exit 1, while compose → validate → studio keeps working.
4040

4141
## Environments
4242

@@ -85,6 +85,17 @@ The bare command (no `--watch`) returns only an `id`; the hosted `source` URL do
8585

8686
Other ingest subcommands: `ingest fetch <url>` (host a copy of a remote URL), `ingest status <id>`, `ingest list`, `ingest delete <id>`. **Read [`references/ingest.md`](references/ingest.md) for the full upload→render workflow, status values, and supported file types.**
8787

88+
## Generation models
89+
90+
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.
91+
92+
```sh
93+
shotstack models --output json
94+
shotstack models seedance-2.0-text-to-video --output json
95+
```
96+
97+
Offer only models marked available. `unavailable: AiCapabilityNotIncluded` means the plan doesn't include that kind of generation, `AiDisabled` means AI is turned off for the account, and `AiAccessUnavailable` means access couldn't be confirmed, so try again later. "availability unknown" means the check didn't run. It is not a yes.
98+
8899
## Default workflow: preview in Studio (no key, no credits)
89100

90101
**`shotstack studio <file>` is the default way to look at an edit** — prefer it over `render` unless you specifically need a cloud-exported MP4. It posts the JSON to https://shotstack.studio and opens `https://shotstack.studio/s/<slug>` in the browser — a short, shareable URL. No API key, no render credits charged; it previews client-side, so it works with no key. The human plays, edits, and decides whether to spend credits on a render.

‎src/commands/models.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Command } from "commander";
2+
import type { components } from "@shotstack/schemas";
3+
import { createClient, type Client } from "../http/client.ts";
4+
import { requireApiKey } from "../http/auth.ts";
5+
import { resolveEnv, ENV_NAMES } from "../http/env.ts";
6+
import { emit, parseOutputFormat } from "../output.ts";
7+
import { withRecording, commandArgv } from "../recorder.ts";
8+
9+
export type GenerationModel = components["schemas"]["GenerationModel"];
10+
type GenerationModelPricing = components["schemas"]["GenerationModelPricing"];
11+
type GenerationModelList = components["schemas"]["GenerationModelListResponse"];
12+
13+
const UNIT_NOUNS: Record<string, string> = { clipSeconds: "second", promptCharacters: "character" };
14+
15+
export async function listModels(client: Client): Promise<GenerationModel[]> {
16+
const result = await client.get<GenerationModelList>("/models");
17+
return result.models;
18+
}
19+
20+
/** One model, including the JSON Schema for its `options`. */
21+
export async function fetchModel(client: Client, id: string): Promise<GenerationModel> {
22+
return client.get<GenerationModel>(`/models/${encodeURIComponent(id)}`);
23+
}
24+
25+
export const modelsCommand = new Command("models")
26+
.description("List generation models with availability and price, or show one model's options")
27+
.argument("[id]", "Model id; prints the JSON Schema for its options")
28+
.option(`--env <name>`, `Environment: ${ENV_NAMES.join(" | ")}`)
29+
.option("--output <format>", "Output format: text | json", "text")
30+
.action(async (id: string | undefined, options: { env?: string; output: string }) => {
31+
await withRecording("models", commandArgv("models"), async () => {
32+
const format = parseOutputFormat(options.output);
33+
const env = resolveEnv(options.env);
34+
const apiKey = requireApiKey(env.name);
35+
const client = createClient({ apiKey, env });
36+
37+
if (id) {
38+
const model = await fetchModel(client, id);
39+
emit(format, model, formatModelDetail(model));
40+
return { response: { model: model.model, available: model.available } };
41+
}
42+
43+
const models = await listModels(client);
44+
emit(format, models, models.length ? formatModelTable(models) : "No models.");
45+
return { response: { count: models.length } };
46+
});
47+
});
48+
49+
// `available` is omitted when the account could not be read; that is not a yes.
50+
export function formatAvailability(model: GenerationModel): string {
51+
if (model.available === true) return "available";
52+
if (model.available === false) return `unavailable: ${model.unavailableReason ?? "no reason given"}`;
53+
return "availability unknown";
54+
}
55+
56+
export function formatPrice(pricing: GenerationModelPricing): string {
57+
const unit = formatUnit(pricing.quantity);
58+
if (typeof pricing.credits === "number") return `${pricing.credits} ${pricing.credits === 1 ? "credit" : "credits"} ${unit}`;
59+
const fallback = pricing.tieredBy?.default;
60+
const tiers = Object.entries(pricing.credits)
61+
.map(([tier, credits]) => `${tier} ${credits}${tier === fallback ? " (default)" : ""}`)
62+
.join(", ");
63+
return `credits ${unit} by ${pricing.tieredBy?.option ?? "option"}: ${tiers}`;
64+
}
65+
66+
function formatUnit(quantity: GenerationModelPricing["quantity"]): string {
67+
if (!quantity) return "per generation";
68+
const noun = UNIT_NOUNS[quantity.measure] ?? quantity.measure;
69+
return quantity.per === 1 ? `per ${noun}` : `per ${quantity.per} ${noun}s`;
70+
}
71+
72+
function formatModelTable(models: GenerationModel[]): string {
73+
const rows = models.map((m) => [m.model, m.type, formatAvailability(m), m.pricing ? formatPrice(m.pricing) : ""]);
74+
const widths = rows[0]!.map((_, col) => Math.max(...rows.map((row) => row[col]!.length)));
75+
return rows.map((row) => row.map((cell, col) => cell.padEnd(widths[col]!)).join(" ").trimEnd()).join("\n");
76+
}
77+
78+
function formatModelDetail(model: GenerationModel): string {
79+
const lines = [`${model.model} ${model.type} ${formatAvailability(model)}`];
80+
if (model.name) lines.push(model.description ? `${model.name}: ${model.description}` : model.name);
81+
if (model.pricing) lines.push(`Price: ${formatPrice(model.pricing)}`);
82+
if (model.options) lines.push("Options:", JSON.stringify(model.options, null, 2));
83+
return lines.join("\n");
84+
}

‎src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { studioCommand } from "./commands/studio.ts";
66
import { ingestCommand } from "./commands/ingest.ts";
77
import { templateCommand } from "./commands/template.ts";
88
import { validateCommand } from "./commands/validate.ts";
9+
import { modelsCommand } from "./commands/models.ts";
910
import { loginCommand, logoutCommand } from "./commands/auth.ts";
1011
import { ApiError } from "./http/client.ts";
1112
import { MissingApiKeyError } from "./http/auth.ts";
@@ -22,6 +23,7 @@ const program = new Command()
2223
.addCommand(ingestCommand)
2324
.addCommand(templateCommand)
2425
.addCommand(validateCommand)
26+
.addCommand(modelsCommand)
2527
.addCommand(loginCommand)
2628
.addCommand(logoutCommand)
2729
.addCommand(feedbackCommand);

‎tests/models.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { fetchModel, formatAvailability, formatPrice, type GenerationModel } from "../src/commands/models.ts";
3+
import type { Client } from "../src/http/client.ts";
4+
5+
const model = (fields: Partial<GenerationModel>): GenerationModel => ({ model: "m", type: "image", ...fields });
6+
7+
describe("fetchModel", () => {
8+
test("requests the model by encoded id", async () => {
9+
let requestedPath = "";
10+
const client = {
11+
get: async (path: string) => {
12+
requestedPath = path;
13+
return { model: "seedance 2", type: "video" };
14+
},
15+
} as unknown as Client;
16+
17+
await fetchModel(client, "seedance 2");
18+
expect(requestedPath).toBe("/models/seedance%202");
19+
});
20+
});
21+
22+
describe("formatAvailability", () => {
23+
test("prints the reason a model is unavailable", () => {
24+
expect(formatAvailability(model({ available: false, unavailableReason: "AiCapabilityNotIncluded" }))).toBe(
25+
"unavailable: AiCapabilityNotIncluded",
26+
);
27+
});
28+
29+
test("treats a missing flag as unknown, not available", () => {
30+
expect(formatAvailability(model({}))).toBe("availability unknown");
31+
expect(formatAvailability(model({ available: true }))).toBe("available");
32+
});
33+
});
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)