From ec973b34894551ee2fafb27b7c8f3881ecef0b8b Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 31 Jul 2026 23:37:39 +0800 Subject: [PATCH 1/2] add realtime session support --- CHANGELOG.md | 18 +- README.md | 19 +- contract/beatapi.openapi.yaml | 220 ++++++++++++++++++++- contract/contract.lock.json | 4 +- package-lock.json | 46 +++-- package.json | 7 +- packages/cli/README.md | 8 + packages/cli/package.json | 7 +- packages/cli/src/cli.ts | 85 +++++++- packages/cli/test/cli.test.ts | 97 ++++++++++ packages/client/README.md | 14 +- packages/client/package.json | 5 +- packages/client/src/client.ts | 35 ++++ packages/client/src/index.ts | 2 + packages/client/src/types.generated.ts | 256 ++++++++++++++++++++++++- packages/client/test/client.test.ts | 93 +++++++++ scripts/package-smoke.mjs | 5 +- 17 files changed, 868 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee210c..7644da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.2.0] - 2026-07-31 + +### Added + +- Type-safe Realtime session create, get, and close methods in + `beatapi-client`. +- Realtime session commands in the `beatapi` CLI with required origin, + duration, and idempotency semantics. +- Realtime Video documentation and explicit API key/client secret boundaries. + +### Changed + +- Synchronize generated types and the contract lock to the current public + BeatAPI OpenAPI baseline. + ## [0.1.1] - 2026-07-20 ### Changed @@ -30,6 +45,7 @@ and this project uses [Semantic Versioning](https://semver.org/). - Structured errors, request IDs, bounded retries, CI, package checks, and npm release automation. -[Unreleased]: https://github.com/BeatAPI/beatapi-cli/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/BeatAPI/beatapi-cli/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/BeatAPI/beatapi-cli/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/BeatAPI/beatapi-cli/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/BeatAPI/beatapi-cli/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 181d7ef..4bd3cd3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # BeatAPI CLI and TypeScript Client Official command-line interface and TypeScript client for BeatAPI's public -asynchronous AI video workflows. +asynchronous workflows and Realtime Video API. The repository contains two independently publishable npm packages: @@ -79,6 +79,12 @@ beatapi music-video compose TASK --shot SHOT_1 --shot SHOT_2 beatapi ecommerce-video create --file ./ecommerce-video.json +beatapi realtime sessions create --duration 60 \ + --origin https://app.example.com \ + --idempotency-key rt_checkout_123 +beatapi realtime sessions get SESSION +beatapi realtime sessions close SESSION + beatapi tasks get TASK beatapi tasks wait TASK --interval 7000 --attempts 120 @@ -132,9 +138,14 @@ try { } ``` -The client exposes every launch-contract operation: workflows, usage, file +The client exposes every public contract operation: workflows, usage, file upload, music-video automatic and manual composition, ecommerce-video tasks, -task polling, and webhook CRUD. +task polling, webhook CRUD, and Realtime session create/get/close. + +Create Realtime sessions only on a trusted server. The returned `client_secret` +is short lived and may be handed to the browser SDK; never expose the long-lived +`sk_` API key to browser code. The browser SDK owns camera access, WebRTC, and +media rendering. See the [Realtime Video guide](https://docs.beatapi.io/realtime-video). Retries are bounded and opt-in through method retry options. Task waiting uses bounded retries for transient network and retryable server failures. The client @@ -152,6 +163,8 @@ preserves BeatAPI error code, HTTP status, request ID, details, and honors issue reports, or passed as command arguments. - Webhook signing secrets are returned once by the API and should be stored with the same care as an API key. +- Realtime `client_secret` values are returned only on create. Treat terminal + output and CI logs containing them as sensitive, and close unused sessions. See [SECURITY.md](./SECURITY.md) for reporting instructions. diff --git a/contract/beatapi.openapi.yaml b/contract/beatapi.openapi.yaml index afa4701..65e1dc9 100644 --- a/contract/beatapi.openapi.yaml +++ b/contract/beatapi.openapi.yaml @@ -6,15 +6,20 @@ info: name: BeatAPI Terms of Service url: https://beatapi.io/terms-of-service description: | - BeatAPI is a simple async API for video workflows. Most integrations use the - same loop: create a task, poll the task until it finishes, then read the - hosted video URL from `output.media`. + BeatAPI provides async video workflows and short-lived Realtime Video + Sessions behind one BeatAPI-native API. Async integrations create a task, + poll until it finishes, then read the hosted video URL from `output.media`. + Realtime browser integrations create a Session with the same Bearer API key, + then pass only the returned BeatAPI `client_secret` to `@beatapi/realtime`. ## 5 minute Quick Start 1. Set your base URL to `https://api.beatapi.io`. - 2. Create an API key in Dashboard and send it as + 2. Create an API key in [Dashboard → API Keys](https://beatapi.io/dashboard/apikeys) + and send it as `Authorization: Bearer `. + Credit packs are available from + [Dashboard → Billing](https://beatapi.io/dashboard/billing). 3. Use public HTTPS URLs for input media. If your files are local, upload them with `POST /v1/files` first. 4. Create a workflow task. @@ -96,6 +101,8 @@ tags: description: Poll task status and read output URLs. - name: Usage description: Inspect task totals and account concurrency. + - name: Realtime Video + description: Create and manage short-lived BeatAPI browser sessions for live AI video effects. - name: Files description: Upload local assets and use the returned HTTPS URL as workflow input. - name: Webhooks @@ -444,12 +451,73 @@ components: type: integer credits_settled: type: integer + realtime: + type: object + required: [sessions, credits, active] + properties: + sessions: + type: integer + description: Total BeatAPI realtime sessions for this account. + credits: + type: integer + description: Credits settled by connected realtime sessions. + active: + type: integer + description: Realtime sessions in ready, connecting, or active state. UsageResponse: type: object required: [data] properties: data: $ref: '#/components/schemas/Usage' + RealtimeSession: + type: object + required: [id, object, status, expires_at, max_duration_seconds, allowed_origins, credits, request_id, created_at, connected_at, closed_at] + properties: + id: + type: string + pattern: '^rts_' + object: + type: string + enum: [realtime.session] + status: + type: string + enum: [ready, connecting, active, closed, failed, expired] + description: Active means BeatAPI accepted the first billing heartbeat after remote output began. + client_secret: + type: string + description: Returned only by POST. Give this short-lived BeatAPI secret to the browser SDK; never give the browser an sk_ API key. + pattern: '^brt_live_' + expires_at: + type: string + format: date-time + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + items: { type: string, format: uri } + credits: + type: object + required: [reserved, settled, refunded] + properties: + reserved: { type: integer } + settled: { type: integer } + refunded: { type: integer } + request_id: + type: string + created_at: { type: string, format: date-time } + connected_at: + type: [string, 'null'] + format: date-time + description: Time of the first accepted BeatAPI billing heartbeat; null before billing activation. + closed_at: { type: [string, 'null'], format: date-time } + RealtimeSessionResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/RealtimeSession' FileResponse: type: object required: [data] @@ -515,6 +583,12 @@ components: - processing_timeout - result_transfer_failed - invalid_signature + - realtime_disabled + - realtime_capacity_unavailable + - realtime_session_expired + - origin_not_allowed + - invalid_client_secret + - transport_not_allowed - internal_error message: type: string @@ -522,7 +596,7 @@ components: type: string retry_after_seconds: type: integer - description: Present on rate_limit_exceeded responses when the client should wait before retrying. + description: Present on retryable rate-limit or capacity responses when the client should wait before retrying. responses: Unauthorized: description: Missing, invalid, or inactive API key. @@ -568,6 +642,7 @@ paths: get: operationId: listWorkflows tags: [Workflows] + x-apidog-folder: Reference/API Overview summary: List launch workflows security: [] responses: @@ -596,7 +671,8 @@ paths: post: operationId: createMusicVideoTask tags: [Music Video] - summary: Create a Music Video workflow task + x-apidog-folder: Music Video API/Create Video + summary: Create Music Video security: - BearerAuth: [] description: | @@ -778,7 +854,8 @@ paths: post: operationId: editMusicVideoShot tags: [Music Video] - summary: Edit a Music Video storyboard shot + x-apidog-folder: Music Video API/Advanced Editing + summary: Edit Shot security: - BearerAuth: [] description: | @@ -852,7 +929,8 @@ paths: post: operationId: getMusicVideoShotMedia tags: [Music Video] - summary: Retrieve a Music Video storyboard shot media URL + x-apidog-folder: Music Video API/Advanced Editing + summary: Get Shot Media security: - BearerAuth: [] description: | @@ -916,7 +994,8 @@ paths: post: operationId: composeMusicVideoTask tags: [Music Video] - summary: Compose a Music Video task from selected shots + x-apidog-folder: Music Video API/Advanced Editing + summary: Compose Video security: - BearerAuth: [] description: | @@ -966,7 +1045,8 @@ paths: post: operationId: createEcommerceVideoTask tags: [Ecommerce Video] - summary: Create an Ecommerce Video workflow task + x-apidog-folder: Ecommerce Video API + summary: Create Ecommerce Video security: - BearerAuth: [] description: Ecommerce Video requires product images and an explicit output duration. @@ -1062,6 +1142,7 @@ paths: get: operationId: getTask tags: [Tasks] + x-apidog-folder: Reference/Task Status summary: Poll task status security: - BearerAuth: [] @@ -1172,10 +1253,119 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /v1/realtime/sessions: + post: + operationId: createRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Create a realtime browser session + security: + - BearerAuth: [] + description: | + Reserve credits and allocate a short-lived BeatAPI realtime session. Send a unique + `Idempotency-Key`; retries with the same user, key, and body return the same session + and deterministic short-lived `client_secret` without reserving credits or capacity + twice. The browser receives only that BeatAPI secret and connects with + `@beatapi/realtime`. + + Billing is fixed by the selected maximum duration. The BeatAPI browser runtime sends its + first billing heartbeat only after the first remote output frame is rendered. When that + accepted BeatAPI billing heartbeat succeeds, the Session becomes `active` and the selected + duration is fully settled. A Session that closes or expires without an accepted billing + heartbeat is fully refunded. Billing activation comes from the trusted transport lifecycle, + not a caller-supplied browser event. + Production availability remains gated until the documented commercial and capacity launch + checks pass. + parameters: + - in: header + name: Idempotency-Key + required: true + schema: { type: string, maxLength: 128 } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [max_duration_seconds, allowed_origins] + properties: + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + minItems: 1 + maxItems: 10 + items: { type: string, format: uri } + metadata: + type: object + maxProperties: 20 + propertyNames: { maxLength: 64 } + additionalProperties: { type: string, maxLength: 256 } + example: + max_duration_seconds: 60 + allowed_origins: ["https://app.example.com"] + metadata: { customer_id: cus_123 } + responses: + '201': + description: Realtime session created + content: + application/json: + schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Insufficient credits + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '409': + description: Idempotency conflict + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '429': { $ref: '#/components/responses/RateLimited' } + '503': + description: Realtime is disabled or capacity is temporarily unavailable + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + + /v1/realtime/sessions/{session_id}: + parameters: + - in: path + name: session_id + required: true + schema: { type: string, pattern: '^rts_' } + get: + operationId: getRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Get a realtime session + security: [{ BearerAuth: [] }] + responses: + '200': + description: Realtime session + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + delete: + operationId: closeRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Close a realtime session + security: [{ BearerAuth: [] }] + description: Idempotently closes the session, clears temporary credentials, and releases account capacity. + responses: + '200': + description: Realtime session closed + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + /v1/usage: get: operationId: getUsage tags: [Usage] + x-apidog-folder: Reference/Usage & Limits summary: Get account usage and concurrency security: - BearerAuth: [] @@ -1196,6 +1386,10 @@ paths: concurrency: limit: 2 active: 1 + realtime: + sessions: 3 + credits: 90 + active: 1 by_workflow: - workflow: music-video tasks: 8 @@ -1210,6 +1404,7 @@ paths: post: operationId: uploadFile tags: [Files] + x-apidog-folder: Reference/Upload Files summary: Upload a file for workflow inputs security: - BearerAuth: [] @@ -1278,6 +1473,7 @@ paths: get: operationId: listWebhookEndpoints tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: List webhook endpoints security: - BearerAuth: [] @@ -1306,6 +1502,7 @@ paths: post: operationId: createWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Create a webhook endpoint security: - BearerAuth: [] @@ -1426,6 +1623,7 @@ paths: get: operationId: getWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Get a webhook endpoint security: - BearerAuth: [] @@ -1465,6 +1663,7 @@ paths: patch: operationId: updateWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Update a webhook endpoint security: - BearerAuth: [] @@ -1511,6 +1710,7 @@ paths: delete: operationId: deleteWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Delete a webhook endpoint security: - BearerAuth: [] diff --git a/contract/contract.lock.json b/contract/contract.lock.json index 77e44cc..47af603 100644 --- a/contract/contract.lock.json +++ b/contract/contract.lock.json @@ -1,6 +1,6 @@ { "source": "https://github.com/BeatAPI/beatapi-examples", - "ref": "0c1346cbc1a48b1ac89032543e299f90d4620913", + "ref": "8f7d3cff33445ded4d3c94f0fb8ac5060d790148", "openapiVersion": "1.0.0-launch", - "sha256": "4ce0420a65ca14443a089307563e4b422118533982a1e742eded72f32707a52a" + "sha256": "290100dba10bb14b040f5a826657ad7d4a01f179fc28ef69ea0bdcaa66f7dad3" } diff --git a/package-lock.json b/package-lock.json index 46a2e9f..e56282d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beatapi-cli-workspace", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beatapi-cli-workspace", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "workspaces": [ "packages/*" @@ -1067,9 +1067,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.17", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", - "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "version": "1.34.18", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.18.tgz", + "integrity": "sha512-UyKIm0wTPw5BcY7Z2PkbK1Ma260um96LSBWXHrdSMe+ZV0EPMyDfAcUcjjm3qEiGST9OK/1TriekdPCZkn4Q3A==", "dev": true, "license": "MIT", "dependencies": { @@ -1078,7 +1078,7 @@ "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", - "js-yaml": "4.2.0", + "js-yaml": "4.3.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" @@ -1150,11 +1150,14 @@ "license": "Python-2.0" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/beatapi": { "resolved": "packages/cli", @@ -1165,13 +1168,16 @@ "link": true }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/change-case": { @@ -1398,9 +1404,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -1696,10 +1702,10 @@ }, "packages/cli": { "name": "beatapi", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { - "beatapi-client": "0.1.1", + "beatapi-client": "0.2.0", "cross-keychain": "1.1.0" }, "bin": { @@ -1711,7 +1717,7 @@ }, "packages/client": { "name": "beatapi-client", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "engines": { "node": ">=20.19.0 <21 || >=22.12.0" diff --git a/package.json b/package.json index 7973551..387796f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "beatapi-cli-workspace", - "version": "0.1.1", + "version": "0.2.0", "private": true, - "description": "Official TypeScript client and CLI for BeatAPI AI video workflows.", + "description": "Official TypeScript client and CLI for BeatAPI async and realtime AI video APIs.", "type": "module", "workspaces": [ "packages/*" @@ -30,5 +30,8 @@ "tsx": "4.23.1", "typescript": "5.9.3" }, + "overrides": { + "brace-expansion": "5.0.9" + }, "license": "MIT" } diff --git a/packages/cli/README.md b/packages/cli/README.md index 100b43d..af50925 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15,6 +15,9 @@ system's credential manager. For CI and short-lived shells, set ```bash beatapi music-video create --file music-video.json beatapi tasks wait task_123 --interval 7000 +beatapi realtime sessions create --duration 60 \ + --origin https://app.example.com \ + --idempotency-key rt_checkout_123 ``` Results are JSON on stdout. Progress and errors use stderr so the CLI composes @@ -23,5 +26,10 @@ cleanly with shell scripts and automation tools. Webhook creation writes the one-time signing secret to a mode-`0600` file and returns its path as `secret_file`; it does not print the secret. +Realtime session creation prints the API result, including its one-time, +short-lived `client_secret`. Run it only in a trusted terminal, avoid CI log +capture, pass only that secret to the browser SDK, and never expose an `sk_` +API key in browser code. + See the [repository](https://github.com/BeatAPI/beatapi-cli) for the complete command reference and security model. diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e3a754..b73a097 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "beatapi", - "version": "0.1.1", - "description": "Command-line interface for BeatAPI AI music video and ecommerce video workflows.", + "version": "0.2.0", + "description": "Command-line interface for BeatAPI async and realtime AI video APIs.", "type": "module", "bin": { "beatapi": "dist/bin.js" @@ -19,7 +19,7 @@ "test": "tsx --test test/*.test.ts" }, "dependencies": { - "beatapi-client": "0.1.1", + "beatapi-client": "0.2.0", "cross-keychain": "1.1.0" }, "engines": { @@ -44,6 +44,7 @@ "ai-video", "music-video", "ecommerce-video", + "realtime-video", "automation" ], "license": "MIT" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7da2eef..fc437e0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,10 +1,12 @@ import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { basename, extname } from "node:path"; import { BeatAPIClient, type BeatAPITask, type CreateWebhookInput, + type CreateRealtimeSessionInput, type EcommerceVideoTaskInput, type MusicVideoTaskInput, type UpdateWebhookInput, @@ -18,7 +20,7 @@ import { import { promptSecret as defaultPromptSecret } from "./prompt.js"; import { persistWebhookSecret } from "./webhook-secrets.js"; -export const VERSION = "0.1.1"; +export const VERSION = "0.2.0"; const HELP = `BeatAPI CLI ${VERSION} @@ -32,6 +34,9 @@ Usage: beatapi music-video shots media beatapi music-video compose --shot [--shot ] beatapi ecommerce-video create --file + beatapi realtime sessions create --duration <15|60|300> --origin [--origin ] + beatapi realtime sessions get + beatapi realtime sessions close beatapi tasks get beatapi tasks wait [--interval ] [--attempts ] beatapi webhooks list @@ -79,6 +84,12 @@ interface ClientLike { input: { shot_ids: string[] }, ): Promise; createEcommerceVideoTask(input: EcommerceVideoTaskInput): Promise; + createRealtimeSession( + input: CreateRealtimeSessionInput, + options: { idempotencyKey: string }, + ): Promise; + getRealtimeSession(id: string): Promise; + closeRealtimeSession(id: string): Promise; listWebhooks(): Promise; createWebhook(input: CreateWebhookInput): Promise; getWebhook(id: string): Promise; @@ -145,6 +156,25 @@ function positiveInteger( return parsed; } +function realtimeDuration(value: string | undefined): 15 | 60 | 300 { + const parsed = Number(value); + if (parsed !== 15 && parsed !== 60 && parsed !== 300) { + throw new Error("--duration must be 15, 60, or 300 seconds."); + } + return parsed; +} + +function metadataValues(args: string[]): Record | undefined { + const entries = repeatedFlagValues(args, "--metadata").map((entry) => { + const separator = entry.indexOf("="); + if (separator <= 0) { + throw new Error("--metadata values must use key=value syntax."); + } + return [entry.slice(0, separator), entry.slice(separator + 1)] as const; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + async function readJson(path: string | undefined): Promise { if (!path) throw new Error("--file is required."); let text: string; @@ -338,6 +368,59 @@ export async function run( return 0; } + if ( + resource === "realtime" && + action === "sessions" && + firstIdentifier === "create" + ) { + const allowedOrigins = repeatedFlagValues(args, "--origin"); + if (allowedOrigins.length === 0) { + throw new Error("At least one --origin is required."); + } + const metadata = metadataValues(args); + const input: CreateRealtimeSessionInput = { + max_duration_seconds: realtimeDuration(flagValue(args, "--duration")), + allowed_origins: allowedOrigins, + ...(metadata ? { metadata } : {}), + }; + printJson( + await client.createRealtimeSession(input, { + idempotencyKey: + flagValue(args, "--idempotency-key") ?? randomUUID(), + }), + stdout, + ); + return 0; + } + + if ( + resource === "realtime" && + action === "sessions" && + firstIdentifier === "get" + ) { + printJson( + await client.getRealtimeSession( + requireIdentifier(secondIdentifier, "Session ID"), + ), + stdout, + ); + return 0; + } + + if ( + resource === "realtime" && + action === "sessions" && + firstIdentifier === "close" + ) { + printJson( + await client.closeRealtimeSession( + requireIdentifier(secondIdentifier, "Session ID"), + ), + stdout, + ); + return 0; + } + if ( (resource === "tasks" || resource === "task") && action === "get" diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 76011cb..92ce6eb 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -183,6 +183,103 @@ test("supports music-video manual workflow and webhook commands", async () => { assert.deepEqual(calls, ["edit", "media", "compose", "webhooks-list"]); }); +test("supports realtime session create, get, and close commands", async () => { + const output = outputCollector(); + const calls: unknown[] = []; + const client = { + createRealtimeSession: async (input: unknown, options: unknown) => { + calls.push(["create", input, options]); + return { id: "brt_test", status: "ready" }; + }, + getRealtimeSession: async (id: string) => { + calls.push(["get", id]); + return { id, status: "active" }; + }, + closeRealtimeSession: async (id: string) => { + calls.push(["close", id]); + return { id, status: "closed" }; + }, + }; + + assert.equal( + await run( + [ + "realtime", + "sessions", + "create", + "--duration", + "60", + "--origin", + "https://app.example.com", + "--origin", + "https://preview.example.com", + "--metadata", + "customer_id=cus_123", + "--idempotency-key", + "rt_cli_test", + ], + { ...output.io, apiKey: "sk_test", createClient: () => client }, + ), + 0, + ); + assert.equal( + await run(["realtime", "sessions", "get", "brt_test"], { + ...output.io, + apiKey: "sk_test", + createClient: () => client, + }), + 0, + ); + assert.equal( + await run(["realtime", "sessions", "close", "brt_test"], { + ...output.io, + apiKey: "sk_test", + createClient: () => client, + }), + 0, + ); + + assert.deepEqual(calls, [ + [ + "create", + { + max_duration_seconds: 60, + allowed_origins: [ + "https://app.example.com", + "https://preview.example.com", + ], + metadata: { customer_id: "cus_123" }, + }, + { idempotencyKey: "rt_cli_test" }, + ], + ["get", "brt_test"], + ["close", "brt_test"], + ]); +}); + +test("validates realtime session duration and origins", async () => { + const output = outputCollector(); + await assert.rejects( + run( + [ + "realtime", + "sessions", + "create", + "--duration", + "45", + "--origin", + "https://app.example.com", + ], + { + ...output.io, + apiKey: "sk_test", + createClient: () => ({ createRealtimeSession: async () => ({}) }), + }, + ), + /15, 60, or 300/, + ); +}); + test("webhook creation stores the one-time secret instead of printing it", async () => { const output = outputCollector(); const directory = await mkdtemp(resolve(tmpdir(), "beatapi-cli-webhook-")); diff --git a/packages/client/README.md b/packages/client/README.md index 640e451..b28d5b9 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,6 +1,7 @@ # beatapi-client -Official TypeScript client for the public BeatAPI asynchronous video API. +Official TypeScript client for the public BeatAPI asynchronous and Realtime +Video APIs. ```bash npm install beatapi-client @@ -23,8 +24,19 @@ const result = await beatapi.waitForTask(task.id, { intervalMs: 7_000, onUpdate: (update) => console.error(update.status), }); + +const session = await beatapi.createRealtimeSession( + { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + }, + { idempotencyKey: "rt_checkout_123" }, +); ``` +Create Realtime sessions on a trusted server. Send only the short-lived +`session.client_secret` to the browser SDK; never expose the `sk_` API key. + The exported request and response types are generated from the reviewed BeatAPI OpenAPI contract. The runtime client preserves structured API errors, request IDs, retry hints, and supports bounded opt-in retries. diff --git a/packages/client/package.json b/packages/client/package.json index c3a63c4..fc6c065 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,7 +1,7 @@ { "name": "beatapi-client", - "version": "0.1.1", - "description": "Type-safe JavaScript and TypeScript client for the BeatAPI async video API.", + "version": "0.2.0", + "description": "Type-safe JavaScript and TypeScript client for BeatAPI async and realtime video APIs.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,6 +42,7 @@ "ai-video", "music-video", "ecommerce-video", + "realtime-video", "typescript", "sdk" ], diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index ed5346b..30d3f2f 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -8,6 +8,7 @@ export type BeatAPIUsage = components["schemas"]["Usage"]; export type BeatAPIFile = components["schemas"]["File"]; export type BeatAPIShotMedia = components["schemas"]["ShotMedia"]; export type BeatAPIWebhook = components["schemas"]["WebhookEndpoint"]; +export type BeatAPIRealtimeSession = components["schemas"]["RealtimeSession"]; export type BeatAPIDeleteResult = components["schemas"]["DeleteResponse"]["data"]; export type MusicVideoTaskInput = @@ -22,6 +23,8 @@ export type CreateWebhookInput = operations["createWebhookEndpoint"]["requestBody"]["content"]["application/json"]; export type UpdateWebhookInput = operations["updateWebhookEndpoint"]["requestBody"]["content"]["application/json"]; +export type CreateRealtimeSessionInput = + operations["createRealtimeSession"]["requestBody"]["content"]["application/json"]; type FetchLike = ( input: string | URL | Request, @@ -45,6 +48,7 @@ export interface BeatAPIClientOptions { interface RequestOptions { method?: string | undefined; body?: unknown | undefined; + headers?: HeadersInit | undefined; authenticated?: boolean | undefined; retry?: RetryOptions | undefined; } @@ -198,6 +202,9 @@ export class BeatAPIClient { const headers = new Headers({ accept: "application/json" }); if (authenticated) headers.set("authorization", `Bearer ${this.apiKey}`); + for (const [name, value] of new Headers(options.headers)) { + headers.set(name, value); + } let body: BodyInit | undefined; if (options.body instanceof FormData) { @@ -273,6 +280,34 @@ export class BeatAPIClient { return this.request("/v1/usage"); } + createRealtimeSession( + input: CreateRealtimeSessionInput, + options: { idempotencyKey: string }, + ): Promise { + const idempotencyKey = options.idempotencyKey.trim(); + if (!idempotencyKey) { + throw new TypeError("idempotencyKey must not be empty."); + } + return this.request("/v1/realtime/sessions", { + method: "POST", + body: input, + headers: { "idempotency-key": idempotencyKey }, + }); + } + + getRealtimeSession(sessionId: string): Promise { + return this.request( + `/v1/realtime/sessions/${encodePathSegment(sessionId)}`, + ); + } + + closeRealtimeSession(sessionId: string): Promise { + return this.request( + `/v1/realtime/sessions/${encodePathSegment(sessionId)}`, + { method: "DELETE" }, + ); + } + getTask( taskId: string, options: { retry?: RetryOptions } = {}, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 135a775..9aaa491 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3,6 +3,7 @@ export { type BeatAPIClientOptions, type BeatAPIDeleteResult, type BeatAPIFile, + type BeatAPIRealtimeSession, type BeatAPIShotMedia, type BeatAPITask, type BeatAPITaskStatus, @@ -10,6 +11,7 @@ export { type BeatAPIWebhook, type BeatAPIWorkflow, type CreateWebhookInput, + type CreateRealtimeSessionInput, type EcommerceVideoTaskInput, type MusicVideoComposeInput, type MusicVideoShotEditInput, diff --git a/packages/client/src/types.generated.ts b/packages/client/src/types.generated.ts index 309ada8..d030b36 100644 --- a/packages/client/src/types.generated.ts +++ b/packages/client/src/types.generated.ts @@ -31,7 +31,7 @@ export interface paths { get?: never; put?: never; /** - * Create a Music Video workflow task + * Create Music Video * @description Music Video requires public HTTPS image URLs and a public HTTPS audio URL. * Prompt, language, quality, style, lip reference, subtitle, and format * controls are optional. BeatAPI detects the audio duration before task @@ -93,7 +93,7 @@ export interface paths { get?: never; put?: never; /** - * Edit a Music Video storyboard shot + * Edit Shot * @description Edit one storyboard shot using its BeatAPI `shot_id`. This operation * charges BeatAPI customer credits using the selected quality/resolution * rate and the shot duration. Default shot duration is 5 seconds. @@ -118,7 +118,7 @@ export interface paths { get?: never; put?: never; /** - * Retrieve a Music Video storyboard shot media URL + * Get Shot Media * @description Materialize one storyboard shot video using its BeatAPI `shot_id`. * If the shot has not been stored yet, BeatAPI retrieves the current shot * video, stores it under BeatAPI media storage, and returns a BeatAPI media @@ -144,7 +144,7 @@ export interface paths { get?: never; put?: never; /** - * Compose a Music Video task from selected shots + * Compose Video * @description Compose selected BeatAPI storyboard shots into the final Music Video. * This operation charges a fixed 1 BeatAPI customer credit. */ @@ -165,7 +165,7 @@ export interface paths { get?: never; put?: never; /** - * Create an Ecommerce Video workflow task + * Create Ecommerce Video * @description Ecommerce Video requires product images and an explicit output duration. */ post: operations["createEcommerceVideoTask"]; @@ -195,6 +195,62 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/realtime/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a realtime browser session + * @description Reserve credits and allocate a short-lived BeatAPI realtime session. Send a unique + * `Idempotency-Key`; retries with the same user, key, and body return the same session + * and deterministic short-lived `client_secret` without reserving credits or capacity + * twice. The browser receives only that BeatAPI secret and connects with + * `@beatapi/realtime`. + * + * Billing is fixed by the selected maximum duration. The BeatAPI browser runtime sends its + * first billing heartbeat only after the first remote output frame is rendered. When that + * accepted BeatAPI billing heartbeat succeeds, the Session becomes `active` and the selected + * duration is fully settled. A Session that closes or expires without an accepted billing + * heartbeat is fully refunded. Billing activation comes from the trusted transport lifecycle, + * not a caller-supplied browser event. + * Production availability remains gated until the documented commercial and capacity launch + * checks pass. + */ + post: operations["createRealtimeSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/realtime/sessions/{session_id}": { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + /** Get a realtime session */ + get: operations["getRealtimeSession"]; + put?: never; + post?: never; + /** + * Close a realtime session + * @description Idempotently closes the session, clears temporary credentials, and releases account capacity. + */ + delete: operations["closeRealtimeSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/usage": { parameters: { query?: never; @@ -582,10 +638,53 @@ export interface components { tasks: number; credits_settled: number; }[]; + realtime?: { + /** @description Total BeatAPI realtime sessions for this account. */ + sessions: number; + /** @description Credits settled by connected realtime sessions. */ + credits: number; + /** @description Realtime sessions in ready, connecting, or active state. */ + active: number; + }; }; UsageResponse: { data: components["schemas"]["Usage"]; }; + RealtimeSession: { + id: string; + /** @enum {string} */ + object: "realtime.session"; + /** + * @description Active means BeatAPI accepted the first billing heartbeat after remote output began. + * @enum {string} + */ + status: "ready" | "connecting" | "active" | "closed" | "failed" | "expired"; + /** @description Returned only by POST. Give this short-lived BeatAPI secret to the browser SDK; never give the browser an sk_ API key. */ + client_secret?: string; + /** Format: date-time */ + expires_at: string; + /** @enum {integer} */ + max_duration_seconds: 15 | 60 | 300; + allowed_origins: string[]; + credits: { + reserved: number; + settled: number; + refunded: number; + }; + request_id: string; + /** Format: date-time */ + created_at: string; + /** + * Format: date-time + * @description Time of the first accepted BeatAPI billing heartbeat; null before billing activation. + */ + connected_at: string | null; + /** Format: date-time */ + closed_at: string | null; + }; + RealtimeSessionResponse: { + data: components["schemas"]["RealtimeSession"]; + }; FileResponse: { data: components["schemas"]["File"]; }; @@ -609,10 +708,10 @@ export interface components { Error: { error: { /** @enum {string} */ - code: "bad_request" | "unauthorized" | "forbidden" | "not_found" | "insufficient_credits" | "idempotency_conflict" | "user_concurrency_exceeded" | "rate_limit_exceeded" | "processing_unavailable" | "processing_failed" | "processing_timeout" | "result_transfer_failed" | "invalid_signature" | "internal_error"; + code: "bad_request" | "unauthorized" | "forbidden" | "not_found" | "insufficient_credits" | "idempotency_conflict" | "user_concurrency_exceeded" | "rate_limit_exceeded" | "processing_unavailable" | "processing_failed" | "processing_timeout" | "result_transfer_failed" | "invalid_signature" | "realtime_disabled" | "realtime_capacity_unavailable" | "realtime_session_expired" | "origin_not_allowed" | "invalid_client_secret" | "transport_not_allowed" | "internal_error"; message: string; request_id: string; - /** @description Present on rate_limit_exceeded responses when the client should wait before retrying. */ + /** @description Present on retryable rate-limit or capacity responses when the client should wait before retrying. */ retry_after_seconds?: number; }; }; @@ -1178,6 +1277,144 @@ export interface operations { 429: components["responses"]["RateLimited"]; }; }; + createRealtimeSession: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "max_duration_seconds": 60, + * "allowed_origins": [ + * "https://app.example.com" + * ], + * "metadata": { + * "customer_id": "cus_123" + * } + * } + */ + "application/json": { + /** @enum {integer} */ + max_duration_seconds: 15 | 60 | 300; + allowed_origins: string[]; + metadata?: { + [key: string]: string; + }; + }; + }; + }; + responses: { + /** @description Realtime session created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RealtimeSessionResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description Insufficient credits */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Idempotency conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 429: components["responses"]["RateLimited"]; + /** @description Realtime is disabled or capacity is temporarily unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getRealtimeSession: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Realtime session */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RealtimeSessionResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Realtime session not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + closeRealtimeSession: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Realtime session closed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RealtimeSessionResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Realtime session not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getUsage: { parameters: { query?: never; @@ -1205,6 +1442,11 @@ export interface operations { * "limit": 2, * "active": 1 * }, + * "realtime": { + * "sessions": 3, + * "credits": 90, + * "active": 1 + * }, * "by_workflow": [ * { * "workflow": "music-video", diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 8e7e32e..85ec2ab 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -235,3 +235,96 @@ test("exposes the complete launch workflow methods", async () => { { method: "DELETE", path: "/v1/webhooks/wh" }, ]); }); + +test("creates, reads, and closes realtime sessions with safe request semantics", async () => { + const requests: Array<{ + method: string; + path: string; + idempotencyKey: string | null; + body?: unknown; + }> = []; + const client = new BeatAPIClient({ + apiKey: "sk_test_value", + fetch: async (input, init) => { + requests.push({ + method: init?.method || "GET", + path: new URL(String(input)).pathname, + idempotencyKey: + new Headers(init?.headers).get("idempotency-key"), + ...(init?.body + ? { body: JSON.parse(String(init.body)) as unknown } + : {}), + }); + return jsonResponse({ + data: { + id: "brt_test", + object: "realtime.session", + status: "ready", + expires_at: "2026-07-31T12:00:00Z", + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + credits: { reserved: 1, settled: 0, refunded: 0 }, + request_id: "req_test", + created_at: "2026-07-31T11:59:00Z", + connected_at: null, + closed_at: null, + }, + }); + }, + }); + + await client.createRealtimeSession( + { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + }, + { idempotencyKey: "rt-test-key" }, + ); + await client.getRealtimeSession("session/encoded"); + await client.closeRealtimeSession("session/encoded"); + + assert.deepEqual(requests, [ + { + method: "POST", + path: "/v1/realtime/sessions", + idempotencyKey: "rt-test-key", + body: { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + }, + }, + { + method: "GET", + path: "/v1/realtime/sessions/session%2Fencoded", + idempotencyKey: null, + }, + { + method: "DELETE", + path: "/v1/realtime/sessions/session%2Fencoded", + idempotencyKey: null, + }, + ]); +}); + +test("rejects a missing realtime idempotency key before making a request", async () => { + let requests = 0; + const client = new BeatAPIClient({ + apiKey: "sk_test_value", + fetch: async () => { + requests += 1; + return jsonResponse({ data: {} }); + }, + }); + + assert.throws( + () => client.createRealtimeSession( + { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + }, + { idempotencyKey: "" }, + ), + /idempotencyKey must not be empty/, + ); + assert.equal(requests, 0); +}); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 9c53549..56b15cb 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -51,7 +51,10 @@ try { "utf8", ), ); - if (installedPackage.version !== "0.1.1") { + const workspacePackage = JSON.parse( + await readFile(join(root, "packages", "cli", "package.json"), "utf8"), + ); + if (installedPackage.version !== workspacePackage.version) { throw new Error("Installed CLI package version did not match the release."); } From 3b0496133990303cf55db012a3fc207ddb8ade75 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 31 Jul 2026 23:42:55 +0800 Subject: [PATCH 2/2] update npm release ownership guidance --- docs/releasing.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 1e4bc75..757b4f2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,7 +4,8 @@ 1. Use the public GitHub repository `BeatAPI/beatapi-cli`. 2. Log in to npm with the BeatAPI publishing account. -3. Publish or reserve both package names: `beatapi-client` and `beatapi`. +3. Confirm the BeatAPI publishing account still owns both existing package + names: `beatapi-client` and `beatapi`. 4. In GitHub, create an environment named `npm`. 5. Add the publishing owner as a required reviewer for the `npm` environment and allow release tags matching `v*`. @@ -21,9 +22,9 @@ does not store or reference a long-lived `NPM_TOKEN`. GitHub grants the workflow a short-lived identity for each approved release, and npm automatically records provenance for public packages published from the public repository. -The package names were unregistered when this repository was prepared. npm -names are first-come, first-served, so reserve them before announcing the -release. +Both package names are already published. Do not create overlapping Realtime +package names for the same runtime or CLI surface; release Realtime session +support through `beatapi-client` and `beatapi` together. ## Release checklist