Grok Imagine Video

aimock mocks xAI's native Grok Imagine async video-generation API — submit a job under POST /v1/videos/generations, then poll it through pending → done | failed | expired under GET /v1/videos/{request_id}. It draws from the same endpoint: "video" fixture pool as the other video surfaces, disambiguated by the grok-imagine-* model string.

Endpoints

Method Path Response
POST /v1/videos/generations { request_id } job envelope (JSON only — a multipart/form-data body is rejected with 400 before the body is parsed); the matched fixture's video drives the job's terminal state
GET /v1/videos/{request_id} { request_id, status, progress } — plus video.url / video.duration + usage.cost_in_usd_ticks once done, or those same { request_id, status, progress } fields plus code / error once failed

Sora-safe dispatch

Grok's status route, GET /v1/videos/{request_id}, shares its path shape with the OpenAI-shaped Sora /v1/videos/{id} surface. aimock resolves this with a job-map-first lookup: a GET that matches a live Grok job is served by the Grok handler, and any miss falls through to the unchanged Sora handleVideoStatus byte-for-byte (the two id namespaces are disjoint — Sora ids come from its own state map, Grok request ids are minted UUIDs). The literal /v1/videos/generations submit path is guarded so it is never parsed as a status id of "generations". Existing Sora behavior is unaffected.

Fixture Authoring

Submits are matched against endpoint: "video" fixtures on the request's prompt (via match.userMessage, read from body.prompt) and model (via match.model, default grok-imagine-video). The grok-imagine-* model string is the provider disambiguator — Grok, Veo, and OpenRouter video fixtures share one match namespace, and their model tokens never overlap.

grok-video.test.ts ts
mock.onVideo("a cat playing piano", {
  // `id` is required by the type but ignored on this surface (the request_id
  // is always server-minted); `url` is served as-is, no byte proxying.
  video: { id: "vid_1", status: "completed", url: "https://videos.x.ai/abc.mp4", duration: 6, cost: 0.12 },
}, { model: "grok-imagine-video" });

// A failed job:
mock.onVideo("impossible prompt", {
  video: { id: "vid_2", status: "failed", error: "content policy violation" },
}, { model: "grok-imagine-video" });

The fixture's video object supports:

Progress & Polling Realism

Grok reports a progress percentage on every poll; aimock synthesizes it from the poll count (climbing toward 100, reaching 100 when the job is done). By default a submitted job is seeded terminal internally. To exercise client code that polls through intermediate states, pass grokVideo with poll thresholds. The semantics are identical to falQueue and the OpenRouter video surface.

polling.test.ts ts
const mock = new LLMock({
  port: 0,
  grokVideo: { pollsBeforeInProgress: 1, pollsBeforeCompleted: 2 },
});

// Submit  → { request_id }
// poll 1  → { request_id, status: "pending", progress: … }
// poll 2  → { request_id, status: "done", progress: 100, video: { url, duration }, usage: { cost_in_usd_ticks } }

Thresholds are sanitized exactly as on the other video surfaces (non-finite treated as unset; negatives/fractions floored and clamped). createServer warns at startup on invalid values.

JSON-only — multipart rejected

The Grok video API is JSON-only. A submit carrying a Content-Type: multipart/form-data body is rejected with HTTP 400 and a { code, error } envelope before the body is parsed — aimock does not reuse Sora's accept-multipart create branch. A non-object or malformed JSON body and an empty prompt likewise return { code: "invalid_request", error }.

Cost units

Grok bills in cost_in_usd_ticks, an integer count of USD ticks where 1 USD = 1e10 ticks. A fixture's video.cost is the cost in USD; on a done poll aimock surfaces usage.cost_in_usd_ticks = round(cost × 1e10). Record mode persists the upstream cost back to USD (cost = cost_in_usd_ticks / 1e10) so it round-trips.

Chaos & Metrics

Chaos injection applies to both routes. In Prometheus metrics the per-job status path is templated as /v1/videos/{request_id} to keep label cardinality bounded.

Record Mode

With record mode and the grok provider configured, an unmatched submit becomes a live interactive proxy: the /v1/videos/generations POST is forwarded to the real API and answered with a mock-rewritten { request_id } envelope (a fresh aimock request id), and each client poll is proxied upstream 1:1 with the mock request id substituted. The client's own polling drives the upstream lifecycle.

aimock.config.json json
{
  "llm": {
    "fixtures": "./fixtures",
    "record": {
      "providers": { "grok": "https://api.x.ai" }
    }
  }
}
Programmatic config ts
const mock = new LLMock({
  record: {
    providers: { grok: "https://api.x.ai" },
  },
});

When the upstream poll reports done, the completed poll body is relayed first and the eager capture then runs synchronously on the request stack (there is no byte download to detach): aimock reads the video URL, duration, and cost straight out of the terminal poll body (no download — the URL is served as-is) and persists a normal video fixture (match.userMessage = the prompt, match.model = the submitted model under the standard model-normalization rules, video.id = the upstream request id, video.status = "completed", video.url / video.duration, plus cost converted from cost_in_usd_ticks). The same submit then replays in-session and across sessions. Relayed poll bodies are otherwise faithful: status, progress, video.url, and usage pass through verbatim with only the request id rewritten. A failed upstream persists a failed fixture ({ status: "failed", error }) and relays { request_id, status, progress, code, error }; expired (and any terminal status not representable in video.status) passes through with a warning, persists nothing, and keeps proxying.

The polling client's Bearer credential is forwarded only to the configured provider origin: the submit response carries just a request_id (never an upstream poll URL), so there is nothing off-origin to adopt or validate — the poll URL is always constructed on the configured provider origin from that request id. Strict mode wins over record: a strict no-match returns 503 and nothing is proxied. Without a configured grok provider URL, --record warns and serves the normal no-match 404. Under proxy-only mode (record.proxyOnly / --proxy-only) nothing is persisted and a done job is never converted to a local replay job — every poll keeps proxying upstream.

TTL caveat: record-mode jobs live in the same bounded job map as replay jobs (1-hour TTL, 10,000 entries). Each successful proxied poll refreshes a record job's TTL, so an actively-polled long render is never evicted mid-recording — but a poll arriving more than an hour after the last successful poll finds the job evicted and returns 404.