diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfbf3f..821a6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,81 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.39.0 + +The Seedance capability tables, re-derived from token360's OWN published +parameter schema (GET /v1/models/{id} → parameter_schema) — a source nobody +had ever read; every table in every repo predated knowing it existed. Sibling +of blockrun PR #353 and blockrun-sol PR #133, which land the same truth in the +gateways, so client and server can no longer disagree. + +- **`fix(video)` — per-model resolution sets tightened to the schema.** 0.38.1 + (yesterday) tightened toward the gateway's tables; live probing shows those + tables were themselves wrong, in both directions: + + | model | 0.38.1 allowed | schema truth | + |---|---|---| + | 1.5-pro | 360p-1K | **480p / 720p / 1080p** | + | 2.0-fast | 360p-1K | **480p / 720p** | + | 2.0 | t2v narrow · i2v 360p-1K+4K | **480p / 720p / 1080p / 4K, both modes** | + | 2.5 | 360p-720p | **480p / 720p** | + + The t2v/i2v split is gone — the schema is per-model, everything it lists + passes in both modes, and 540p/1K hard-reject upstream even image-conditioned + (the pay-then-lose path the 0.38.1 guard half-closed). A few off-schema + values do pass upstream validation (2.0-fast 1080p, 1.5-pro 540p) and stay + blocked anyway: "passes validation" is not "renders" — 1.5-pro historically + echoed 2K/4K, billed the requested tier, and rendered 720p. + +- **`fix(video)` — `360p`/`540p`/`1K` leave the resolution enum, `9:21` leaves + the aspect-ratio enum.** No Seedance model lists any of them, and 9:21 is + rejected upstream for the whole family. Probed at $0: token360 validates + duration → resolution → ratio → other params, so a deliberately invalid + LATER parameter (ratio "9:99"; output_format "avi" when testing ratios) + guarantees rejection after the parameter under test is judged, and a failed + submit never creates a task or bills. RESOLUTION_TOKEN_FACTOR drops the same + tiers — the estimator throws on anything missing, so a stale entry is a loud + failure, not a silent 720p-baseline reserve. + +- **`fix(video)` — the resolution guard's advice now names the real ceiling.** + 2.0-fast is told 720p is its ceiling (0.38.1 wrongly offered it 1080p/1K — + the exact bill-high-render-low trap shape). + +- **`test(video)` — the t2v/i2v split test became the no-split test**, and the + factor-coverage test iterates the trimmed enum. verify:prices drops the + 2.0@360p tracking probe (inexpressible from this client now) and keeps + 2.5@1080p: the gateway still quotes $3.55 for a render token360 refuses — + the defect blockrun PR #353 fixes — so that probe reports the moment the + fleet is consistent. + +Breaking (deliberately — hence a minor bump, not a patch): a stale caller +sending `resolution:"360p"` or `aspect_ratio:"9:21"` now gets a zod enum +error instead of a paid round trip or an upstream 400. For Sora/Grok +callers, removed `resolution` values were accepted no-ops; `9:21` was NOT a +no-op on Sora — it selected portrait orientation — and `9:16` is the exact +replacement. One removed value did produce billed output before this +release — seedance-2.0 image-conditioned 360p passed every layer — but it +is exactly the off-schema shape that can silently downscale while billing +the requested tier, which is why the schema is the line. The +`aspect_ratio` description also stops claiming Sora/Grok both ignore it: +the handler forwards it for every model, SORA uses it to pick portrait vs +landscape, and Grok is the one that truly ignores it (the gateway never +forwards it to xAI). + +303 tests pass; live gate: 0 under-reserving. + +Also shipping in 0.39.0 (landed on main as #97, recorded here because +releases are cut from this file): the price sweep now probes BOTH gateways — +Base charges base + $0.001, Solana base + $0.000, and a Solana price DEARER +than Base fails the gate since an agent can switch chains mid-session; +`blockrun_defi`/`blockrun_modal` gained the Base-only chain guard six other +tools already had; eleven skills' flat prices were re-read off the live 402; +and dFlow left the catalog (all three routes 404 at the gateway). The +dual-gateway sweep also gives the 2.5@1080p fleet-consistency probe its +other half for free: Solana already 400s it (blockrun-sol#130's cap) while +Base still quotes it — one line of output now shows the split until +blockrun#353 deploys. + ## 0.38.1 Review pass over 0.38.0. The model addition held up; the guards and the tests diff --git a/package-lock.json b/package-lock.json index 3d07844..87531a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/mcp", - "version": "0.38.1", + "version": "0.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.38.1", + "version": "0.39.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/package.json b/package.json index a933a7a..ef756b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.38.1", + "version": "0.39.0", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.", "type": "module", diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts index f9229ee..b1c451a 100644 --- a/scripts/verify-prices.ts +++ b/scripts/verify-prices.ts @@ -162,14 +162,24 @@ const PROBES: Probe[] = [ ["bytedance/seedance-2.0", undefined, "4K"], ["bytedance/seedance-2.5", undefined, undefined], ["bytedance/seedance-2.5", 30, undefined], - // Combinations the client-side guards REFUSE, probed anyway because a guard - // is a claim about the gateway and this is the only thing that checks it. - // 2.5@1080p is the interesting one: the gateway still QUOTES it ($3.55) even - // though token360 rejects it at submit (probed 2026-08-07), so this line - // tracks a known gateway defect. The day it stops quoting, the gateway has - // been fixed and this probe will report `no 402` like 2.0@360p does. + // A combination the client-side guard REFUSES, probed anyway because a + // guard is a claim about the gateway and this is the only thing checking + // it: the gateway still QUOTES 2.5@1080p ($3.55) even though token360 + // rejects it at submit (probed 2026-08-07) — a known gateway defect, fixed + // by blockrun PR #353. Once that deploys, this probe reports `no 402` and + // the guard is vindicated. (2.0@360p left the matrix: 360p is out of the + // schema for every model and out of this client's enum, so the estimator + // now throws on it — it can no longer even be expressed from here.) ["bytedance/seedance-2.5", undefined, "1080p"], - ["bytedance/seedance-2.0", undefined, "360p"], + // Each model probed at (or near) its ceiling and floor tier, so a gateway + // whose capability surface diverges from SEEDANCE_RESOLUTIONS — in either + // direction — shows up here as `no 402` or a price mismatch. 1.5-pro@1080p + // was the only above-720p schema tier with no live pin anywhere until the + // 0.39.0 review probed it: charge $0.797311 vs reserve $0.798311. + ["bytedance/seedance-1.5-pro", undefined, "1080p"], + ["bytedance/seedance-2.0-fast", undefined, "480p"], + ["bytedance/seedance-2.5", undefined, "480p"], + ["bytedance/seedance-2.0", undefined, "480p"], ] as Array<[string, number | undefined, string | undefined]>).map(([model, seconds, resolution]) => ({ label: `video ${model.split("/")[1]}${seconds ? ` ${seconds}s` : ""}${resolution ? ` ${resolution}` : ""}`, path: "videos/generations", diff --git a/src/tools/video.ts b/src/tools/video.ts index d3240d7..4c0f961 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -59,13 +59,12 @@ const VIDEO_BASE_PRICE_PER_SECOND: Record = { // is 0.3, not 0.25), and the live 402 agrees with the table, not the geometry. // So a new tier must be read off a real quote (`npm run verify:prices`), never // derived. Sora/Grok bill per second and ignore this entirely. +// 360p/540p/1K trimmed 2026-08-07: token360's schema lists them for no model, +// so they left the enum too; estimateVideoCost throws on anything missing here. const RESOLUTION_TOKEN_FACTOR: Record = { - "360p": 0.3, "480p": 0.5, - "540p": 0.7, "720p": 1, "1080p": 2.25, - "1K": 2.25, "4K": 9, }; @@ -113,37 +112,41 @@ const VIDEO_DURATION_RANGE: Record parameter_schema), verified +// live 2026-08-07. The gateway's checkUnsupportedVideoInput will derive from +// the same source once blockrun PR #353 lands (machine-written snapshot); +// until it deploys, production still runs the OLD wide tables and this client +// is the only gate. THIS table is a hand-copy of that probe run — re-check it +// whenever the gateway resyncs its snapshot. +// The verify:prices matrix probes each model at its ceiling tier, so a gateway +// that starts quoting (or stops quoting) a tier this table disagrees with +// shows up in the free 402 sweep. // -// seedance-2.0 splits on generation mode — token360 400s 360p/540p/1K in -// text-to-video but accepts them when a first frame is supplied (live-probed -// upstream 2026-06-26). 4K is real 3840x2160 on 2.0 and on nothing else. -const RES_LOW = ["360p", "480p", "540p", "720p", "1080p", "1K"]; -const SEEDANCE_RESOLUTIONS: Record; i2v: Set; note: string }> = { - "bytedance/seedance-1.5-pro": { t2v: new Set(RES_LOW), i2v: new Set(RES_LOW), note: "1080p is the ceiling — only bytedance/seedance-2.0 renders true 4K" }, - "bytedance/seedance-2.0-fast": { t2v: new Set(RES_LOW), i2v: new Set(RES_LOW), note: "1080p is the ceiling — only bytedance/seedance-2.0 renders true 4K" }, +// No t2v/i2v split: the schema is per-model, and probing shows everything it +// lists passes in both modes, while 540p/1K hard-reject upstream even +// image-conditioned. We allow exactly the schema, nothing more — a few +// off-schema values PASS submit validation (2.0-fast 1080p, 1.5-pro 540p) but +// "passes validation" is not "renders": 1.5-pro historically echoed 2K/4K, +// billed the requested tier, and rendered 720p. Off-schema acceptance can +// silently downscale while billing the higher tier, so it stays out. +export const SEEDANCE_RESOLUTIONS: Record; note: string }> = { + "bytedance/seedance-1.5-pro": { + resolutions: new Set(["480p", "720p", "1080p"]), + note: "1080p is the ceiling — only bytedance/seedance-2.0 renders true 4K", + }, + "bytedance/seedance-2.0-fast": { + resolutions: new Set(["480p", "720p"]), + note: "720p is the ceiling on 2.0-fast — for 1080p use bytedance/seedance-1.5-pro (cheapest) or bytedance/seedance-2.0 (also 4K)", + }, "bytedance/seedance-2.0": { - t2v: new Set(["480p", "720p", "1080p", "4K"]), - i2v: new Set([...RES_LOW, "4K"]), - note: "text-to-video on 2.0 accepts 480p / 720p / 1080p / 4K only; the lower tiers need an image_url first frame", + resolutions: new Set(["480p", "720p", "1080p", "4K"]), + note: "the only model that renders true 4K (3840x2160)", + }, + "bytedance/seedance-2.5": { + resolutions: new Set(["480p", "720p"]), + note: "2.5 caps at 720p — it trades resolution for length (up to 30s). Use bytedance/seedance-2.0 for 1080p or 4K", }, - // 2.5 trades resolution for length, and this is PROVEN, not inferred: a paid - // probe on 2026-08-07 sent 1080p and 1K at both t2v and i2v, and token360 - // rejected all four — "the parameter resolution specified in the request is - // not valid for model dreamina-seedance-2-5". The probe cost $0.00 because - // nothing settles on a failed submit. - // - // Worth knowing why this guard earns its keep: the GATEWAY does not enforce - // this. checkUnsupportedVideoInput's `is20` regex does not match "2.5", so 2.5 - // falls to a permissive fallback and the gateway happily quotes $3.551218 for - // 1080p — a 402 signed for a request upstream will refuse. Without this check - // the caller signs a payment and gets a 500 the gateway labels "temporary". - "bytedance/seedance-2.5": { t2v: new Set(["360p", "480p", "540p", "720p"]), i2v: new Set(["360p", "480p", "540p", "720p"]), note: "2.5 caps at 720p — it trades resolution for length (up to 30s). Use bytedance/seedance-2.0 for 1080p or 4K" }, }; /** @@ -212,8 +215,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC real_face_asset_id: z.string().regex(/^ta_[A-Za-z0-9]+$/, "token360 asset id like 'ta_xxxx'").optional().describe("BytePlus RealFace asset id (from blockrun_realface enroll/list) to generate video of a specific real person. Seedance 2.0 / 2.0-fast only (NOT 2.5). Mutually exclusive with image_url."), duration_seconds: z.number().int().min(1).max(60).optional().describe("Duration to bill for. Defaults to the model's own default (8s xAI, 5s Seedance, 4s Sora). Per-model range: seedance-1.5-pro 4-12s · seedance-2.0 / 2.0-fast 4-15s · seedance-2.5 4-30s · sora-2 exactly 4, 8 or 12 · grok-imagine-video 1-15s."), generate_audio: z.boolean().optional().describe("Seedance only: whether to generate a synced audio track. Defaults ON for text-to-video and OFF for image/RealFace-conditioned. The auto-generated audio is occasionally rejected by upstream moderation ('output audio may contain sensitive information') even for benign prompts — pass false to skip audio and avoid that failure. Ignored by xAI/Sora."), - resolution: z.enum(["360p", "480p", "540p", "720p", "1080p", "1K", "4K"]).optional().describe("Seedance only: output resolution. Defaults to 720p. Higher resolutions cost more (token-priced upstream, ~2.25x at 1080p and ~9x at 4K) — the final price comes from the 402 challenge, so the up-front estimate understates 1080p/4K. 4K is bytedance/seedance-2.0 only; seedance-2.5 caps at 720p. Ignored by xAI/Sora."), - aspect_ratio: z.enum(["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "9:21"]).optional().describe("Seedance only: output aspect ratio, e.g. '9:16' for vertical/mobile, '16:9' for landscape. Defaults to the model's own default. Ignored by xAI/Sora."), + resolution: z.enum(["480p", "720p", "1080p", "4K"]).optional().describe("Seedance only: output resolution. Defaults to 720p. Higher resolutions cost more (token-priced upstream, ~2.25x at 1080p and ~9x at 4K). Per-model sets from token360's published schema: seedance-2.0 480p/720p/1080p/4K · 1.5-pro 480p/720p/1080p · 2.0-fast and 2.5 480p/720p only. Ignored by xAI/Sora (dropped from the request)."), + aspect_ratio: z.enum(["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]).optional().describe("Output aspect ratio. Seedance honors the full set; Sora uses it only to pick portrait vs landscape (9:16 / 3:4 -> portrait); Grok ignores it (the gateway never forwards it to xAI). Defaults to the model's own default. (9:21 removed 2026-08-07 — no Seedance model offers it; use 9:16 for vertical.)"), last_frame_url: z.string().url().optional().describe("Seedance 1.5-pro / 2.0 / 2.0-fast only (NOT 2.5): first-and-last-frame interpolation. A second image URL that seeds the FINAL frame so the model tweens from image_url (first frame) → last_frame_url (last frame). Requires image_url; mutually exclusive with real_face_asset_id."), model: z.enum(["azure/sora-2", "xai/grok-imagine-video", "bytedance/seedance-1.5-pro", "bytedance/seedance-2.0-fast", "bytedance/seedance-2.0", "bytedance/seedance-2.5"]).optional().default("xai/grok-imagine-video").describe("Video model to use"), agent_id: z.string().optional().describe("Agent identifier for budget tracking and enforcement."), @@ -272,21 +275,17 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC } } - // Resolution ceilings, per model and per generation mode. Only Seedance + // Resolution ceilings, per model. Only Seedance // is checked: Sora and Grok bill per second and ignore the parameter, // which is what the schema promises — so for them it is DROPPED from the // body below rather than rejected. (Forwarding it earned a gateway 400, // which is the opposite of "ignored".) const seedanceRes = SEEDANCE_RESOLUTIONS[selectedModel]; - if (resolution && seedanceRes) { - const imageConditioned = Boolean(image_url || real_face_asset_id || last_frame_url); - const allowed = imageConditioned ? seedanceRes.i2v : seedanceRes.t2v; - if (!allowed.has(resolution)) { - return { - content: [{ type: "text", text: formatError(`${selectedModel} does not render ${resolution}${imageConditioned ? " for image-conditioned video" : ""}. ${seedanceRes.note}. Supported here: ${[...allowed].join(", ")}.`) }], - isError: true, - }; - } + if (resolution && seedanceRes && !seedanceRes.resolutions.has(resolution)) { + return { + content: [{ type: "text", text: formatError(`${selectedModel} does not render ${resolution}. ${seedanceRes.note}. Supported: ${[...seedanceRes.resolutions].join(", ")}.`) }], + isError: true, + }; } const billedSeconds = duration_seconds ?? VIDEO_DEFAULT_DURATION[selectedModel] ?? 8; @@ -359,7 +358,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC const settledUsd = amountToUsd(details.amount); // The 402 carries the REAL price; Seedance/Sora are token-priced, so a - // 1080p/2K/4K render can far exceed the per-second estimate reserved at + // 1080p/4K render can far exceed the per-second estimate reserved at // the gate. Re-reserve against the cap BEFORE paying so a single high-res // call can't settle past the budget (and concurrent jobs hold the true // amount, not the low estimate, for the whole polling window). diff --git a/test/video-models.test.ts b/test/video-models.test.ts index f6f7685..861afe7 100644 --- a/test/video-models.test.ts +++ b/test/video-models.test.ts @@ -42,7 +42,7 @@ mock.module("../src/utils/wallet.js", { }, }); -const { registerVideoTool, estimateVideoCost } = await import("../src/tools/video.js"); +const { registerVideoTool, estimateVideoCost, SEEDANCE_RESOLUTIONS } = await import("../src/tools/video.js"); function makeHarness() { let handler: ((args: Record) => Promise) | undefined; @@ -211,15 +211,58 @@ test("4K is refused on every model but seedance-2.0 — and 2.0 keeps it", async assert.equal(estimate, "$10.22", `4K must reserve the 9x factor: ${text}`); }); -test("seedance-2.0 mirrors the gateway's t2v/i2v resolution split", async () => { - // Text-to-video on 2.0 rejects 360p/540p/1K upstream; image-conditioned accepts - // them. Getting this wrong is only a wasted round trip, which is precisely what - // these guards exist to remove. +test("off-schema resolutions are refused per model, in BOTH modes — with reachable inputs", async () => { + // The load-bearing cases use ENUM-VALID values (1080p, 4K): a revert that + // re-adds an image-conditioned wider set expressed in enum values is the + // production-reachable "bill the tier, render 720p" trap, and mutation + // testing showed the old 360p-only version missed it entirely. + for (const args of [{}, { image_url: "https://example.com/a.png" }]) { + assert.match(await errorText({ prompt: "a cube", model: "bytedance/seedance-2.0-fast", resolution: "1080p", ...args }), /does not render 1080p/); + assert.match(await errorText({ prompt: "a cube", model: "bytedance/seedance-2.5", resolution: "1080p", ...args }), /does not render 1080p/); + assert.match(await errorText({ prompt: "a cube", model: "bytedance/seedance-1.5-pro", resolution: "4K", ...args }), /does not render 4K/); + } + // The 2.0-fast note must point somewhere REAL. Anchored on the note's own + // phrasing — a bare /seedance-2\.0/ was self-matching ("seedance-2.0-fast" + // contains it), the fourth vacuous assertion this file has grown. + const fast = await errorText({ prompt: "a cube", model: "bytedance/seedance-2.0-fast", resolution: "1080p" }); + assert.match(fast, /for 1080p use bytedance\/seedance-1\.5-pro/); + // Defense-in-depth only: 360p left the zod enum in this release, so real MCP + // callers get the enum error and never reach this guard message — the direct + // handler call here pins the backstop for unvalidated invocation paths. assert.match( await errorText({ prompt: "a cube", model: "bytedance/seedance-2.0", resolution: "360p" }), /does not render 360p/, ); - await reservedFor({ prompt: "a cube", model: "bytedance/seedance-2.0", resolution: "360p", image_url: "https://example.com/a.png" }); +}); + +test("every schema-listed (model, resolution) pair is ACCEPTED — the whole table, positively", async () => { + // Mutation testing proved the acceptance side was silently revertible: + // emptying 2.0-fast's set left the suite green. The expected pairs are + // HARDCODED here — iterating the exported table would test the mutation + // against itself (a first draft of this test did exactly that and the same + // two mutations still passed). This literal map IS the spec: token360's + // published parameter schema, verified live 2026-08-07. + const EXPECTED: Record = { + "bytedance/seedance-1.5-pro": ["480p", "720p", "1080p"], + "bytedance/seedance-2.0-fast": ["480p", "720p"], + "bytedance/seedance-2.0": ["480p", "720p", "1080p", "4K"], + "bytedance/seedance-2.5": ["480p", "720p"], + }; + // The shipped table must equal the spec exactly — both directions. + assert.deepEqual( + Object.fromEntries(Object.entries(SEEDANCE_RESOLUTIONS).map(([m, s]) => [m, [...s.resolutions].sort()])), + Object.fromEntries(Object.entries(EXPECTED).map(([m, r]) => [m, [...r].sort()])), + ); + for (const [model, resolutions] of Object.entries(EXPECTED)) { + for (const resolution of resolutions) { + const { text } = await reservedFor({ prompt: "a cube", model, resolution }); + assert.doesNotMatch(text, /does not render/, `${model} must accept ${resolution}`); + // Every accepted resolution must also be expressible by real callers and + // priced by a real factor (estimateVideoCost throws on a missing one). + assert.equal(makeHarness().config.inputSchema.resolution.parse(resolution), resolution); + estimateVideoCost(model, 5, resolution); + } + } }); test("resolution is ignored, not rejected, on the per-second models", async () => { @@ -245,7 +288,7 @@ test("every resolution the schema accepts has a token factor — no silent 1x", // enum and the factor table cannot drift apart in the first place. const { config } = makeHarness(); const base = estimateVideoCost("bytedance/seedance-2.0", 5, "720p"); - for (const r of ["360p", "480p", "540p", "720p", "1080p", "1K", "4K"]) { + for (const r of ["480p", "720p", "1080p", "4K"]) { assert.equal(config.inputSchema.resolution.parse(r), r, `${r} must be in the enum`); if (r === "720p") continue; assert.notEqual(estimateVideoCost("bytedance/seedance-2.0", 5, r), base, `${r} prices at the 720p factor`);