diff --git a/.env.live.example b/.env.live.example index 5953b7d..2de5712 100644 --- a/.env.live.example +++ b/.env.live.example @@ -37,3 +37,22 @@ QODER_SA_MODEL=auto # Optional single-region factory input (QODER_SA_KEY), used only explicitly. QODER_SA_KEY= QODER_ACCESS_TOKEN= + +# Separate Realtime Beta entry validates BOTH regions before creating Conversations. +# Uses temporary Identities (deleted during cleanup), original Templates stay unchanged. +QODER_REALTIME_CN_PAT_TEMPLATE_ID= +QODER_REALTIME_GLOBAL_PAT_TEMPLATE_ID= +QODER_REALTIME_CN_SA_TEMPLATE_ID= +QODER_REALTIME_GLOBAL_SA_TEMPLATE_ID= +# Forward-issued bound Admin SATs take priority over platform SA Key exchange for Realtime. +# Current Realtime requires Forward Token bindings; ServiceAccountCredential does not create them. +QODER_REALTIME_CN_SAT= +QODER_REALTIME_GLOBAL_SAT= +QODER_REALTIME_LIVE_ALLOW_WRITE=false +QODER_REALTIME_LIVE_ALLOW_EXECUTION=false +# PAT variant uses ordinary Forward credentials, not a new Realtime token source. +QODER_FORWARD_CN_PAT= +QODER_FORWARD_GLOBAL_PAT= +# Optional PCM/work scenarios (not run by basic connection checks). +QODER_REALTIME_PCM_FILE= +QODER_REALTIME_WORK_PROMPT= diff --git a/CHANGELOG.md b/CHANGELOG.md index ee91be2..d1406a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ existing `0.1.0` release; earlier development prereleases are not listed. - Forward admin and Identity SAT issuance and Forward SAT revocation, using explicit per-call SA Key authentication, typed issuance/revocation metadata, and no automatic retries. Existing platform credentials and PAT/SAT business authentication remain unchanged. +- Forward Realtime Beta Conversation creation with required idempotency keys, independently paginated History, and Node-only authenticated WebSocket connections supporting text, PCM16, work events, playback receipts, interruption and acknowledged graceful close. Connections preserve pre-ready and unknown events, bound queues, support cancellation and separate deadlines, and never automatically reconnect or replay input. + ## [0.4.0] - 2026-10-12 ### Added diff --git a/README.md b/README.md index 798c837..58f982f 100644 --- a/README.md +++ b/README.md @@ -552,3 +552,135 @@ The API reference under `docs/api/` is generated from the source. Regenerate it ## License Released under the [MIT License](LICENSE). + +### Forward Realtime (Beta, Node.js) + +`forward.realtime` supports Conversation creation, persisted History and the +`voice.realtime.v1` WebSocket protocol. A pre-existing enabled Identity and a +Realtime-enabled Template are required. Template Realtime configuration APIs are +not included. The `ws` runtime dependency is installed with the SDK and loaded +only when connecting; existing HTTP imports remain usable in browser runtimes. +Browsers cannot set the required Authorization header, so Realtime connections +require Node.js 20.12 or newer. + +```ts +import { ForwardClient } from 'qca-sdk'; + +const client = new ForwardClient({ pat: process.env.QODER_FORWARD_PAT }); +const conversation = await client.realtime.conversations.create({ + identity_id: process.env.QODER_REALTIME_IDENTITY_ID!, + template_id: process.env.QODER_REALTIME_TEMPLATE_ID!, + idempotency_key: crypto.randomUUID(), // Stable for this create operation and its retries. +}); +const connection = await client.realtime.connect({ + conversationId: conversation.id, + signal: AbortSignal.timeout(180_000), +}); +try { + // connect returns after validated voice.ready; pre-ready events are retained. + await connection.sendText('Hello'); + const event = await connection.receive({ timeout: 30_000 }); + console.log(event.value?.type); + const acknowledgement = await connection.closeGracefully(); + console.log(acknowledgement.outcome); +} finally { + connection.close(); +} + +const history = await client.realtime.conversations.history(conversation.id, { + limit: 50, types: 'message,work', +}); +for await (const event of history) console.log(event.id, event.type); +// Or use history.hasNextPage() / history.getNextPage() / history.iterPages(). +``` + +Realtime currently accepts PAT or a **Forward-issued bound SAT**. A platform SAT +obtained by `ServiceAccountCredential` has no Forward Token binding and is +rejected by the current Realtime service. `ServiceAccountCredential` only +exchanges and renews platform SATs; it does not create a Forward-bound Token. +For SA access, obtain the appropriate Admin/Identity SAT from Forward's Token +management API and pass it through the existing direct-Token interface: + +```ts +import { ForwardClient } from 'qca-sdk'; + +const client = new ForwardClient({ + authMode: 'sa', + accessToken: process.env.QODER_ACCESS_TOKEN!, // Forward-issued bound SAT. +}); +``` + +An Identity SAT is restricted to its authorized Identity; use an Admin SAT for +the live suite that creates temporary Identities. A Token callback or custom +Credential can supply a refreshed Forward-bound SAT. Each new connection +resolves current credentials; an existing WebSocket does not update its +Authorization header when a SAT is renewed. +Credentials are sent only in headers. Redirects, automatic reconnection and input +replay are disabled. Custom HTTP fetch/middleware and defaultQuery do not apply +to the separate WS transport; `transport` accepts a Node agent (including a proxy +agent) and CA/certificate/TLS settings. Per-connection headers override client +headers using the ordinary authentication rules. + +Handshake, ready, send and graceful-close deadlines default to 30, 60, 30 and 10 +seconds, respectively, and can be overridden through connect options. The HTTP +request timeout does not impose a WS lifetime. The service limits one connection +to 60 minutes. AbortSignal cancels and releases the connection. Breaking event iteration only +ends that read phase; keep using the connection or explicitly close it in +`finally`. Incoming events +are bounded to 256 queued events, 4 MiB total, and 1 MiB per frame; slow consumers +receive a queue-overflow error. Sends are serialized and bounded to the same +queue budget. Unknown Beta event types and extension fields remain available; +invalid envelopes, protocol versions or JSON fail explicitly. Server error +codes/retry guidance and abnormal WS close codes/reasons remain accessible. + +`appendAudio(Uint8Array)` accepts nonempty raw **16 kHz mono PCM16 little-endian** +samples; decode WAV and resample in the application. Output `audio.delta` is +Base64 **24 kHz mono PCM16**, decoded by `decodeRealtimeAudio(event)`. The final +client JSON frame, including Base64 expansion, is limited to 256 KiB. VAD detects +speech endings; there is no `audio.commit` event. Text is limited to 16 KiB UTF-8. +The SDK does not capture or play audio. `audio.done` and `work.completed` do not +mean a speaker finished playing. Report actual playback with `playbackStarted`, +`playbackEnded` or `playbackCancelled`, passing the original audio event's +`work_id` and `announcement_id` when present. On `playback.interrupt`, stop and +clear application playback; `interrupt()` sends a user interruption. + +`closeGracefully()` waits for `connection.closed` with its matching request ID +and returns the persistence outcome. Missing acknowledgements, persistence +errors and timeouts fail explicitly. Plain `close()` releases transport without +promising persistence, cancelling Work or deleting the Conversation. History +uses separate `events` / `page.next_before` fields and older-record cursors; +WS sequence/event IDs are not history cursors. Timestamp strings preserve +nanosecond precision. Conversation retention/deletion must be managed outside +this SDK because no public delete API is available. + +Runnable examples reuse the existing PAT/SA environment selection. For SA, +set `QODER_AUTH_MODE=sa` and provide a Forward-issued bound SAT through +`QODER_ACCESS_TOKEN`; leave `QODER_SA_KEY` unset: + +```bash +npm run build +node examples/forward/realtime/text.mjs --help +node examples/forward/realtime/pcm.mjs --help +# Set QODER_REALTIME_IDENTITY_ID and QODER_REALTIME_TEMPLATE_ID before real execution. +node examples/forward/realtime/text.mjs 'Hello' +node examples/forward/realtime/pcm.mjs input.pcm output.pcm +``` + +The separate opt-in two-region entry is `npm run test:live:realtime -- all text` +(or select `-- pat basic` / `-- sa basic`). Configure the selected +`QODER_REALTIME_{CN,GLOBAL}_{PAT,SA}_TEMPLATE_ID` values, +Forward PATs or Forward-issued Admin SATs in +`QODER_REALTIME_CN_SAT` / `QODER_REALTIME_GLOBAL_SAT`, and explicitly enable +`QODER_REALTIME_LIVE_ALLOW_WRITE=true` and +`QODER_REALTIME_LIVE_ALLOW_EXECUTION=true`. Both regions are validated before +creation. Explicit Realtime SATs take priority over region SA Keys. The Key +fallback remains available to detect changes in platform-SAT acceptance, but +currently fails without a Forward Token binding. Basic verifies +create/ready/ping/acknowledged close/History; SA also +checks a direct-SAT reconnect. `text`, `pcm` and `work` scenarios separately run +model/audio/task execution. PCM requires `QODER_REALTIME_PCM_FILE`; work requires +a confirmed trigger in `QODER_REALTIME_WORK_PROMPT`. Logs retain Conversation +and request IDs without tokens. Tests create temporary Identities, delete them on success/failure, preserve the +original Templates and record persisted Conversations because transport close cannot delete them. These entries are not +included in daily PAT tests until explicitly configured; offline CI validates +protocol behavior without network or credentials. diff --git a/docs/api/forward/README.md b/docs/api/forward/README.md index 10c3c72..5b6ed4d 100644 --- a/docs/api/forward/README.md +++ b/docs/api/forward/README.md @@ -321,6 +321,12 @@ Re-exports [Credential](../index/interfaces/Credential.md) *** +### decodeRealtimeAudio + +Re-exports [decodeRealtimeAudio](../index/functions/decodeRealtimeAudio.md) + +*** + ### default Renames and re-exports [ForwardClient](../index/classes/ForwardClient.md) @@ -339,6 +345,12 @@ Re-exports [InternalServerError](../index/classes/InternalServerError.md) *** +### isRealtimeEvent + +Re-exports [isRealtimeEvent](../index/functions/isRealtimeEvent.md) + +*** + ### NotFoundError Re-exports [NotFoundError](../index/classes/NotFoundError.md) @@ -381,6 +393,186 @@ Re-exports [RateLimitError](../index/classes/RateLimitError.md) *** +### Realtime + +Re-exports [Realtime](../index/classes/Realtime.md) + +*** + +### REALTIME\_VERSION + +Re-exports [REALTIME_VERSION](../index/variables/REALTIME_VERSION.md) + +*** + +### RealtimeAudioFormat + +Re-exports [RealtimeAudioFormat](../index/interfaces/RealtimeAudioFormat.md) + +*** + +### RealtimeClientEvent + +Re-exports [RealtimeClientEvent](../index/interfaces/RealtimeClientEvent.md) + +*** + +### RealtimeClosed + +Re-exports [RealtimeClosed](../index/interfaces/RealtimeClosed.md) + +*** + +### RealtimeCloseOptions + +Re-exports [RealtimeCloseOptions](../index/interfaces/RealtimeCloseOptions.md) + +*** + +### RealtimeCloseOutcome + +Re-exports [RealtimeCloseOutcome](../index/type-aliases/RealtimeCloseOutcome.md) + +*** + +### RealtimeConfig + +Re-exports [RealtimeConfig](../index/interfaces/RealtimeConfig.md) + +*** + +### RealtimeConnection + +Re-exports [RealtimeConnection](../index/classes/RealtimeConnection.md) + +*** + +### RealtimeConnectionClosedError + +Re-exports [RealtimeConnectionClosedError](../index/classes/RealtimeConnectionClosedError.md) + +*** + +### RealtimeConnectOptions + +Re-exports [RealtimeConnectOptions](../index/interfaces/RealtimeConnectOptions.md) + +*** + +### RealtimeConversation + +Re-exports [RealtimeConversation](../index/interfaces/RealtimeConversation.md) + +*** + +### RealtimeConversationCreateParams + +Re-exports [RealtimeConversationCreateParams](../index/interfaces/RealtimeConversationCreateParams.md) + +*** + +### RealtimeConversations + +Re-exports [RealtimeConversations](../index/classes/RealtimeConversations.md) + +*** + +### RealtimeErrorPayload + +Re-exports [RealtimeErrorPayload](../index/interfaces/RealtimeErrorPayload.md) + +*** + +### RealtimeEventEnvelope + +Re-exports [RealtimeEventEnvelope](../index/interfaces/RealtimeEventEnvelope.md) + +*** + +### RealtimeHistoryEvent + +Re-exports [RealtimeHistoryEvent](../index/interfaces/RealtimeHistoryEvent.md) + +*** + +### RealtimeHistoryEventType + +Re-exports [RealtimeHistoryEventType](../index/type-aliases/RealtimeHistoryEventType.md) + +*** + +### RealtimeHistoryPage + +Re-exports [RealtimeHistoryPage](../index/classes/RealtimeHistoryPage.md) + +*** + +### RealtimeHistoryParams + +Re-exports [RealtimeHistoryParams](../index/interfaces/RealtimeHistoryParams.md) + +*** + +### RealtimeHistoryResponse + +Re-exports [RealtimeHistoryResponse](../index/interfaces/RealtimeHistoryResponse.md) + +*** + +### RealtimeKnownEvent + +Re-exports [RealtimeKnownEvent](../index/type-aliases/RealtimeKnownEvent.md) + +*** + +### RealtimePlaybackReceipt + +Re-exports [RealtimePlaybackReceipt](../index/interfaces/RealtimePlaybackReceipt.md) + +*** + +### RealtimeProtocolError + +Re-exports [RealtimeProtocolError](../index/classes/RealtimeProtocolError.md) + +*** + +### RealtimeReady + +Re-exports [RealtimeReady](../index/interfaces/RealtimeReady.md) + +*** + +### RealtimeServerError + +Re-exports [RealtimeServerError](../index/classes/RealtimeServerError.md) + +*** + +### RealtimeServerEvent + +Re-exports [RealtimeServerEvent](../index/type-aliases/RealtimeServerEvent.md) + +*** + +### RealtimeServerPayloads + +Re-exports [RealtimeServerPayloads](../index/interfaces/RealtimeServerPayloads.md) + +*** + +### RealtimeVoice + +Re-exports [RealtimeVoice](../index/type-aliases/RealtimeVoice.md) + +*** + +### RealtimeWaitOptions + +Re-exports [RealtimeWaitOptions](../index/interfaces/RealtimeWaitOptions.md) + +*** + ### RequestOptions Re-exports [RequestOptions](../index/interfaces/RequestOptions.md) diff --git a/docs/api/index/README.md b/docs/api/index/README.md index bec4a1c..9af1e02 100644 --- a/docs/api/index/README.md +++ b/docs/api/index/README.md @@ -27,6 +27,13 @@ - [PermissionDeniedError](classes/PermissionDeniedError.md) - [QoderError](classes/QoderError.md) - [RateLimitError](classes/RateLimitError.md) +- [Realtime](classes/Realtime.md) +- [RealtimeConnection](classes/RealtimeConnection.md) +- [RealtimeConnectionClosedError](classes/RealtimeConnectionClosedError.md) +- [RealtimeConversations](classes/RealtimeConversations.md) +- [RealtimeHistoryPage](classes/RealtimeHistoryPage.md) +- [RealtimeProtocolError](classes/RealtimeProtocolError.md) +- [RealtimeServerError](classes/RealtimeServerError.md) - [ResumableSessionEventStream](classes/ResumableSessionEventStream.md) - [ServiceAccountCredential](classes/ServiceAccountCredential.md) - [Stream](classes/Stream.md) @@ -38,6 +45,23 @@ - [ClientOptions](interfaces/ClientOptions.md) - [Credential](interfaces/Credential.md) - [PageResponse](interfaces/PageResponse.md) +- [RealtimeAudioFormat](interfaces/RealtimeAudioFormat.md) +- [RealtimeClientEvent](interfaces/RealtimeClientEvent.md) +- [RealtimeClosed](interfaces/RealtimeClosed.md) +- [RealtimeCloseOptions](interfaces/RealtimeCloseOptions.md) +- [RealtimeConfig](interfaces/RealtimeConfig.md) +- [RealtimeConnectOptions](interfaces/RealtimeConnectOptions.md) +- [RealtimeConversation](interfaces/RealtimeConversation.md) +- [RealtimeConversationCreateParams](interfaces/RealtimeConversationCreateParams.md) +- [RealtimeErrorPayload](interfaces/RealtimeErrorPayload.md) +- [RealtimeEventEnvelope](interfaces/RealtimeEventEnvelope.md) +- [RealtimeHistoryEvent](interfaces/RealtimeHistoryEvent.md) +- [RealtimeHistoryParams](interfaces/RealtimeHistoryParams.md) +- [RealtimeHistoryResponse](interfaces/RealtimeHistoryResponse.md) +- [RealtimePlaybackReceipt](interfaces/RealtimePlaybackReceipt.md) +- [RealtimeReady](interfaces/RealtimeReady.md) +- [RealtimeServerPayloads](interfaces/RealtimeServerPayloads.md) +- [RealtimeWaitOptions](interfaces/RealtimeWaitOptions.md) - [RequestOptions](interfaces/RequestOptions.md) - [ServerSentEvent](interfaces/ServerSentEvent.md) - [ServiceAccountCredentialOptions](interfaces/ServiceAccountCredentialOptions.md) @@ -48,16 +72,24 @@ - [HeadersLike](type-aliases/HeadersLike.md) - [Middleware](type-aliases/Middleware.md) - [PaginationMode](type-aliases/PaginationMode.md) +- [RealtimeCloseOutcome](type-aliases/RealtimeCloseOutcome.md) +- [RealtimeHistoryEventType](type-aliases/RealtimeHistoryEventType.md) +- [RealtimeKnownEvent](type-aliases/RealtimeKnownEvent.md) +- [RealtimeServerEvent](type-aliases/RealtimeServerEvent.md) +- [RealtimeVoice](type-aliases/RealtimeVoice.md) - [Uploadable](type-aliases/Uploadable.md) - [WithRequestID](type-aliases/WithRequestID.md) ## Variables +- [REALTIME\_VERSION](variables/REALTIME_VERSION.md) - [VERSION](variables/VERSION.md) ## Functions +- [decodeRealtimeAudio](functions/decodeRealtimeAudio.md) - [decodeSSE](functions/decodeSSE.md) +- [isRealtimeEvent](functions/isRealtimeEvent.md) - [mergeHeaders](functions/mergeHeaders.md) - [readEnv](functions/readEnv.md) - [stringifyQuery](functions/stringifyQuery.md) diff --git a/docs/api/index/classes/APIClient.md b/docs/api/index/classes/APIClient.md index e726cdf..ccd0f3f 100644 --- a/docs/api/index/classes/APIClient.md +++ b/docs/api/index/classes/APIClient.md @@ -75,6 +75,36 @@ ## Methods +### \_prepareRealtimeRequest() + +> **\_prepareRealtimeRequest**(`options`): `Promise`\<\{ `headers`: `Headers`; `url`: `URL`; \}\> + +**`Internal`** + +Resolve the same public credentials for the separate Node WS transport. + +#### Parameters + +##### options + +###### conversationId + +`string` + +###### headers? + +[`HeadersLike`](../type-aliases/HeadersLike.md) + +###### signal? + +`AbortSignal` \| `null` + +#### Returns + +`Promise`\<\{ `headers`: `Headers`; `url`: `URL`; \}\> + +*** + ### downloadFile() > **downloadFile**(`path`, `options?`): [`APIPromise`](APIPromise.md)\<`Response`\> diff --git a/docs/api/index/classes/APIConnectionError.md b/docs/api/index/classes/APIConnectionError.md index 81c7860..02bd28b 100644 --- a/docs/api/index/classes/APIConnectionError.md +++ b/docs/api/index/classes/APIConnectionError.md @@ -13,6 +13,9 @@ ## Extended by - [`APIConnectionTimeoutError`](APIConnectionTimeoutError.md) +- [`RealtimeProtocolError`](RealtimeProtocolError.md) +- [`RealtimeConnectionClosedError`](RealtimeConnectionClosedError.md) +- [`RealtimeServerError`](RealtimeServerError.md) ## Constructors @@ -168,8 +171,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -203,3 +294,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/APIConnectionTimeoutError.md b/docs/api/index/classes/APIConnectionTimeoutError.md index 5365f43..cce3319 100644 --- a/docs/api/index/classes/APIConnectionTimeoutError.md +++ b/docs/api/index/classes/APIConnectionTimeoutError.md @@ -164,8 +164,96 @@ [`APIConnectionError`](APIConnectionError.md).[`type`](APIConnectionError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stackTraceLimit`](APIConnectionError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`captureStackTrace`](APIConnectionError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -199,3 +287,31 @@ #### Inherited from [`APIConnectionError`](APIConnectionError.md).[`generate`](APIConnectionError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`prepareStackTrace`](APIConnectionError.md#preparestacktrace) diff --git a/docs/api/index/classes/APIError.md b/docs/api/index/classes/APIError.md index 88e674f..decbbb9 100644 --- a/docs/api/index/classes/APIError.md +++ b/docs/api/index/classes/APIError.md @@ -171,8 +171,96 @@ > `readonly` `optional` **type?**: `string` +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`QoderError`](QoderError.md).[`stackTraceLimit`](QoderError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`QoderError`](QoderError.md).[`captureStackTrace`](QoderError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): `APIError`\<`number`, `Headers`\> @@ -202,3 +290,31 @@ #### Returns `APIError`\<`number`, `Headers`\> + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`QoderError`](QoderError.md).[`prepareStackTrace`](QoderError.md#preparestacktrace) diff --git a/docs/api/index/classes/APIUserAbortError.md b/docs/api/index/classes/APIUserAbortError.md index ddf4a14..8022137 100644 --- a/docs/api/index/classes/APIUserAbortError.md +++ b/docs/api/index/classes/APIUserAbortError.md @@ -164,8 +164,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -199,3 +287,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/AuthenticationError.md b/docs/api/index/classes/AuthenticationError.md index 41f47d5..32f2187 100644 --- a/docs/api/index/classes/AuthenticationError.md +++ b/docs/api/index/classes/AuthenticationError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/BadRequestError.md b/docs/api/index/classes/BadRequestError.md index f3d1981..2ecdbb6 100644 --- a/docs/api/index/classes/BadRequestError.md +++ b/docs/api/index/classes/BadRequestError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/ConflictError.md b/docs/api/index/classes/ConflictError.md index 870efe2..37a0a9e 100644 --- a/docs/api/index/classes/ConflictError.md +++ b/docs/api/index/classes/ConflictError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/ForwardClient.md b/docs/api/index/classes/ForwardClient.md index fda088c..8651a25 100644 --- a/docs/api/index/classes/ForwardClient.md +++ b/docs/api/index/classes/ForwardClient.md @@ -140,6 +140,12 @@ *** +### realtime + +> `readonly` **realtime**: [`Realtime`](Realtime.md) + +*** + ### scheduleRuns > `readonly` **scheduleRuns**: [`ScheduleRuns`](../../forward/classes/ScheduleRuns.md) @@ -198,6 +204,40 @@ ## Methods +### \_prepareRealtimeRequest() + +> **\_prepareRealtimeRequest**(`options`): `Promise`\<\{ `headers`: `Headers`; `url`: `URL`; \}\> + +**`Internal`** + +Resolve the same public credentials for the separate Node WS transport. + +#### Parameters + +##### options + +###### conversationId + +`string` + +###### headers? + +[`HeadersLike`](../type-aliases/HeadersLike.md) + +###### signal? + +`AbortSignal` \| `null` + +#### Returns + +`Promise`\<\{ `headers`: `Headers`; `url`: `URL`; \}\> + +#### Inherited from + +[`APIClient`](APIClient.md).[`_prepareRealtimeRequest`](APIClient.md#_preparerealtimerequest) + +*** + ### downloadFile() > **downloadFile**(`path`, `options?`): [`APIPromise`](APIPromise.md)\<`Response`\> diff --git a/docs/api/index/classes/InternalServerError.md b/docs/api/index/classes/InternalServerError.md index 4e09850..cbc482a 100644 --- a/docs/api/index/classes/InternalServerError.md +++ b/docs/api/index/classes/InternalServerError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/ManagedClient.md b/docs/api/index/classes/ManagedClient.md index 560beab..d3f3666 100644 --- a/docs/api/index/classes/ManagedClient.md +++ b/docs/api/index/classes/ManagedClient.md @@ -170,6 +170,40 @@ Qoder Managed Mode API client. ## Methods +### \_prepareRealtimeRequest() + +> **\_prepareRealtimeRequest**(`options`): `Promise`\<\{ `headers`: `Headers`; `url`: `URL`; \}\> + +**`Internal`** + +Resolve the same public credentials for the separate Node WS transport. + +#### Parameters + +##### options + +###### conversationId + +`string` + +###### headers? + +[`HeadersLike`](../type-aliases/HeadersLike.md) + +###### signal? + +`AbortSignal` \| `null` + +#### Returns + +`Promise`\<\{ `headers`: `Headers`; `url`: `URL`; \}\> + +#### Inherited from + +[`APIClient`](APIClient.md).[`_prepareRealtimeRequest`](APIClient.md#_preparerealtimerequest) + +*** + ### downloadFile() > **downloadFile**(`path`, `options?`): [`APIPromise`](APIPromise.md)\<`Response`\> diff --git a/docs/api/index/classes/NotFoundError.md b/docs/api/index/classes/NotFoundError.md index 73a2829..45519f0 100644 --- a/docs/api/index/classes/NotFoundError.md +++ b/docs/api/index/classes/NotFoundError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/PermissionDeniedError.md b/docs/api/index/classes/PermissionDeniedError.md index 5a8c5fc..769af07 100644 --- a/docs/api/index/classes/PermissionDeniedError.md +++ b/docs/api/index/classes/PermissionDeniedError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/QoderError.md b/docs/api/index/classes/QoderError.md index 69aa225..de09501 100644 --- a/docs/api/index/classes/QoderError.md +++ b/docs/api/index/classes/QoderError.md @@ -77,3 +77,119 @@ #### Inherited from `Error.stack` + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +`Error.stackTraceLimit` + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +`Error.captureStackTrace` + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +`Error.prepareStackTrace` diff --git a/docs/api/index/classes/RateLimitError.md b/docs/api/index/classes/RateLimitError.md index 0b0bc86..747e955 100644 --- a/docs/api/index/classes/RateLimitError.md +++ b/docs/api/index/classes/RateLimitError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/classes/Realtime.md b/docs/api/index/classes/Realtime.md new file mode 100644 index 0000000..6817944 --- /dev/null +++ b/docs/api/index/classes/Realtime.md @@ -0,0 +1,65 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / Realtime + +# Class: Realtime + +Forward Realtime Beta. WebSocket transport is supported on Node.js only. + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new Realtime**(`_client`): `Realtime` + +#### Parameters + +##### \_client + +[`APIClient`](APIClient.md) + +#### Returns + +`Realtime` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](APIClient.md) + +#### Inherited from + +`APIResource._client` + +*** + +### conversations + +> `readonly` **conversations**: [`RealtimeConversations`](RealtimeConversations.md) + +## Methods + +### connect() + +> **connect**(`options`): `Promise`\<[`RealtimeConnection`](RealtimeConnection.md)\> + +#### Parameters + +##### options + +[`RealtimeConnectOptions`](../interfaces/RealtimeConnectOptions.md) + +#### Returns + +`Promise`\<[`RealtimeConnection`](RealtimeConnection.md)\> diff --git a/docs/api/index/classes/RealtimeConnection.md b/docs/api/index/classes/RealtimeConnection.md new file mode 100644 index 0000000..4e485d4 --- /dev/null +++ b/docs/api/index/classes/RealtimeConnection.md @@ -0,0 +1,269 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConnection + +# Class: RealtimeConnection + +A Node-only connection with one receiver and bounded input/output queues. No reconnect or replay. + +## Implements + +- `AsyncIterable`\<[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md)\> + +## Properties + +### conversationId + +> `readonly` **conversationId**: `string` + +*** + +### ready + +> **ready**: [`RealtimeReady`](../interfaces/RealtimeReady.md) + +*** + +### request\_id + +> **request\_id**: `string` \| `null` = `null` + +## Methods + +### \[asyncIterator\]() + +> **\[asyncIterator\]**(): `AsyncGenerator`\<[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md)\> + +#### Returns + +`AsyncGenerator`\<[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md)\> + +#### Implementation of + +`AsyncIterable.[asyncIterator]` + +*** + +### appendAudio() + +> **appendAudio**(`pcm`, `options?`): `Promise`\<`void`\> + +#### Parameters + +##### pcm + +`Uint8Array` + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### close() + +> **close**(): `void` + +Transport close does not promise history persistence or cancel background Work. + +#### Returns + +`void` + +*** + +### closeGracefully() + +> **closeGracefully**(`options?`): `Promise`\<[`RealtimeClosed`](../interfaces/RealtimeClosed.md)\> + +Send connection.close, require its matching persistence acknowledgement, then release transport. + +#### Parameters + +##### options? + +[`RealtimeCloseOptions`](../interfaces/RealtimeCloseOptions.md) = `{}` + +#### Returns + +`Promise`\<[`RealtimeClosed`](../interfaces/RealtimeClosed.md)\> + +*** + +### interrupt() + +> **interrupt**(`options?`): `Promise`\<`void`\> + +#### Parameters + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### ping() + +> **ping**(`options?`): `Promise`\<`void`\> + +#### Parameters + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### playbackCancelled() + +> **playbackCancelled**(`receipt?`, `options?`): `Promise`\<`void`\> + +#### Parameters + +##### receipt? + +[`RealtimePlaybackReceipt`](../interfaces/RealtimePlaybackReceipt.md) = `{}` + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### playbackEnded() + +> **playbackEnded**(`receipt?`, `options?`): `Promise`\<`void`\> + +#### Parameters + +##### receipt? + +[`RealtimePlaybackReceipt`](../interfaces/RealtimePlaybackReceipt.md) = `{}` + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### playbackStarted() + +> **playbackStarted**(`receipt?`, `options?`): `Promise`\<`void`\> + +#### Parameters + +##### receipt? + +[`RealtimePlaybackReceipt`](../interfaces/RealtimePlaybackReceipt.md) = `{}` + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### receive() + +> **receive**(`options?`): `Promise`\<`IteratorResult`\<[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md), `any`\>\> + +Read one event. Concurrent consumers are rejected instead of stealing each other's events. + +#### Parameters + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) = `{}` + +#### Returns + +`Promise`\<`IteratorResult`\<[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md), `any`\>\> + +*** + +### sendEvent() + +> **sendEvent**(`event`, `options?`): `Promise`\<`void`\> + +Serialize sends in call order; no input is automatically replayed. + +#### Parameters + +##### event + +[`RealtimeClientEvent`](../interfaces/RealtimeClientEvent.md) + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) = `{}` + +#### Returns + +`Promise`\<`void`\> + +*** + +### sendText() + +> **sendText**(`text`, `options?`): `Promise`\<`void`\> + +#### Parameters + +##### text + +`string` + +##### options? + +[`RealtimeWaitOptions`](../interfaces/RealtimeWaitOptions.md) + +#### Returns + +`Promise`\<`void`\> + +*** + +### connect() + +> `static` **connect**(`client`, `options`): `Promise`\<`RealtimeConnection`\> + +**`Internal`** + +#### Parameters + +##### client + +[`APIClient`](APIClient.md) + +##### options + +[`RealtimeConnectOptions`](../interfaces/RealtimeConnectOptions.md) + +#### Returns + +`Promise`\<`RealtimeConnection`\> diff --git a/docs/api/index/classes/RealtimeConnectionClosedError.md b/docs/api/index/classes/RealtimeConnectionClosedError.md new file mode 100644 index 0000000..418b3f4 --- /dev/null +++ b/docs/api/index/classes/RealtimeConnectionClosedError.md @@ -0,0 +1,339 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConnectionClosedError + +# Class: RealtimeConnectionClosedError + +## Extends + +- [`APIConnectionError`](APIConnectionError.md) + +## Constructors + +### Constructor + +> **new RealtimeConnectionClosedError**(`conversationId`, `closeCode`, `closeReason`): `RealtimeConnectionClosedError` + +#### Parameters + +##### conversationId + +`string` + +##### closeCode + +`number` + +##### closeReason + +`string` + +#### Returns + +`RealtimeConnectionClosedError` + +#### Overrides + +[`APIConnectionError`](APIConnectionError.md).[`constructor`](APIConnectionError.md#constructor) + +## Properties + +### cause? + +> `optional` **cause?**: `unknown` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`cause`](APIConnectionError.md#cause) + +*** + +### closeCode + +> `readonly` **closeCode**: `number` + +*** + +### closeReason + +> `readonly` **closeReason**: `string` + +*** + +### code? + +> `readonly` `optional` **code?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`code`](APIConnectionError.md#code) + +*** + +### conversationId + +> `readonly` **conversationId**: `string` + +*** + +### error + +> `readonly` **error**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`error`](APIConnectionError.md#error) + +*** + +### headers + +> `readonly` **headers**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`headers`](APIConnectionError.md#headers) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`message`](APIConnectionError.md#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`name`](APIConnectionError.md#name) + +*** + +### request? + +> `optional` **request?**: `Request` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request`](APIConnectionError.md#request) + +*** + +### request\_id + +> `readonly` **request\_id**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request_id`](APIConnectionError.md#request_id) + +*** + +### requestID + +> `readonly` **requestID**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`requestID`](APIConnectionError.md#requestid) + +*** + +### response? + +> `readonly` `optional` **response?**: `Response` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`response`](APIConnectionError.md#response) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stack`](APIConnectionError.md#stack) + +*** + +### status + +> `readonly` **status**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`status`](APIConnectionError.md#status) + +*** + +### type? + +> `readonly` `optional` **type?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`type`](APIConnectionError.md#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stackTraceLimit`](APIConnectionError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`captureStackTrace`](APIConnectionError.md#capturestacktrace) + +*** + +### generate() + +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Parameters + +##### status + +`number` + +##### error + +`unknown` + +##### message? + +`string` + +##### headers? + +`Headers` = `...` + +##### response? + +`Response` + +#### Returns + +[`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`generate`](APIConnectionError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`prepareStackTrace`](APIConnectionError.md#preparestacktrace) diff --git a/docs/api/index/classes/RealtimeConversations.md b/docs/api/index/classes/RealtimeConversations.md new file mode 100644 index 0000000..a865cff --- /dev/null +++ b/docs/api/index/classes/RealtimeConversations.md @@ -0,0 +1,89 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConversations + +# Class: RealtimeConversations + +## Extends + +- `APIResource` + +## Constructors + +### Constructor + +> **new RealtimeConversations**(`_client`): `RealtimeConversations` + +#### Parameters + +##### \_client + +[`APIClient`](APIClient.md) + +#### Returns + +`RealtimeConversations` + +#### Inherited from + +`APIResource.constructor` + +## Properties + +### \_client + +> `protected` `readonly` **\_client**: [`APIClient`](APIClient.md) + +#### Inherited from + +`APIResource._client` + +## Methods + +### create() + +> **create**(`params`, `options?`): [`APIPromise`](APIPromise.md)\<[`RealtimeConversation`](../interfaces/RealtimeConversation.md)\> + +Create a Beta Realtime Conversation; use a stable key for this operation. + +#### Parameters + +##### params + +[`RealtimeConversationCreateParams`](../interfaces/RealtimeConversationCreateParams.md) + +##### options? + +[`RequestOptions`](../interfaces/RequestOptions.md) = `{}` + +#### Returns + +[`APIPromise`](APIPromise.md)\<[`RealtimeConversation`](../interfaces/RealtimeConversation.md)\> + +*** + +### history() + +> **history**(`conversationID`, `params?`, `options?`): [`APIPromise`](APIPromise.md)\<[`RealtimeHistoryPage`](RealtimeHistoryPage.md)\> + +Query persisted history, newest first; next_before retrieves older records. + +#### Parameters + +##### conversationID + +`string` + +##### params? + +[`RealtimeHistoryParams`](../interfaces/RealtimeHistoryParams.md) = `{}` + +##### options? + +[`RequestOptions`](../interfaces/RequestOptions.md) = `{}` + +#### Returns + +[`APIPromise`](APIPromise.md)\<[`RealtimeHistoryPage`](RealtimeHistoryPage.md)\> diff --git a/docs/api/index/classes/RealtimeHistoryPage.md b/docs/api/index/classes/RealtimeHistoryPage.md new file mode 100644 index 0000000..faf372e --- /dev/null +++ b/docs/api/index/classes/RealtimeHistoryPage.md @@ -0,0 +1,190 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeHistoryPage + +# Class: RealtimeHistoryPage + +History uses before cursors and events, independently of ordinary list pages. + +## Implements + +- [`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md) +- `AsyncIterable`\<[`RealtimeHistoryEvent`](../interfaces/RealtimeHistoryEvent.md)\> + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Constructors + +### Constructor + +> **new RealtimeHistoryPage**(`client`, `conversationID`, `params`, `options`, `data`, `response`): `RealtimeHistoryPage` + +#### Parameters + +##### client + +[`APIClient`](APIClient.md) + +##### conversationID + +`string` + +##### params + +[`RealtimeHistoryParams`](../interfaces/RealtimeHistoryParams.md) + +##### options + +[`RequestOptions`](../interfaces/RequestOptions.md) + +##### data + +[`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md) + +##### response + +`Response` + +#### Returns + +`RealtimeHistoryPage` + +## Properties + +### \_request\_id + +> `readonly` **\_request\_id**: `string` \| `null` + +*** + +### conversation + +> `readonly` **conversation**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### created\_at + +> **created\_at**: `string` + +#### id + +> **id**: `string` + +#### initialization\_status + +> **initialization\_status**: `string` + +#### metadata + +> **metadata**: `Record`\<`string`, `unknown`\> + +#### title + +> **title**: `string` \| `null` + +#### updated\_at + +> **updated\_at**: `string` + +#### Implementation of + +[`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md).[`conversation`](../interfaces/RealtimeHistoryResponse.md#conversation) + +*** + +### events + +> `readonly` **events**: [`RealtimeHistoryEvent`](../interfaces/RealtimeHistoryEvent.md)[] + +#### Implementation of + +[`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md).[`events`](../interfaces/RealtimeHistoryResponse.md#events) + +*** + +### page + +> `readonly` **page**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### has\_more + +> **has\_more**: `boolean` + +#### next\_before + +> **next\_before**: `string` \| `null` + +#### Implementation of + +[`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md).[`page`](../interfaces/RealtimeHistoryResponse.md#page) + +*** + +### response + +> `readonly` **response**: `Response` + +## Methods + +### \[asyncIterator\]() + +> **\[asyncIterator\]**(): `AsyncGenerator`\<[`RealtimeHistoryEvent`](../interfaces/RealtimeHistoryEvent.md)\> + +#### Returns + +`AsyncGenerator`\<[`RealtimeHistoryEvent`](../interfaces/RealtimeHistoryEvent.md)\> + +#### Implementation of + +`AsyncIterable.[asyncIterator]` + +*** + +### getNextPage() + +> **getNextPage**(): [`APIPromise`](APIPromise.md)\<`RealtimeHistoryPage`\> + +#### Returns + +[`APIPromise`](APIPromise.md)\<`RealtimeHistoryPage`\> + +*** + +### hasNextPage() + +> **hasNextPage**(): `boolean` + +#### Returns + +`boolean` + +*** + +### iterPages() + +> **iterPages**(): `AsyncGenerator`\<`RealtimeHistoryPage`\> + +#### Returns + +`AsyncGenerator`\<`RealtimeHistoryPage`\> + +*** + +### toJSON() + +> **toJSON**(): [`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md) + +#### Returns + +[`RealtimeHistoryResponse`](../interfaces/RealtimeHistoryResponse.md) diff --git a/docs/api/index/classes/RealtimeProtocolError.md b/docs/api/index/classes/RealtimeProtocolError.md new file mode 100644 index 0000000..e2ca53b --- /dev/null +++ b/docs/api/index/classes/RealtimeProtocolError.md @@ -0,0 +1,327 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeProtocolError + +# Class: RealtimeProtocolError + +## Extends + +- [`APIConnectionError`](APIConnectionError.md) + +## Constructors + +### Constructor + +> **new RealtimeProtocolError**(`message`, `conversationId`, `code?`): `RealtimeProtocolError` + +#### Parameters + +##### message + +`string` + +##### conversationId + +`string` + +##### code? + +`string` = `'invalid_event'` + +#### Returns + +`RealtimeProtocolError` + +#### Overrides + +[`APIConnectionError`](APIConnectionError.md).[`constructor`](APIConnectionError.md#constructor) + +## Properties + +### cause? + +> `optional` **cause?**: `unknown` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`cause`](APIConnectionError.md#cause) + +*** + +### code + +> `readonly` **code**: `string` = `'invalid_event'` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`code`](APIConnectionError.md#code) + +*** + +### conversationId + +> `readonly` **conversationId**: `string` + +*** + +### error + +> `readonly` **error**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`error`](APIConnectionError.md#error) + +*** + +### headers + +> `readonly` **headers**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`headers`](APIConnectionError.md#headers) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`message`](APIConnectionError.md#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`name`](APIConnectionError.md#name) + +*** + +### request? + +> `optional` **request?**: `Request` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request`](APIConnectionError.md#request) + +*** + +### request\_id + +> `readonly` **request\_id**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request_id`](APIConnectionError.md#request_id) + +*** + +### requestID + +> `readonly` **requestID**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`requestID`](APIConnectionError.md#requestid) + +*** + +### response? + +> `readonly` `optional` **response?**: `Response` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`response`](APIConnectionError.md#response) + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stack`](APIConnectionError.md#stack) + +*** + +### status + +> `readonly` **status**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`status`](APIConnectionError.md#status) + +*** + +### type? + +> `readonly` `optional` **type?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`type`](APIConnectionError.md#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stackTraceLimit`](APIConnectionError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`captureStackTrace`](APIConnectionError.md#capturestacktrace) + +*** + +### generate() + +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Parameters + +##### status + +`number` + +##### error + +`unknown` + +##### message? + +`string` + +##### headers? + +`Headers` = `...` + +##### response? + +`Response` + +#### Returns + +[`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`generate`](APIConnectionError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`prepareStackTrace`](APIConnectionError.md#preparestacktrace) diff --git a/docs/api/index/classes/RealtimeServerError.md b/docs/api/index/classes/RealtimeServerError.md new file mode 100644 index 0000000..f071aa4 --- /dev/null +++ b/docs/api/index/classes/RealtimeServerError.md @@ -0,0 +1,331 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeServerError + +# Class: RealtimeServerError + +## Extends + +- [`APIConnectionError`](APIConnectionError.md) + +## Constructors + +### Constructor + +> **new RealtimeServerError**(`event`): `RealtimeServerError` + +#### Parameters + +##### event + +[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md) + +#### Returns + +`RealtimeServerError` + +#### Overrides + +[`APIConnectionError`](APIConnectionError.md).[`constructor`](APIConnectionError.md#constructor) + +## Properties + +### cause? + +> `optional` **cause?**: `unknown` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`cause`](APIConnectionError.md#cause) + +*** + +### code + +> `readonly` **code**: `string` + +#### Overrides + +[`APIConnectionError`](APIConnectionError.md).[`code`](APIConnectionError.md#code) + +*** + +### error + +> `readonly` **error**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`error`](APIConnectionError.md#error) + +*** + +### event + +> `readonly` **event**: [`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md) + +*** + +### headers + +> `readonly` **headers**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`headers`](APIConnectionError.md#headers) + +*** + +### message + +> **message**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`message`](APIConnectionError.md#message) + +*** + +### name + +> **name**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`name`](APIConnectionError.md#name) + +*** + +### request? + +> `optional` **request?**: `Request` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request`](APIConnectionError.md#request) + +*** + +### request\_id + +> `readonly` **request\_id**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`request_id`](APIConnectionError.md#request_id) + +*** + +### requestID + +> `readonly` **requestID**: `string` \| `null` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`requestID`](APIConnectionError.md#requestid) + +*** + +### response? + +> `readonly` `optional` **response?**: `Response` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`response`](APIConnectionError.md#response) + +*** + +### retry\_after\_ms? + +> `readonly` `optional` **retry\_after\_ms?**: `number` + +*** + +### retryable? + +> `readonly` `optional` **retryable?**: `boolean` + +*** + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stack`](APIConnectionError.md#stack) + +*** + +### status + +> `readonly` **status**: `undefined` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`status`](APIConnectionError.md#status) + +*** + +### type? + +> `readonly` `optional` **type?**: `string` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`type`](APIConnectionError.md#type) + +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`stackTraceLimit`](APIConnectionError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`captureStackTrace`](APIConnectionError.md#capturestacktrace) + +*** + +### generate() + +> `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Parameters + +##### status + +`number` + +##### error + +`unknown` + +##### message? + +`string` + +##### headers? + +`Headers` = `...` + +##### response? + +`Response` + +#### Returns + +[`APIError`](APIError.md)\<`number`, `Headers`\> + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`generate`](APIConnectionError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIConnectionError`](APIConnectionError.md).[`prepareStackTrace`](APIConnectionError.md#preparestacktrace) diff --git a/docs/api/index/classes/UnprocessableEntityError.md b/docs/api/index/classes/UnprocessableEntityError.md index 3652c2a..7695f4e 100644 --- a/docs/api/index/classes/UnprocessableEntityError.md +++ b/docs/api/index/classes/UnprocessableEntityError.md @@ -180,8 +180,96 @@ [`APIError`](APIError.md).[`type`](APIError.md#type) +*** + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`APIError`](APIError.md).[`stackTraceLimit`](APIError.md#stacktracelimit) + ## Methods +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`APIError`](APIError.md).[`captureStackTrace`](APIError.md#capturestacktrace) + +*** + ### generate() > `static` **generate**(`status`, `error`, `message?`, `headers?`, `response?`): [`APIError`](APIError.md)\<`number`, `Headers`\> @@ -215,3 +303,31 @@ #### Inherited from [`APIError`](APIError.md).[`generate`](APIError.md#generate) + +*** + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`APIError`](APIError.md).[`prepareStackTrace`](APIError.md#preparestacktrace) diff --git a/docs/api/index/functions/decodeRealtimeAudio.md b/docs/api/index/functions/decodeRealtimeAudio.md new file mode 100644 index 0000000..950396d --- /dev/null +++ b/docs/api/index/functions/decodeRealtimeAudio.md @@ -0,0 +1,21 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / decodeRealtimeAudio + +# Function: decodeRealtimeAudio() + +> **decodeRealtimeAudio**(`event`): `Uint8Array` + +Decode an audio.delta payload into raw 24 kHz PCM16 bytes; this does not play it. + +## Parameters + +### event + +[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md) + +## Returns + +`Uint8Array` diff --git a/docs/api/index/functions/isRealtimeEvent.md b/docs/api/index/functions/isRealtimeEvent.md new file mode 100644 index 0000000..0756987 --- /dev/null +++ b/docs/api/index/functions/isRealtimeEvent.md @@ -0,0 +1,31 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / isRealtimeEvent + +# Function: isRealtimeEvent() + +> **isRealtimeEvent**\<`K`\>(`event`, `type`): event is Extract\; type: "error" \}, \{ type: K \}\> \| Extract\; type: "voice.state" \}, \{ type: K \}\> \| Extract\; type: "voice.ready" \}, \{ type: K \}\> \| Extract\; type: "voice.replaced" \}, \{ type: K \}\> \| Extract\; type: "transcript.delta" \}, \{ type: K \}\> \| Extract\; type: "transcript.final" \}, \{ type: K \}\> \| Extract\; type: "audio.delta" \}, \{ type: K \}\> \| Extract\ & Record\; type: "audio.done" \}, \{ type: K \}\> \| Extract\; type: "playback.interrupt" \}, \{ type: K \}\> \| Extract\; type: "work.accepted" \}, \{ type: K \}\> \| Extract\; type: "work.running" \}, \{ type: K \}\> \| Extract\; type: "work.progress" \}, \{ type: K \}\> \| Extract\; type: "work.milestone" \}, \{ type: K \}\> \| Extract\; type: "work.completed" \}, \{ type: K \}\> \| Extract\; type: "work.failed" \}, \{ type: K \}\> \| Extract\ & Record\; type: "pong" \}, \{ type: K \}\> \| Extract\; type: "connection.closed" \}, \{ type: K \}\> + +Narrow a known event payload while continuing to allow future Beta event types. + +## Type Parameters + +### K + +`K` *extends* keyof [`RealtimeServerPayloads`](../interfaces/RealtimeServerPayloads.md) + +## Parameters + +### event + +[`RealtimeServerEvent`](../type-aliases/RealtimeServerEvent.md) + +### type + +`K` + +## Returns + +event is Extract\; type: "error" \}, \{ type: K \}\> \| Extract\; type: "voice.state" \}, \{ type: K \}\> \| Extract\; type: "voice.ready" \}, \{ type: K \}\> \| Extract\; type: "voice.replaced" \}, \{ type: K \}\> \| Extract\; type: "transcript.delta" \}, \{ type: K \}\> \| Extract\; type: "transcript.final" \}, \{ type: K \}\> \| Extract\; type: "audio.delta" \}, \{ type: K \}\> \| Extract\ & Record\; type: "audio.done" \}, \{ type: K \}\> \| Extract\; type: "playback.interrupt" \}, \{ type: K \}\> \| Extract\; type: "work.accepted" \}, \{ type: K \}\> \| Extract\; type: "work.running" \}, \{ type: K \}\> \| Extract\; type: "work.progress" \}, \{ type: K \}\> \| Extract\; type: "work.milestone" \}, \{ type: K \}\> \| Extract\; type: "work.completed" \}, \{ type: K \}\> \| Extract\; type: "work.failed" \}, \{ type: K \}\> \| Extract\ & Record\; type: "pong" \}, \{ type: K \}\> \| Extract\; type: "connection.closed" \}, \{ type: K \}\> diff --git a/docs/api/index/interfaces/ClientOptions.md b/docs/api/index/interfaces/ClientOptions.md index 3f4daf2..c7d80ca 100644 --- a/docs/api/index/interfaces/ClientOptions.md +++ b/docs/api/index/interfaces/ClientOptions.md @@ -46,21 +46,45 @@ ### fetch? -> `optional` **fetch?**: (`input`, `init?`) => `Promise`\<`Response`\> +> `optional` **fetch?**: \{(`input`, `init?`): `Promise`\<`Response`\>; (`input`, `init?`): `Promise`\<`Response`\>; \} + +#### Call Signature + +> (`input`, `init?`): `Promise`\<`Response`\> + +[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) + +##### Parameters + +###### input + +`URL` \| `RequestInfo` + +###### init? + +`RequestInit` + +##### Returns + +`Promise`\<`Response`\> + +#### Call Signature + +> (`input`, `init?`): `Promise`\<`Response`\> [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) -#### Parameters +##### Parameters -##### input +###### input -`RequestInfo` \| `URL` +`string` \| `Request` \| `URL` -##### init? +###### init? `RequestInit` -#### Returns +##### Returns `Promise`\<`Response`\> diff --git a/docs/api/index/interfaces/RealtimeAudioFormat.md b/docs/api/index/interfaces/RealtimeAudioFormat.md new file mode 100644 index 0000000..4cfd699 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeAudioFormat.md @@ -0,0 +1,29 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeAudioFormat + +# Interface: RealtimeAudioFormat + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### channels + +> **channels**: `1` + +*** + +### format + +> **format**: `"pcm16"` + +*** + +### sample\_rate + +> **sample\_rate**: `number` diff --git a/docs/api/index/interfaces/RealtimeClientEvent.md b/docs/api/index/interfaces/RealtimeClientEvent.md new file mode 100644 index 0000000..68bcc74 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeClientEvent.md @@ -0,0 +1,25 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeClientEvent + +# Interface: RealtimeClientEvent + +## Properties + +### payload + +> **payload**: `Record`\<`string`, `unknown`\> + +*** + +### type + +> **type**: `string` + +*** + +### version + +> **version**: `"voice.realtime.v1"` diff --git a/docs/api/index/interfaces/RealtimeCloseOptions.md b/docs/api/index/interfaces/RealtimeCloseOptions.md new file mode 100644 index 0000000..b8b0d8f --- /dev/null +++ b/docs/api/index/interfaces/RealtimeCloseOptions.md @@ -0,0 +1,37 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeCloseOptions + +# Interface: RealtimeCloseOptions + +## Extends + +- [`RealtimeWaitOptions`](RealtimeWaitOptions.md) + +## Properties + +### requestId? + +> `optional` **requestId?**: `string` + +*** + +### signal? + +> `optional` **signal?**: `AbortSignal` \| `null` + +#### Inherited from + +[`RealtimeWaitOptions`](RealtimeWaitOptions.md).[`signal`](RealtimeWaitOptions.md#signal) + +*** + +### timeout? + +> `optional` **timeout?**: `number` + +#### Inherited from + +[`RealtimeWaitOptions`](RealtimeWaitOptions.md).[`timeout`](RealtimeWaitOptions.md#timeout) diff --git a/docs/api/index/interfaces/RealtimeClosed.md b/docs/api/index/interfaces/RealtimeClosed.md new file mode 100644 index 0000000..4fa270f --- /dev/null +++ b/docs/api/index/interfaces/RealtimeClosed.md @@ -0,0 +1,19 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeClosed + +# Interface: RealtimeClosed + +## Properties + +### outcome + +> **outcome**: [`RealtimeCloseOutcome`](../type-aliases/RealtimeCloseOutcome.md) + +*** + +### request\_id + +> **request\_id**: `string` diff --git a/docs/api/index/interfaces/RealtimeConfig.md b/docs/api/index/interfaces/RealtimeConfig.md new file mode 100644 index 0000000..9a04afa --- /dev/null +++ b/docs/api/index/interfaces/RealtimeConfig.md @@ -0,0 +1,33 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConfig + +# Interface: RealtimeConfig + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### audio? + +> `optional` **audio?**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### output? + +> `optional` **output?**: `object` + +##### Index Signature + +\[`key`: `string`\]: `unknown` + +##### output.voice? + +> `optional` **voice?**: [`RealtimeVoice`](../type-aliases/RealtimeVoice.md) diff --git a/docs/api/index/interfaces/RealtimeConnectOptions.md b/docs/api/index/interfaces/RealtimeConnectOptions.md new file mode 100644 index 0000000..91dc1e2 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeConnectOptions.md @@ -0,0 +1,101 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConnectOptions + +# Interface: RealtimeConnectOptions + +## Properties + +### closeTimeout? + +> `optional` **closeTimeout?**: `number` + +*** + +### conversationId + +> **conversationId**: `string` + +*** + +### handshakeTimeout? + +> `optional` **handshakeTimeout?**: `number` + +*** + +### headers? + +> `optional` **headers?**: [`HeadersLike`](../type-aliases/HeadersLike.md) + +*** + +### maxQueueBytes? + +> `optional` **maxQueueBytes?**: `number` + +*** + +### maxQueuedEvents? + +> `optional` **maxQueuedEvents?**: `number` + +*** + +### maxReceiveBytes? + +> `optional` **maxReceiveBytes?**: `number` + +*** + +### readyTimeout? + +> `optional` **readyTimeout?**: `number` + +*** + +### sendTimeout? + +> `optional` **sendTimeout?**: `number` + +*** + +### signal? + +> `optional` **signal?**: `AbortSignal` \| `null` + +Cancelling this signal releases the entire connection, including ready waits. + +*** + +### transport? + +> `optional` **transport?**: `object` + +Node agent (including proxy agents) and TLS settings; fetch/middleware do not apply to WS. + +#### agent? + +> `optional` **agent?**: `object` + +#### ca? + +> `optional` **ca?**: `string` \| `Uint8Array`\<`ArrayBufferLike`\> \| (`string` \| `Uint8Array`\<`ArrayBufferLike`\>)[] + +#### cert? + +> `optional` **cert?**: `string` \| `Uint8Array`\<`ArrayBufferLike`\> + +#### key? + +> `optional` **key?**: `string` \| `Uint8Array`\<`ArrayBufferLike`\> + +#### rejectUnauthorized? + +> `optional` **rejectUnauthorized?**: `boolean` + +#### servername? + +> `optional` **servername?**: `string` diff --git a/docs/api/index/interfaces/RealtimeConversation.md b/docs/api/index/interfaces/RealtimeConversation.md new file mode 100644 index 0000000..9e632a9 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeConversation.md @@ -0,0 +1,59 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConversation + +# Interface: RealtimeConversation + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### config + +> **config**: [`RealtimeConfig`](RealtimeConfig.md) + +*** + +### created\_at + +> **created\_at**: `string` + +*** + +### id + +> **id**: `string` + +*** + +### metadata + +> **metadata**: `Record`\<`string`, `unknown`\> + +*** + +### status + +> **status**: `"ready"` + +*** + +### title + +> **title**: `string` \| `null` + +*** + +### type + +> **type**: `"voice.conversation"` + +*** + +### updated\_at + +> **updated\_at**: `string` diff --git a/docs/api/index/interfaces/RealtimeConversationCreateParams.md b/docs/api/index/interfaces/RealtimeConversationCreateParams.md new file mode 100644 index 0000000..0bf35b2 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeConversationCreateParams.md @@ -0,0 +1,45 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeConversationCreateParams + +# Interface: RealtimeConversationCreateParams + +## Properties + +### config? + +> `optional` **config?**: [`RealtimeConfig`](RealtimeConfig.md) + +*** + +### idempotency\_key + +> **idempotency\_key**: `string` + +Required header, omitted from the JSON body. + +*** + +### identity\_id + +> **identity\_id**: `string` + +*** + +### metadata? + +> `optional` **metadata?**: `Record`\<`string`, `unknown`\> \| `null` + +*** + +### template\_id + +> **template\_id**: `string` + +*** + +### title? + +> `optional` **title?**: `string` \| `null` diff --git a/docs/api/index/interfaces/RealtimeErrorPayload.md b/docs/api/index/interfaces/RealtimeErrorPayload.md new file mode 100644 index 0000000..4cbb8b1 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeErrorPayload.md @@ -0,0 +1,35 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeErrorPayload + +# Interface: RealtimeErrorPayload + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### code + +> **code**: `string` + +*** + +### message + +> **message**: `string` + +*** + +### retry\_after\_ms? + +> `optional` **retry\_after\_ms?**: `number` + +*** + +### retryable? + +> `optional` **retryable?**: `boolean` diff --git a/docs/api/index/interfaces/RealtimeEventEnvelope.md b/docs/api/index/interfaces/RealtimeEventEnvelope.md new file mode 100644 index 0000000..d170491 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeEventEnvelope.md @@ -0,0 +1,53 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeEventEnvelope + +# Interface: RealtimeEventEnvelope + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### announcement\_id? + +> `optional` **announcement\_id?**: `string` + +*** + +### conversation\_id + +> **conversation\_id**: `string` + +*** + +### event\_id + +> **event\_id**: `string` + +*** + +### sequence + +> **sequence**: `number` + +*** + +### timestamp + +> **timestamp**: `string` + +*** + +### version + +> **version**: `"voice.realtime.v1"` + +*** + +### work\_id? + +> `optional` **work\_id?**: `string` diff --git a/docs/api/index/interfaces/RealtimeHistoryEvent.md b/docs/api/index/interfaces/RealtimeHistoryEvent.md new file mode 100644 index 0000000..6c7f945 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeHistoryEvent.md @@ -0,0 +1,103 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeHistoryEvent + +# Interface: RealtimeHistoryEvent + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### announcement\_id? + +> `optional` **announcement\_id?**: `string` + +*** + +### error? + +> `optional` **error?**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### code + +> **code**: `string` + +*** + +### id + +> **id**: `string` + +*** + +### milestone\_type? + +> `optional` **milestone\_type?**: `string` + +*** + +### objective? + +> `optional` **objective?**: `string` + +*** + +### occurred\_at + +> **occurred\_at**: `string` + +*** + +### result? + +> `optional` **result?**: `string` + +*** + +### role? + +> `optional` **role?**: `string` + +*** + +### status + +> **status**: `string` + +*** + +### text? + +> `optional` **text?**: `string` + +*** + +### turn\_id? + +> `optional` **turn\_id?**: `string` + +*** + +### type + +> **type**: [`RealtimeHistoryEventType`](../type-aliases/RealtimeHistoryEventType.md) + +*** + +### user\_message\_event\_id? + +> `optional` **user\_message\_event\_id?**: `string` + +*** + +### work\_id? + +> `optional` **work\_id?**: `string` diff --git a/docs/api/index/interfaces/RealtimeHistoryParams.md b/docs/api/index/interfaces/RealtimeHistoryParams.md new file mode 100644 index 0000000..1b0e45f --- /dev/null +++ b/docs/api/index/interfaces/RealtimeHistoryParams.md @@ -0,0 +1,25 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeHistoryParams + +# Interface: RealtimeHistoryParams + +## Properties + +### before? + +> `optional` **before?**: `string` + +*** + +### limit? + +> `optional` **limit?**: `number` + +*** + +### types? + +> `optional` **types?**: `"message"` \| `"work"` \| `"message,work"` diff --git a/docs/api/index/interfaces/RealtimeHistoryResponse.md b/docs/api/index/interfaces/RealtimeHistoryResponse.md new file mode 100644 index 0000000..b85423d --- /dev/null +++ b/docs/api/index/interfaces/RealtimeHistoryResponse.md @@ -0,0 +1,69 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeHistoryResponse + +# Interface: RealtimeHistoryResponse + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### conversation + +> **conversation**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### created\_at + +> **created\_at**: `string` + +#### id + +> **id**: `string` + +#### initialization\_status + +> **initialization\_status**: `string` + +#### metadata + +> **metadata**: `Record`\<`string`, `unknown`\> + +#### title + +> **title**: `string` \| `null` + +#### updated\_at + +> **updated\_at**: `string` + +*** + +### events + +> **events**: [`RealtimeHistoryEvent`](RealtimeHistoryEvent.md)[] + +*** + +### page + +> **page**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### has\_more + +> **has\_more**: `boolean` + +#### next\_before + +> **next\_before**: `string` \| `null` diff --git a/docs/api/index/interfaces/RealtimePlaybackReceipt.md b/docs/api/index/interfaces/RealtimePlaybackReceipt.md new file mode 100644 index 0000000..08169a5 --- /dev/null +++ b/docs/api/index/interfaces/RealtimePlaybackReceipt.md @@ -0,0 +1,19 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimePlaybackReceipt + +# Interface: RealtimePlaybackReceipt + +## Properties + +### announcement\_id? + +> `optional` **announcement\_id?**: `string` + +*** + +### work\_id? + +> `optional` **work\_id?**: `string` diff --git a/docs/api/index/interfaces/RealtimeReady.md b/docs/api/index/interfaces/RealtimeReady.md new file mode 100644 index 0000000..baa65c5 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeReady.md @@ -0,0 +1,49 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeReady + +# Interface: RealtimeReady + +## Indexable + +> \[`key`: `string`\]: `unknown` + +## Properties + +### capabilities + +> **capabilities**: `object` + +#### Index Signature + +\[`key`: `string`\]: `unknown` + +#### graceful\_close + +> **graceful\_close**: `boolean` + +*** + +### config + +> **config**: [`RealtimeConfig`](RealtimeConfig.md) + +*** + +### input\_audio + +> **input\_audio**: [`RealtimeAudioFormat`](RealtimeAudioFormat.md) + +*** + +### output\_audio + +> **output\_audio**: [`RealtimeAudioFormat`](RealtimeAudioFormat.md) + +*** + +### provider + +> **provider**: `string` diff --git a/docs/api/index/interfaces/RealtimeServerPayloads.md b/docs/api/index/interfaces/RealtimeServerPayloads.md new file mode 100644 index 0000000..f811605 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeServerPayloads.md @@ -0,0 +1,233 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeServerPayloads + +# Interface: RealtimeServerPayloads + +## Properties + +### audio.delta + +> **audio.delta**: `object` + +#### audio + +> **audio**: `string` + +*** + +### audio.done + +> **audio.done**: `Record`\<`string`, `never`\> + +*** + +### connection.closed + +> **connection.closed**: [`RealtimeClosed`](RealtimeClosed.md) + +*** + +### error + +> **error**: [`RealtimeErrorPayload`](RealtimeErrorPayload.md) + +*** + +### playback.interrupt + +> **playback.interrupt**: `object` + +#### reason + +> **reason**: `string` + +*** + +### pong + +> **pong**: `Record`\<`string`, `never`\> + +*** + +### transcript.delta + +> **transcript.delta**: `object` + +#### item\_id? + +> `optional` **item\_id?**: `string` + +#### response\_id? + +> `optional` **response\_id?**: `string` + +#### role + +> **role**: `string` + +#### text + +> **text**: `string` + +*** + +### transcript.final + +> **transcript.final**: `object` + +#### item\_id? + +> `optional` **item\_id?**: `string` + +#### response\_id? + +> `optional` **response\_id?**: `string` + +#### role + +> **role**: `string` + +#### text + +> **text**: `string` + +*** + +### voice.ready + +> **voice.ready**: [`RealtimeReady`](RealtimeReady.md) + +*** + +### voice.replaced + +> **voice.replaced**: `object` + +#### reason + +> **reason**: `string` + +*** + +### voice.state + +> **voice.state**: `object` + +#### state + +> **state**: `string` + +*** + +### work.accepted + +> **work.accepted**: `object` + +#### objective + +> **objective**: `string` + +#### status + +> **status**: `"accepted"` + +*** + +### work.completed + +> **work.completed**: `object` + +#### event\_id? + +> `optional` **event\_id?**: `string` + +#### result + +> **result**: `string` + +#### status + +> **status**: `"completed"` + +*** + +### work.failed + +> **work.failed**: `object` + +#### error + +> **error**: `object` + +##### Index Signature + +\[`key`: `string`\]: `unknown` + +##### error.code + +> **code**: `string` + +#### event\_id? + +> `optional` **event\_id?**: `string` + +#### status + +> **status**: `"failed"` + +*** + +### work.milestone + +> **work.milestone**: `object` + +#### announcement\_id + +> **announcement\_id**: `string` + +#### milestone\_type + +> **milestone\_type**: `string` + +#### summary + +> **summary**: `string` + +*** + +### work.progress + +> **work.progress**: `object` + +#### detail + +> **detail**: `string` + +#### event\_id? + +> `optional` **event\_id?**: `string` + +#### is\_error? + +> `optional` **is\_error?**: `boolean` + +#### kind + +> **kind**: `string` + +#### title? + +> `optional` **title?**: `string` + +*** + +### work.running + +> **work.running**: `object` + +#### status + +> **status**: `"running"` diff --git a/docs/api/index/interfaces/RealtimeWaitOptions.md b/docs/api/index/interfaces/RealtimeWaitOptions.md new file mode 100644 index 0000000..ed6d6b3 --- /dev/null +++ b/docs/api/index/interfaces/RealtimeWaitOptions.md @@ -0,0 +1,23 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeWaitOptions + +# Interface: RealtimeWaitOptions + +## Extended by + +- [`RealtimeCloseOptions`](RealtimeCloseOptions.md) + +## Properties + +### signal? + +> `optional` **signal?**: `AbortSignal` \| `null` + +*** + +### timeout? + +> `optional` **timeout?**: `number` diff --git a/docs/api/index/interfaces/ServiceAccountCredentialOptions.md b/docs/api/index/interfaces/ServiceAccountCredentialOptions.md index 4d03f88..fc46e77 100644 --- a/docs/api/index/interfaces/ServiceAccountCredentialOptions.md +++ b/docs/api/index/interfaces/ServiceAccountCredentialOptions.md @@ -10,21 +10,45 @@ ### fetch? -> `optional` **fetch?**: (`input`, `init?`) => `Promise`\<`Response`\> +> `optional` **fetch?**: \{(`input`, `init?`): `Promise`\<`Response`\>; (`input`, `init?`): `Promise`\<`Response`\>; \} + +#### Call Signature + +> (`input`, `init?`): `Promise`\<`Response`\> + +[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) + +##### Parameters + +###### input + +`URL` \| `RequestInfo` + +###### init? + +`RequestInit` + +##### Returns + +`Promise`\<`Response`\> + +#### Call Signature + +> (`input`, `init?`): `Promise`\<`Response`\> [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) -#### Parameters +##### Parameters -##### input +###### input -`RequestInfo` \| `URL` +`string` \| `Request` \| `URL` -##### init? +###### init? `RequestInit` -#### Returns +##### Returns `Promise`\<`Response`\> diff --git a/docs/api/index/type-aliases/RealtimeCloseOutcome.md b/docs/api/index/type-aliases/RealtimeCloseOutcome.md new file mode 100644 index 0000000..f5ec2fd --- /dev/null +++ b/docs/api/index/type-aliases/RealtimeCloseOutcome.md @@ -0,0 +1,9 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeCloseOutcome + +# Type Alias: RealtimeCloseOutcome + +> **RealtimeCloseOutcome** = `"no_active_response"` \| `"no_content"` \| `"saved"` \| `"saved_interrupted"` diff --git a/docs/api/index/type-aliases/RealtimeHistoryEventType.md b/docs/api/index/type-aliases/RealtimeHistoryEventType.md new file mode 100644 index 0000000..6727ad5 --- /dev/null +++ b/docs/api/index/type-aliases/RealtimeHistoryEventType.md @@ -0,0 +1,9 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeHistoryEventType + +# Type Alias: RealtimeHistoryEventType + +> **RealtimeHistoryEventType** = `"voice.user_message.completed"` \| `"voice.assistant_message.completed"` \| `"voice.assistant_message.interrupted"` \| `"voice.work.accepted"` \| `"voice.work.running"` \| `"voice.work.completed"` \| `"voice.work.failed"` \| `"voice.work.final_announcement"` \| `string` & `object` diff --git a/docs/api/index/type-aliases/RealtimeKnownEvent.md b/docs/api/index/type-aliases/RealtimeKnownEvent.md new file mode 100644 index 0000000..1be2716 --- /dev/null +++ b/docs/api/index/type-aliases/RealtimeKnownEvent.md @@ -0,0 +1,9 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeKnownEvent + +# Type Alias: RealtimeKnownEvent + +> **RealtimeKnownEvent** = `{ [K in keyof RealtimeServerPayloads]: RealtimeEventEnvelope & { payload: RealtimeServerPayloads[K] & Record; type: K } }`\[keyof [`RealtimeServerPayloads`](../interfaces/RealtimeServerPayloads.md)\] diff --git a/docs/api/index/type-aliases/RealtimeServerEvent.md b/docs/api/index/type-aliases/RealtimeServerEvent.md new file mode 100644 index 0000000..ce41b5f --- /dev/null +++ b/docs/api/index/type-aliases/RealtimeServerEvent.md @@ -0,0 +1,11 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeServerEvent + +# Type Alias: RealtimeServerEvent + +> **RealtimeServerEvent** = [`RealtimeKnownEvent`](RealtimeKnownEvent.md) \| [`RealtimeEventEnvelope`](../interfaces/RealtimeEventEnvelope.md) & `object` + +Unknown Beta events retain their envelope and complete payload. diff --git a/docs/api/index/type-aliases/RealtimeVoice.md b/docs/api/index/type-aliases/RealtimeVoice.md new file mode 100644 index 0000000..d363161 --- /dev/null +++ b/docs/api/index/type-aliases/RealtimeVoice.md @@ -0,0 +1,9 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / RealtimeVoice + +# Type Alias: RealtimeVoice + +> **RealtimeVoice** = `"longanqian"` \| `"longanlingxin"` \| `"longanlingxi"` \| `"longanxiaoxin"` \| `"longanlufeng"` diff --git a/docs/api/index/variables/REALTIME_VERSION.md b/docs/api/index/variables/REALTIME_VERSION.md new file mode 100644 index 0000000..06c68ee --- /dev/null +++ b/docs/api/index/variables/REALTIME_VERSION.md @@ -0,0 +1,11 @@ +[**qca-sdk**](../../README.md) + +*** + +[qca-sdk](../../README.md) / [index](../README.md) / REALTIME\_VERSION + +# Variable: REALTIME\_VERSION + +> `const` **REALTIME\_VERSION**: `"voice.realtime.v1"` + +Forward Realtime Beta protocol. Timestamp strings preserve nanosecond precision. diff --git a/examples/forward/realtime/pcm.mjs b/examples/forward/realtime/pcm.mjs new file mode 100644 index 0000000..c8546a1 --- /dev/null +++ b/examples/forward/realtime/pcm.mjs @@ -0,0 +1,59 @@ +// Input must be raw 16 kHz mono signed PCM16 little-endian, without a WAV header. +// Output is saved as raw 24 kHz PCM16; this example does not play it or claim playback ended. +import { open } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ForwardClient, decodeRealtimeAudio } from '../../../dist/forward/index.js'; +import { clientOptions, formatExampleError } from '../../lib/auth.mjs'; + +if (process.argv.includes('--help')) { + console.log('node examples/forward/realtime/pcm.mjs input.pcm output.pcm. Set QODER_REALTIME_IDENTITY_ID, QODER_REALTIME_TEMPLATE_ID and a PAT or QODER_AUTH_MODE=sa with a Forward-issued bound SAT in QODER_ACCESS_TOKEN. Platform SA Key exchange does not create the required Token binding. Input: 16kHz mono PCM16LE; output: 24kHz mono PCM16LE. Conversation resources remain persisted.'); +} else { + main().catch(error => { console.error(formatExampleError(error)); process.exitCode = 1; }); +} +async function main() { + const [, , inputPath, outputPath] = process.argv; + if (!inputPath || !outputPath || inputPath === outputPath) throw new Error('Specify separate input.pcm and output.pcm files'); + const identity_id = process.env.QODER_REALTIME_IDENTITY_ID; + const template_id = process.env.QODER_REALTIME_TEMPLATE_ID; + if (!identity_id || !template_id) throw new Error('Provide an existing Identity and Realtime-enabled Template'); + const input = await open(inputPath, 'r'); + let output; + let connection; + try { + const size = (await input.stat()).size; + if (!size || size % 2 || size > 16_000 * 2 * 60) throw new Error('Use nonempty complete PCM16 samples, at most 60 seconds'); + const header = Buffer.alloc(12); await input.read(header, 0, 12, 0); + if (header.toString('ascii', 0, 4) === 'RIFF' && header.toString('ascii', 8, 12) === 'WAVE') throw new Error('Decode WAV into raw PCM first'); + output = await open(outputPath, 'wx'); + const options = clientOptions('forward'); + if (options.credential) throw new Error('Realtime requires a Forward-issued bound SAT in QODER_ACCESS_TOKEN; unset QODER_SA_KEY'); + const client = new ForwardClient(options); + const conversation = await client.realtime.conversations.create({ identity_id, template_id, idempotency_key: randomUUID(), metadata: { source: 'sdk-realtime-pcm-example' } }); + console.log(`conversation_id=${conversation.id}`); + connection = await client.realtime.connect({ conversationId: conversation.id, signal: AbortSignal.timeout(180_000) }); + const receiving = (async () => { + let receipt = {}; + for (;;) { + const { value: event, done } = await connection.receive({ timeout: 120_000 }); + if (done) throw new Error('Connection closed before output audio ended'); + if (event.type === 'error') throw new Error('Realtime returned an error event'); + if (event.type === 'audio.delta') { + receipt = { ...(event.work_id ? { work_id: event.work_id } : {}), ...(event.announcement_id ? { announcement_id: event.announcement_id } : {}) }; + await output.write(decodeRealtimeAudio(event)); + } + if (event.type === 'audio.done') { await connection.playbackCancelled(receipt); return; } + } + })(); + void receiving.catch(() => {}); + const chunk = Buffer.alloc(640); // 20 ms; keep capture pacing rather than uploading in a burst. + for (let position = 0; position < size;) { + const { bytesRead } = await input.read(chunk, 0, chunk.length, position); + if (!bytesRead) break; + await connection.appendAudio(chunk.subarray(0, bytesRead)); position += bytesRead; await delay(20); + } + // VAD detects the end of speech; the protocol has no audio.commit event. + await receiving; + console.log(`close_outcome=${(await connection.closeGracefully()).outcome}`); + } finally { connection?.close(); await input.close(); await output?.close(); } +} diff --git a/examples/forward/realtime/text.mjs b/examples/forward/realtime/text.mjs new file mode 100644 index 0000000..f7e830e --- /dev/null +++ b/examples/forward/realtime/text.mjs @@ -0,0 +1,34 @@ +// Node-only Forward Realtime Beta. Use an existing enabled Identity and Realtime-enabled Template. +import { randomUUID } from 'node:crypto'; +import { ForwardClient } from '../../../dist/forward/index.js'; +import { clientOptions, formatExampleError } from '../../lib/auth.mjs'; + +if (process.argv.includes('--help')) { + console.log('Set QODER_REALTIME_IDENTITY_ID and QODER_REALTIME_TEMPLATE_ID, plus a PAT or QODER_AUTH_MODE=sa and QODER_ACCESS_TOKEN containing a Forward-issued bound SAT. Platform SA Key exchange does not create the required Token binding. Run node examples/forward/realtime/text.mjs [message]. Conversation resources remain persisted; transport close is not deletion.'); +} else { + main().catch(error => { console.error(formatExampleError(error)); process.exitCode = 1; }); +} +async function main() { + const identity_id = process.env.QODER_REALTIME_IDENTITY_ID; + const template_id = process.env.QODER_REALTIME_TEMPLATE_ID; + if (!identity_id || !template_id) throw new Error('Provide an existing Identity and Realtime-enabled Template'); + const options = clientOptions('forward'); + if (options.credential) throw new Error('Realtime requires a Forward-issued bound SAT in QODER_ACCESS_TOKEN; unset QODER_SA_KEY'); + const client = new ForwardClient(options); + const conversation = await client.realtime.conversations.create({ identity_id, template_id, idempotency_key: randomUUID(), metadata: { source: 'sdk-realtime-text-example' } }); + console.log(`conversation_id=${conversation.id} request_id=${conversation._request_id ?? ''}`); + const connection = await client.realtime.connect({ conversationId: conversation.id, signal: AbortSignal.timeout(180_000) }); + try { + await connection.sendText(process.argv[2] || '请简短介绍你能提供的帮助。'); + for (;;) { + const { value: event, done } = await connection.receive({ timeout: 120_000 }); + if (done) throw new Error('Connection closed before an assistant response'); + if (event.type === 'error') throw new Error('Realtime returned an error event'); + if (event.type === 'transcript.final' && event.payload.role === 'assistant') { console.log(event.payload.text); break; } + } + console.log(`close_outcome=${(await connection.closeGracefully()).outcome}`); + } finally { connection.close(); } + // History has its own before cursor. No WS event_id/sequence is used as a cursor. + const history = await client.realtime.conversations.history(conversation.id, { limit: 10, types: 'message' }); + for await (const event of history) console.log(`${event.id} ${event.type}`); +} diff --git a/package-lock.json b/package-lock.json index 606109a..39333d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "qca-sdk", "version": "0.4.0", "license": "MIT", + "dependencies": { + "ws": "^8.22.0" + }, "devDependencies": { + "@types/ws": "^8.18.2", "semver": "7.7.3", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.0", @@ -91,6 +95,16 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -98,6 +112,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -300,6 +324,34 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/package.json b/package.json index ae848e8..1f1eb1b 100644 --- a/package.json +++ b/package.json @@ -81,10 +81,12 @@ "docs:check": "node scripts/docs-check.mjs", "test:live:sa": "npm run build && node scripts/run-sa-live.mjs readonly", "test:live:sa:execution": "npm run build && node scripts/run-sa-live.mjs execution", - "test:live:sa:tokens": "npm run build && node scripts/run-sa-live.mjs tokens", - "test:live:sa:check": "node --check tests/sa/auth-live.mjs && node --check tests/sa/execution-live.mjs" + "test:live:sa:check": "node --check tests/sa/auth-live.mjs && node --check tests/sa/execution-live.mjs && node --check tests/realtime/live.mjs && node --check scripts/run-realtime-live.mjs", + "test:live:realtime": "npm run build && node scripts/run-realtime-live.mjs", + "test:live:sa:tokens": "npm run build && node scripts/run-sa-live.mjs tokens" }, "devDependencies": { + "@types/ws": "^8.18.2", "semver": "7.7.3", "typedoc": "^0.28.20", "typedoc-plugin-markdown": "^4.13.0", @@ -93,5 +95,8 @@ "type": "commonjs", "engines": { "node": ">=20.12.0" + }, + "dependencies": { + "ws": "^8.22.0" } } diff --git a/scripts/run-realtime-live.mjs b/scripts/run-realtime-live.mjs new file mode 100644 index 0000000..2a84f01 --- /dev/null +++ b/scripts/run-realtime-live.mjs @@ -0,0 +1,30 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +export function validateRealtimeEnvironment(environment, authMode, scenario) { + if (!['all', 'pat', 'sa'].includes(authMode)) throw new Error('Realtime auth mode must be all, pat or sa'); + if (!['basic', 'text', 'pcm', 'work'].includes(scenario)) throw new Error('Realtime scenario must be basic, text, pcm or work'); + for (const flag of ['QODER_REALTIME_LIVE_ALLOW_WRITE', 'QODER_REALTIME_LIVE_ALLOW_EXECUTION']) if (environment[flag] !== 'true') throw new Error(`${flag}=true is required for Realtime resource creation and provider initialization`); + for (const selectedMode of authMode === 'all' ? ['pat', 'sa'] : [authMode]) for (const region of ['CN', 'GLOBAL']) { + const templateName = `QODER_REALTIME_${region}_${selectedMode.toUpperCase()}_TEMPLATE_ID`; + if (!environment[templateName]?.trim()) throw new Error(`${templateName} is required; use an existing Realtime-enabled Template`); + if (selectedMode === 'sa') { + if (!environment[`QODER_REALTIME_${region}_SAT`]?.trim() && !environment[`QODER_SA_${region}_KEY`]?.trim()) throw new Error(`QODER_REALTIME_${region}_SAT or QODER_SA_${region}_KEY is required`); + } else if (!environment[`QODER_FORWARD_${region}_PAT`]?.trim()) throw new Error(`QODER_FORWARD_${region}_PAT is required`); + } + if (scenario === 'pcm' && !environment.QODER_REALTIME_PCM_FILE?.trim()) throw new Error('QODER_REALTIME_PCM_FILE is required (16 kHz mono raw PCM16LE)'); + if (scenario === 'work' && !environment.QODER_REALTIME_WORK_PROMPT?.trim()) throw new Error('QODER_REALTIME_WORK_PROMPT is required; use a confirmed task trigger'); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const authMode = process.argv[2] ?? 'all'; + const scenario = process.argv[3] ?? 'basic'; + const envFile = resolve(process.env.LIVE_ENV_FILE ?? '.env.live'); + if (existsSync(envFile)) process.loadEnvFile(envFile); + validateRealtimeEnvironment(process.env, authMode, scenario); // Validate both regions before the first API call. + const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1', 'tests/realtime/live.mjs'], { stdio: 'inherit', env: { ...process.env, QCA_REALTIME_TEST_AUTH_MODE: authMode, QCA_REALTIME_TEST_SCENARIO: scenario } }); + process.exitCode = result.status ?? 1; + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/src/core/client.ts b/src/core/client.ts index 778ebea..bc78807 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -151,6 +151,27 @@ export class APIClient { ); } + private async authenticateHeaders(headers: Headers, signal: AbortSignal): Promise { + if (!headers.has('authorization')) { + const token = await withSignal(resolveToken(this.options, this.authMode), signal, () => false); + if (token) headers.set('Authorization', `Bearer ${token}`); + } + this.validateHeaders(headers); + } + + /** @internal Resolve the same public credentials for the separate Node WS transport. */ + async _prepareRealtimeRequest(options: { conversationId: string; headers?: HeadersLike; signal?: AbortSignal | null }): Promise<{ url: URL; headers: Headers }> { + const url = new URL(this.baseURL); + url.pathname = `${url.pathname.replace(/\/+$/, '')}/realtime`; + url.search = ''; + url.hash = ''; + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('conversation_id', options.conversationId); + const headers = mergeHeaders({ Accept: 'application/json', 'User-Agent': `qca-js/${VERSION}`, ...platformHeaders() }, this.defaultHeaders, options.headers); + await this.authenticateHeaders(headers, options.signal ?? new AbortController().signal); + return { url, headers }; + } + request(options: APIRequestOptions): APIPromise { return new APIPromise(this.execute(options), async (response) => { if (options.responseType === 'response' || options.responseType === 'binary') return response as T; @@ -262,11 +283,7 @@ export class APIClient { const attemptHeaders = new Headers(headers); if (!storage) { attemptHeaders.set('X-Qoder-Retry-Count', String(attempt)); - if (!attemptHeaders.has('authorization')) { - const token = await withSignal(resolveToken(this.options, this.authMode), signal, () => timedOut); - if (token) attemptHeaders.set('Authorization', `Bearer ${token}`); - } - this.validateHeaders(attemptHeaders); + await this.authenticateHeaders(attemptHeaders, signal); } let response: Response; try { diff --git a/src/forward/api-inventory.json b/src/forward/api-inventory.json index 38806fc..92a3ac5 100644 --- a/src/forward/api-inventory.json +++ b/src/forward/api-inventory.json @@ -4721,5 +4721,39 @@ "required": false } ] + }, + { + "operationId": "createRealtimeConversation", + "entry": "realtime.conversations", + "method": "create", + "httpMethod": "POST", + "parameters": [], + "paramsType": "RealtimeConversationCreateParams", + "paramsOptional": false, + "paramsLocation": "json", + "responseType": "json", + "itemType": "RealtimeConversation", + "path": "/realtime/conversations" + }, + { + "operationId": "getRealtimeConversationHistory", + "entry": "realtime.conversations", + "method": "history", + "httpMethod": "GET", + "parameters": [ + { + "name": "conversationId", + "wireName": "conversation_id", + "location": "path", + "required": true, + "type": "string" + } + ], + "paramsType": "RealtimeHistoryParams", + "paramsOptional": true, + "paramsLocation": "query", + "responseType": "history", + "itemType": "RealtimeHistoryPage", + "path": "/realtime/conversations/{conversation_id}/history" } ] diff --git a/src/forward/forwardClient.ts b/src/forward/forwardClient.ts index 1a604a9..1564cef 100644 --- a/src/forward/forwardClient.ts +++ b/src/forward/forwardClient.ts @@ -15,6 +15,8 @@ import { Vaults } from './vault.js'; import { MemoryStores } from './memory-store.js'; import { Models } from './model.js'; import { Usage } from './usage.js'; +import { Realtime } from './realtime/realtime.js'; + import { ServiceAccountTokens } from './service-account-token.js'; export type ForwardClientOptions = ClientOptions; @@ -35,6 +37,8 @@ export class ForwardClient extends APIClient { readonly memoryStores: MemoryStores = new MemoryStores(this); readonly models: Models = new Models(this); readonly usage: Usage = new Usage(this); + readonly realtime: Realtime = new Realtime(this); + readonly serviceAccountTokens: ServiceAccountTokens = new ServiceAccountTokens(this); constructor(options: ForwardClientOptions = {}) { diff --git a/src/forward/index.ts b/src/forward/index.ts index e55cb9d..14ec406 100644 --- a/src/forward/index.ts +++ b/src/forward/index.ts @@ -39,5 +39,12 @@ export type { AuthMode, ServiceAccountCredentialOptions } from '../core/credenti export type { Credential } from '../core/credentials.js'; export * from '../core/error.js'; export { Usage } from './usage.js'; + +export { Realtime } from './realtime/realtime.js'; +export { RealtimeConversations, RealtimeHistoryPage } from './realtime/conversation.js'; +export { RealtimeConnection, RealtimeProtocolError, RealtimeConnectionClosedError, RealtimeServerError, decodeRealtimeAudio } from './realtime/connection.js'; +export type { RealtimeConnectOptions, RealtimeWaitOptions, RealtimeCloseOptions } from './realtime/connection.js'; +export * from './realtime/types.js'; + export { ServiceAccountTokens } from './service-account-token.js'; export { IdentityServiceAccountTokens } from './identity-service-account-token.js'; diff --git a/src/forward/realtime/connection.ts b/src/forward/realtime/connection.ts new file mode 100644 index 0000000..629c67a --- /dev/null +++ b/src/forward/realtime/connection.ts @@ -0,0 +1,349 @@ +import type WebSocket from 'ws'; +import type { ClientOptions as WSOptions, RawData } from 'ws'; +import type { APIClient, HeadersLike } from '../../core/client.js'; +import { APIConnectionError, APIConnectionTimeoutError, APIError, APIUserAbortError, QoderError } from '../../core/error.js'; +import { REALTIME_VERSION, type RealtimeClientEvent, type RealtimeClosed, type RealtimePlaybackReceipt, type RealtimeReady, type RealtimeServerEvent } from './types.js'; + +export interface RealtimeConnectOptions { + conversationId: string; + headers?: HeadersLike; + /** Cancelling this signal releases the entire connection, including ready waits. */ + signal?: AbortSignal | null; + handshakeTimeout?: number; + readyTimeout?: number; + sendTimeout?: number; + closeTimeout?: number; + maxQueuedEvents?: number; + maxQueueBytes?: number; + maxReceiveBytes?: number; + /** Node agent (including proxy agents) and TLS settings; fetch/middleware do not apply to WS. */ + transport?: { agent?: object; ca?: string | Uint8Array | Array; cert?: string | Uint8Array; key?: string | Uint8Array; servername?: string; rejectUnauthorized?: boolean }; +} +export interface RealtimeWaitOptions { signal?: AbortSignal | null; timeout?: number } +export interface RealtimeCloseOptions extends RealtimeWaitOptions { requestId?: string } +export class RealtimeProtocolError extends APIConnectionError { + constructor(message: string, readonly conversationId: string, readonly code = 'invalid_event') { super(message); } +} +export class RealtimeConnectionClosedError extends APIConnectionError { + constructor(readonly conversationId: string, readonly closeCode: number, readonly closeReason: string) { super(`Realtime connection closed (${closeCode})`); } +} +export class RealtimeServerError extends APIConnectionError { + readonly code: string; + readonly retryable?: boolean; + readonly retry_after_ms?: number; + constructor(readonly event: RealtimeServerEvent) { + const payload = event.payload as Record; + super(typeof payload.message === 'string' ? payload.message : 'Realtime server error'); + this.code = String(payload.code); + this.retryable = typeof payload.retryable === 'boolean' ? payload.retryable : undefined; + this.retry_after_ms = typeof payload.retry_after_ms === 'number' ? payload.retry_after_ms : undefined; + } +} +const importWS = () => import('ws'); +const CLIENT_MESSAGE_BYTES = 256 * 1024; +const terminalErrors = new Set(['provider_initialization_failed', 'context_restore_failed', 'provider_unavailable', 'service_restarting', 'event_persistence_failed']); +const base64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +function validTimestamp(value: unknown): value is string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value))) return false; + const year = Number(value.slice(0, 4)), month = Number(value.slice(5, 7)), day = Number(value.slice(8, 10)); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return day > 0 && day <= [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; +} +const isObject = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); +function positive(value: number | undefined, fallback: number, name: string): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || result <= 0 || result > 2 ** 31 - 1) throw new QoderError(`${name} must be a positive integer within the timer/size limit`); + return result; +} +function wait(value: Promise, timeout: number, signal: AbortSignal | null | undefined, label: string): Promise { + return new Promise((resolve, reject) => { + const abort = () => finish(new APIUserAbortError(`${label} was aborted`)); + const timer = setTimeout(() => finish(new APIConnectionTimeoutError(`${label} timed out`)), timeout); + let settled = false; + function finish(error?: Error, result?: T) { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + if (error) reject(error); else resolve(result as T); + } + if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true }); + value.then(result => finish(undefined, result), error => finish(error instanceof Error ? error : new APIConnectionError(label))); + }); +} +function validateEvent(value: unknown, conversationId: string): RealtimeServerEvent { + const invalid = () => new RealtimeProtocolError('Invalid Realtime event envelope or payload', conversationId); + if (!isObject(value) || value.version !== REALTIME_VERSION || typeof value.type !== 'string' || !value.type + || !isObject(value.payload) || typeof value.event_id !== 'string' || !value.event_id + || !Number.isSafeInteger(value.sequence) || (value.sequence as number) < 0 || value.conversation_id !== conversationId + || !validTimestamp(value.timestamp) + || (value.work_id !== undefined && typeof value.work_id !== 'string') + || (value.announcement_id !== undefined && typeof value.announcement_id !== 'string')) throw invalid(); + const p = value.payload; + const required: Record = { + 'voice.state': ['state'], 'voice.replaced': ['reason'], 'transcript.delta': ['role', 'text'], 'transcript.final': ['role', 'text'], + 'audio.delta': ['audio'], 'playback.interrupt': ['reason'], 'work.accepted': ['status', 'objective'], 'work.running': ['status'], + 'work.progress': ['kind', 'detail'], 'work.milestone': ['announcement_id', 'milestone_type', 'summary'], + 'work.completed': ['status', 'result'], 'work.failed': ['status'], 'connection.closed': ['request_id', 'outcome'], error: ['code', 'message'], + }; + if ((required[value.type] ?? []).some(key => typeof p[key] !== 'string')) throw invalid(); + const optionalStrings: Record = { + 'transcript.delta': ['item_id', 'response_id'], 'transcript.final': ['item_id', 'response_id'], + 'work.progress': ['title', 'event_id'], 'work.completed': ['event_id'], 'work.failed': ['event_id'], + }; + if ((optionalStrings[value.type] ?? []).some(key => p[key] !== undefined && typeof p[key] !== 'string')) throw invalid(); + if (value.type === 'work.progress' && p.is_error !== undefined && typeof p.is_error !== 'boolean') throw invalid(); + if (value.type === 'error' && ((p.retryable !== undefined && typeof p.retryable !== 'boolean') || (p.retry_after_ms !== undefined && (!Number.isSafeInteger(p.retry_after_ms) || (p.retry_after_ms as number) < 0)))) throw invalid(); + const statuses: Record = { 'work.accepted': 'accepted', 'work.running': 'running', 'work.completed': 'completed', 'work.failed': 'failed' }; + if (statuses[value.type] && p.status !== statuses[value.type]) throw invalid(); + if (value.type === 'audio.delta' && (typeof p.audio !== 'string' || !p.audio || !base64.test(p.audio) || globalThis.atob(p.audio).length % 2)) throw invalid(); + if (value.type === 'voice.ready') { + if (!isObject(p.config) || typeof p.provider !== 'string' || !isObject(p.input_audio) || !isObject(p.output_audio) + || p.input_audio.format !== 'pcm16' || p.input_audio.sample_rate !== 16000 || p.input_audio.channels !== 1 + || p.output_audio.format !== 'pcm16' || p.output_audio.sample_rate !== 24000 || p.output_audio.channels !== 1 + || !isObject(p.capabilities) || typeof p.capabilities.graceful_close !== 'boolean') throw invalid(); + } + if (value.type === 'work.failed' && (!isObject(p.error) || typeof p.error.code !== 'string')) throw invalid(); + if (value.type === 'connection.closed' && !['no_active_response', 'no_content', 'saved', 'saved_interrupted'].includes(p.outcome as string)) throw invalid(); + return value as unknown as RealtimeServerEvent; +} +/** Decode an audio.delta payload into raw 24 kHz PCM16 bytes; this does not play it. */ +export function decodeRealtimeAudio(event: RealtimeServerEvent): Uint8Array { + if (event.type !== 'audio.delta' || typeof event.payload.audio !== 'string' || !event.payload.audio || !base64.test(event.payload.audio)) throw new QoderError('Expected a valid audio.delta Base64 payload'); + const binary = globalThis.atob(event.payload.audio); + if (binary.length % 2) throw new QoderError('PCM16 output must contain complete samples'); + return Uint8Array.from(binary, char => char.charCodeAt(0)); +} + +/** A Node-only connection with one receiver and bounded input/output queues. No reconnect or replay. */ +export class RealtimeConnection implements AsyncIterable { + readonly conversationId: string; + ready!: RealtimeReady; + request_id: string | null = null; + private readonly queue: Array<{ event: RealtimeServerEvent; bytes: number }> = []; + private queueBytes = 0; + private lastSequence = -1; + private readonly maxQueuedEvents: number; + private readonly maxQueueBytes: number; + private readonly sendTimeout: number; + private readonly closeTimeout: number; + private readonly readyPromise: Promise; + private readyResolve!: () => void; + private readyReject!: (error: Error) => void; + private receiver?: { resolve: (result: IteratorResult) => void; reject: (error: Error) => void }; + private terminal?: Error; + private ended = false; + private closing = false; + private sendTail: Promise = Promise.resolve(); + private sendCount = 0; + private sendBytes = 0; + private graceful?: Promise; + private closeAck?: { requestId: string; resolve: (value: RealtimeClosed) => void; reject: (error: Error) => void }; + private handshakeTimer?: ReturnType; + private readyTimer?: ReturnType; + private readonly abort: () => void; + + private constructor(private readonly socket: WebSocket, private readonly options: RealtimeConnectOptions) { + this.conversationId = options.conversationId; + this.maxQueuedEvents = positive(options.maxQueuedEvents, 256, 'maxQueuedEvents'); + this.maxQueueBytes = positive(options.maxQueueBytes, 4 * 1024 * 1024, 'maxQueueBytes'); + this.sendTimeout = positive(options.sendTimeout, 30000, 'sendTimeout'); + this.closeTimeout = positive(options.closeTimeout, 10000, 'closeTimeout'); + this.readyPromise = new Promise((resolve, reject) => { this.readyResolve = resolve; this.readyReject = reject; }); + void this.readyPromise.catch(() => {}); + this.abort = () => this.fail(new APIUserAbortError('Realtime connection was aborted')); + options.signal?.addEventListener('abort', this.abort, { once: true }); + socket.on('upgrade', response => { this.request_id = String(response.headers['x-request-id'] ?? response.headers['request-id'] ?? '') || null; }); + socket.on('open', () => { + clearTimeout(this.handshakeTimer); + this.readyTimer = setTimeout(() => this.fail(new APIConnectionTimeoutError('Realtime ready timed out')), positive(options.readyTimeout, 60000, 'readyTimeout')); + }); + socket.on('message', (data: RawData, binary: boolean) => this.onMessage(data, binary)); + socket.on('error', error => this.fail(error.message === 'Opening handshake has timed out' + ? new APIConnectionTimeoutError('Realtime handshake timed out') : new APIConnectionError('Realtime WebSocket transport failed'))); + socket.on('close', (code, reason) => this.finish(code === 1000 || code === 1001 ? undefined : new RealtimeConnectionClosedError(this.conversationId, code, reason.toString()))); + socket.on('unexpected-response', (_request, response) => { + let raw = ''; + let bytes = 0; + response.on('data', chunk => { + bytes += chunk.length; + if (bytes > 64 * 1024) { response.destroy(); this.fail(new APIConnectionError('Realtime handshake error body exceeded its limit')); } + else raw += chunk.toString(); + }); + response.on('error', () => this.fail(new APIConnectionError('Realtime handshake response failed'))); + response.on('end', () => { + let body: unknown = raw; + try { body = JSON.parse(raw); } catch { /* Retain bounded gateway text. */ } + const headers = new Headers(); + for (const [key, value] of Object.entries(response.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(', ') : value); + this.fail(APIError.generate(response.statusCode ?? 502, body, undefined, headers)); + }); + }); + this.handshakeTimer = setTimeout(() => this.fail(new APIConnectionTimeoutError('Realtime handshake timed out')), positive(options.handshakeTimeout, 30000, 'handshakeTimeout')); + if (options.signal?.aborted) this.abort(); + } + + /** @internal */ + static async connect(client: APIClient, options: RealtimeConnectOptions): Promise { + if (typeof process === 'undefined' || !process.versions?.node) throw new QoderError('Forward Realtime requires Node.js; browsers cannot provide Authorization headers'); + if (typeof options.conversationId !== 'string' || !options.conversationId || options.conversationId.trim() !== options.conversationId || /[\x00-\x1f\x7f]/.test(options.conversationId)) throw new QoderError('conversationId must be nonempty without surrounding whitespace or control characters'); + for (const [key, fallback] of Object.entries({ handshakeTimeout: 30000, readyTimeout: 60000, sendTimeout: 30000, closeTimeout: 10000, maxQueuedEvents: 256, maxQueueBytes: 4 * 1024 * 1024, maxReceiveBytes: 1024 * 1024 })) positive(options[key as keyof typeof options] as number | undefined, fallback, key); + const handshakeDeadline = Date.now() + (options.handshakeTimeout ?? 30000); + const preparationController = new AbortController(); + const cancelPreparation = () => preparationController.abort(); + options.signal?.addEventListener('abort', cancelPreparation, { once: true }); + let preparation: Awaited, ReturnType]>>>; + try { + preparation = await wait(Promise.all([client._prepareRealtimeRequest({ ...options, signal: preparationController.signal }), importWS()]), options.handshakeTimeout ?? 30000, options.signal, 'Realtime authentication'); + } finally { options.signal?.removeEventListener('abort', cancelPreparation); preparationController.abort(); } + if (options.signal?.aborted) throw new APIUserAbortError('Realtime connection was aborted'); + const [{ url, headers }, module] = preparation; + const handshakeRemaining = handshakeDeadline - Date.now(); + if (handshakeRemaining <= 0) throw new APIConnectionTimeoutError('Realtime handshake timed out'); + const Socket = module.default; + const transport = options.transport ?? {}; + const socket = new Socket(url, { + ...transport as WSOptions, + headers: Object.fromEntries(headers.entries()), + followRedirects: false, + perMessageDeflate: false, + handshakeTimeout: handshakeRemaining, + maxPayload: options.maxReceiveBytes ?? 1024 * 1024, + }); + const connection = new RealtimeConnection(socket, { ...options, handshakeTimeout: handshakeRemaining }); + await connection.readyPromise; + return connection; + } + + private onMessage(data: RawData, binary: boolean): void { + if (this.ended) return; + try { + if (binary) throw new RealtimeProtocolError('Realtime expects UTF-8 JSON text frames', this.conversationId); + const buffer = Array.isArray(data) ? Buffer.concat(data) : data instanceof ArrayBuffer ? Buffer.from(data) : data; + let value: unknown; + try { value = JSON.parse(buffer.toString()); } catch { throw new RealtimeProtocolError('Invalid Realtime JSON', this.conversationId); } + const event = validateEvent(value, this.conversationId); + if (event.sequence <= this.lastSequence) throw new RealtimeProtocolError('Realtime sequence did not increase', this.conversationId); + this.lastSequence = event.sequence; + const size = buffer.byteLength; + if (this.receiver) { const receiver = this.receiver; this.receiver = undefined; receiver.resolve({ done: false, value: event }); } + else { + if (this.queue.length >= this.maxQueuedEvents || this.queueBytes + size > this.maxQueueBytes) throw new RealtimeProtocolError('Realtime receive queue capacity exceeded', this.conversationId, 'queue_overflow'); + this.queue.push({ event, bytes: size }); this.queueBytes += size; + } + if (event.type === 'voice.ready' && !this.ready) { + this.ready = event.payload as unknown as RealtimeReady; + clearTimeout(this.readyTimer); this.readyResolve(); + } + if (event.type === 'connection.closed' && event.payload.request_id === this.closeAck?.requestId) this.closeAck?.resolve(event.payload as unknown as RealtimeClosed); + if (event.type === 'voice.replaced') this.fail(new RealtimeProtocolError('Realtime connection was replaced', this.conversationId, 'voice.replaced')); + if (event.type === 'error' && (!this.ready || terminalErrors.has(String(event.payload.code)))) this.fail(new RealtimeServerError(event)); + } catch (error) { this.fail(error instanceof Error ? error : new APIConnectionError('Realtime receive failed')); } + } + private finish(error?: Error): void { + if (this.ended) return; + this.ended = true; + this.terminal = error; + clearTimeout(this.handshakeTimer); clearTimeout(this.readyTimer); + this.options.signal?.removeEventListener('abort', this.abort); + this.readyReject(error ?? new RealtimeConnectionClosedError(this.conversationId, 1000, 'Closed before ready')); + this.closeAck?.reject(error ?? new RealtimeConnectionClosedError(this.conversationId, 1000, 'Closed without matching acknowledgement')); + if (this.receiver) { const receiver = this.receiver; this.receiver = undefined; if (error) receiver.reject(error); else receiver.resolve({ done: true, value: undefined }); } + } + private fail(error: Error): void { this.finish(error); this.socket.terminate(); } + + /** Read one event. Concurrent consumers are rejected instead of stealing each other's events. */ + async receive(options: RealtimeWaitOptions = {}): Promise> { + if (this.receiver) throw new QoderError('Only one Realtime event consumer is allowed'); + if (options.signal?.aborted) throw new APIUserAbortError('Realtime receive was aborted'); + const queued = this.queue.shift(); + if (queued) { this.queueBytes -= queued.bytes; return { done: false, value: queued.event }; } + if (this.ended) { if (this.terminal) throw this.terminal; return { done: true, value: undefined }; } + let receiver!: NonNullable; + const pending = new Promise>((resolve, reject) => { this.receiver = receiver = { resolve, reject }; }); + try { return await wait(pending, positive(options.timeout, 2 ** 31 - 1, 'receive timeout'), options.signal, 'Realtime receive'); } + finally { if (this.receiver === receiver) this.receiver = undefined; } + } + async *[Symbol.asyncIterator](): AsyncGenerator { + for (;;) { const next = await this.receive(); if (next.done) return; yield next.value; } + } + /** Serialize sends in call order; no input is automatically replayed. */ + sendEvent(event: RealtimeClientEvent, options: RealtimeWaitOptions = {}): Promise { + const timeout = positive(options.timeout, this.sendTimeout, 'send timeout'); + if (options.signal?.aborted) return Promise.reject(new APIUserAbortError('Realtime send was aborted')); + if (!event || event.version !== REALTIME_VERSION || typeof event.type !== 'string' || !event.type || !isObject(event.payload)) throw new QoderError('Client events require version, type and object payload'); + if (this.ended || this.socket.readyState !== 1) throw this.terminal ?? new QoderError('Realtime connection is closed'); + if (this.closing && !['connection.close', 'ping'].includes(event.type)) throw new QoderError('Realtime connection is closing'); + if (event.type === 'text.message' && (typeof event.payload.text !== 'string' || !event.payload.text.trim() || new TextEncoder().encode(event.payload.text.trim()).length > 16 * 1024)) throw new QoderError('Realtime text must be nonempty and at most 16 KiB UTF-8'); + if (event.type === 'audio.append' && (typeof event.payload.audio !== 'string' || !event.payload.audio || !base64.test(event.payload.audio) || globalThis.atob(event.payload.audio).length % 2)) throw new QoderError('Audio append requires nonempty Base64 PCM16'); + if (event.type === 'connection.close') { + const id = event.payload.request_id; + if (typeof id !== 'string' || !id || id.trim() !== id || Buffer.byteLength(id) > 128 || /[\x00-\x1f\x7f]/.test(id)) throw new QoderError('Invalid connection.close request_id'); + } + const json = JSON.stringify(event); + const bytes = Buffer.byteLength(json); + if (bytes > CLIENT_MESSAGE_BYTES) throw new QoderError('Realtime client JSON message exceeds 256 KiB'); + if (this.sendCount >= this.maxQueuedEvents || this.sendBytes + bytes > this.maxQueueBytes) throw new QoderError('Realtime send queue capacity exceeded'); + this.sendCount++; this.sendBytes += bytes; + const result = this.sendTail.then(async () => { + if (this.ended) throw this.terminal ?? new QoderError('Realtime connection is closed'); + if (options.signal?.aborted) throw new APIUserAbortError('Realtime send was aborted'); + const written = new Promise((resolve, reject) => this.socket.send(json, { binary: false }, error => error ? reject(new APIConnectionError('Realtime send failed')) : resolve())); + try { await wait(written, timeout, options.signal, 'Realtime send'); } + catch (error) { this.fail(error as Error); throw error; } + }).finally(() => { this.sendCount--; this.sendBytes -= bytes; }); + this.sendTail = result.catch(() => {}); + const deadline = wait(result, timeout, options.signal, 'Realtime send'); + return deadline.catch(error => { this.fail(error); throw error; }); + } + sendText(text: string, options?: RealtimeWaitOptions): Promise { + if (typeof text !== 'string' || !text.trim() || new TextEncoder().encode(text.trim()).length > 16 * 1024) throw new QoderError('Realtime text must be nonempty and at most 16 KiB UTF-8'); + return this.sendEvent({ version: REALTIME_VERSION, type: 'text.message', payload: { text: text.trim() } }, options); + } + appendAudio(pcm: Uint8Array, options?: RealtimeWaitOptions): Promise { + if (!(pcm instanceof Uint8Array) || !pcm.byteLength || pcm.byteLength % 2) throw new QoderError('Audio must contain nonempty, complete PCM16 samples'); + if (pcm.byteLength > CLIENT_MESSAGE_BYTES * 3 / 4) throw new QoderError('Realtime client JSON message exceeds 256 KiB'); + return this.sendEvent({ version: REALTIME_VERSION, type: 'audio.append', payload: { audio: Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64') } }, options); + } + interrupt(options?: RealtimeWaitOptions): Promise { return this.sendEvent({ version: REALTIME_VERSION, type: 'interrupt', payload: {} }, options); } + ping(options?: RealtimeWaitOptions): Promise { return this.sendEvent({ version: REALTIME_VERSION, type: 'ping', payload: {} }, options); } + playbackStarted(receipt: RealtimePlaybackReceipt = {}, options?: RealtimeWaitOptions): Promise { return this.playback('playback.started', receipt, options); } + playbackEnded(receipt: RealtimePlaybackReceipt = {}, options?: RealtimeWaitOptions): Promise { return this.playback('playback.ended', receipt, options); } + playbackCancelled(receipt: RealtimePlaybackReceipt = {}, options?: RealtimeWaitOptions): Promise { return this.playback('playback.cancelled', receipt, options); } + private playback(type: string, receipt: RealtimePlaybackReceipt, options?: RealtimeWaitOptions): Promise { + for (const value of Object.values(receipt)) if (typeof value !== 'string' || !value) throw new QoderError('Playback identifiers must be nonempty strings'); + return this.sendEvent({ version: REALTIME_VERSION, type, payload: { ...receipt } }, options); + } + /** Send connection.close, require its matching persistence acknowledgement, then release transport. */ + closeGracefully(options: RealtimeCloseOptions = {}): Promise { + if (this.graceful) return this.graceful; + if (this.ended) return Promise.reject(this.terminal ?? new QoderError('Realtime connection is closed')); + if (!this.ready.capabilities.graceful_close) return Promise.reject(new QoderError('Server does not support graceful close')); + const requestId = options.requestId ?? globalThis.crypto.randomUUID(); + if (!requestId || requestId.trim() !== requestId || Buffer.byteLength(requestId) > 128 || /[\x00-\x1f\x7f]/.test(requestId)) throw new QoderError('Close requestId must be 1–128 UTF-8 bytes without surrounding whitespace or control characters'); + const timeout = positive(options.timeout, this.closeTimeout, 'close timeout'); + if (options.signal?.aborted) return Promise.reject(new APIUserAbortError('Realtime graceful close was aborted')); + this.closing = true; + const acknowledgement = new Promise((resolve, reject) => { this.closeAck = { requestId, resolve, reject }; }); + void acknowledgement.catch(() => {}); + this.graceful = (async () => { + try { + const confirmed = wait(acknowledgement, timeout, options.signal, 'Realtime graceful close'); + void confirmed.catch(() => {}); + await this.sendEvent({ version: REALTIME_VERSION, type: 'connection.close', payload: { request_id: requestId } }, options); + return await confirmed; + } finally { this.close(); } + })(); + return this.graceful; + } + /** Transport close does not promise history persistence or cancel background Work. */ + close(): void { + this.finish(); + if (this.socket.readyState === 1) { + this.socket.close(1000); + const timer = setTimeout(() => this.socket.terminate(), 1000); + timer.unref(); + this.socket.once('close', () => clearTimeout(timer)); + } else this.socket.terminate(); + } +} diff --git a/src/forward/realtime/conversation.ts b/src/forward/realtime/conversation.ts new file mode 100644 index 0000000..3a43703 --- /dev/null +++ b/src/forward/realtime/conversation.ts @@ -0,0 +1,74 @@ +import { APIResource } from '../../core/resource.js'; +import { APIPromise } from '../../core/api-promise.js'; +import type { APIClient, RequestOptions } from '../../core/client.js'; +import { QoderError } from '../../core/error.js'; +import { pathParam, requestHeaders } from '../_utils.js'; +import type { RealtimeConversation, RealtimeConversationCreateParams, RealtimeHistoryEvent, RealtimeHistoryParams, RealtimeHistoryResponse } from './types.js'; + +/** History uses before cursors and events, independently of ordinary list pages. */ +export class RealtimeHistoryPage implements RealtimeHistoryResponse, AsyncIterable { + readonly conversation: RealtimeHistoryResponse['conversation']; + readonly events: RealtimeHistoryEvent[]; + readonly page: RealtimeHistoryResponse['page']; + readonly _request_id: string | null; + [key: string]: unknown; + constructor(private readonly client: APIClient, private readonly conversationID: string, + private readonly params: RealtimeHistoryParams, private readonly options: RequestOptions, + private readonly data: RealtimeHistoryResponse, readonly response: Response) { + for (const [key, value] of Object.entries(data)) if (!(key in this)) Object.defineProperty(this, key, { value, enumerable: true }); + this.conversation = data.conversation; + this.events = data.events; + this.page = data.page; + this._request_id = response.headers.get('x-request-id') ?? response.headers.get('request-id'); + } + hasNextPage(): boolean { return this.page.has_more && !!this.page.next_before; } + getNextPage(): APIPromise { + if (!this.hasNextPage()) throw new QoderError('No next history page; check hasNextPage() first'); + if (this.page.next_before === this.params.before) throw new QoderError('History cursor did not advance'); + return new RealtimeConversations(this.client).history(this.conversationID, { ...this.params, before: this.page.next_before! }, this.options); + } + async *iterPages(): AsyncGenerator { + let current: RealtimeHistoryPage = this; + const seen = new Set(); + if (this.params.before) seen.add(this.params.before); + for (;;) { + yield current; + if (!current.hasNextPage()) return; + const cursor = current.page.next_before!; + if (seen.has(cursor)) throw new QoderError('History cursor did not advance'); + seen.add(cursor); + current = await current.getNextPage(); + } + } + async *[Symbol.asyncIterator](): AsyncGenerator { + for await (const page of this.iterPages()) yield* page.events; + } + toJSON(): RealtimeHistoryResponse { return this.data; } +} + +export class RealtimeConversations extends APIResource { + /** Create a Beta Realtime Conversation; use a stable key for this operation. */ + create(params: RealtimeConversationCreateParams, options: RequestOptions = {}): APIPromise { + const { idempotency_key, ...body } = params; + const headers = requestHeaders({ 'Idempotency-Key': idempotency_key }, options.headers); + const key = options.idempotencyKey ?? headers['idempotency-key']; + if (typeof key !== 'string' || !key || key.trim() !== key || new TextEncoder().encode(key).length > 256 || /[\x00-\x1f\x7f]/.test(key)) { + throw new QoderError('Idempotency-Key must be 1–256 UTF-8 bytes without surrounding whitespace or control characters'); + } + return this._client.request({ method: 'POST', path: 'realtime/conversations', body, ...options, headers }); + } + /** Query persisted history, newest first; next_before retrieves older records. */ + history(conversationID: string, params: RealtimeHistoryParams = {}, options: RequestOptions = {}): APIPromise { + const query = { ...params, ...options.query }; + if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 100)) throw new QoderError('History limit must be an integer from 1 to 100'); + if (query.before !== undefined && (typeof query.before !== 'string' || !query.before)) throw new QoderError('History before must be nonempty'); + if (query.types !== undefined && !['message', 'work', 'message,work'].includes(query.types)) throw new QoderError('History types must be message, work, or message,work'); + const request = this._client.request({ method: 'GET', path: `realtime/conversations/${pathParam(conversationID, 'conversation_id')}/history`, ...options, query }); + return new APIPromise(request.asResponse(), async response => { + const data = (await request.withResponse()).data; + if (!data || !Array.isArray(data.events) || !data.conversation || !data.page || typeof data.page.has_more !== 'boolean' + || !(data.page.next_before === null || typeof data.page.next_before === 'string') || (data.page.has_more && !data.page.next_before)) throw new QoderError('Invalid Realtime history response'); + return new RealtimeHistoryPage(this._client, conversationID, query, { ...options, query: undefined }, data, response); + }); + } +} diff --git a/src/forward/realtime/realtime.ts b/src/forward/realtime/realtime.ts new file mode 100644 index 0000000..5874883 --- /dev/null +++ b/src/forward/realtime/realtime.ts @@ -0,0 +1,8 @@ +import { APIResource } from '../../core/resource.js'; +import { RealtimeConversations } from './conversation.js'; +import { RealtimeConnection, type RealtimeConnectOptions } from './connection.js'; +/** Forward Realtime Beta. WebSocket transport is supported on Node.js only. */ +export class Realtime extends APIResource { + readonly conversations = new RealtimeConversations(this._client); + connect(options: RealtimeConnectOptions): Promise { return RealtimeConnection.connect(this._client, options); } +} diff --git a/src/forward/realtime/types.ts b/src/forward/realtime/types.ts new file mode 100644 index 0000000..83ad859 --- /dev/null +++ b/src/forward/realtime/types.ts @@ -0,0 +1,119 @@ +/** Forward Realtime Beta protocol. Timestamp strings preserve nanosecond precision. */ +export const REALTIME_VERSION = 'voice.realtime.v1' as const; +export type RealtimeVoice = 'longanqian' | 'longanlingxin' | 'longanlingxi' | 'longanxiaoxin' | 'longanlufeng'; +export interface RealtimeConfig { + audio?: { output?: { voice?: RealtimeVoice; [key: string]: unknown }; [key: string]: unknown }; + [key: string]: unknown; +} +export interface RealtimeConversationCreateParams { + identity_id: string; + template_id: string; + /** Required header, omitted from the JSON body. */ + idempotency_key: string; + title?: string | null; + metadata?: Record | null; + config?: RealtimeConfig; +} +export interface RealtimeConversation { + id: string; + type: 'voice.conversation'; + status: 'ready'; + title: string | null; + metadata: Record; + config: RealtimeConfig; + created_at: string; + updated_at: string; + [key: string]: unknown; +} +export interface RealtimeHistoryParams { + limit?: number; + before?: string; + types?: 'message' | 'work' | 'message,work'; +} +export type RealtimeHistoryEventType = 'voice.user_message.completed' | 'voice.assistant_message.completed' + | 'voice.assistant_message.interrupted' | 'voice.work.accepted' | 'voice.work.running' + | 'voice.work.completed' | 'voice.work.failed' | 'voice.work.final_announcement' | (string & {}); +export interface RealtimeHistoryEvent { + id: string; + type: RealtimeHistoryEventType; + status: string; + occurred_at: string; + role?: string; + text?: string; + work_id?: string; + objective?: string; + result?: string; + error?: { code: string; [key: string]: unknown }; + turn_id?: string; + user_message_event_id?: string; + announcement_id?: string; + milestone_type?: string; + [key: string]: unknown; +} +export interface RealtimeHistoryResponse { + conversation: { + id: string; + title: string | null; + initialization_status: string; + metadata: Record; + created_at: string; + updated_at: string; + [key: string]: unknown; + }; + events: RealtimeHistoryEvent[]; + page: { next_before: string | null; has_more: boolean; [key: string]: unknown }; + [key: string]: unknown; +} +export interface RealtimeAudioFormat { format: 'pcm16'; sample_rate: number; channels: 1; [key: string]: unknown } +export interface RealtimeReady { + config: RealtimeConfig; + provider: string; + input_audio: RealtimeAudioFormat; + output_audio: RealtimeAudioFormat; + capabilities: { graceful_close: boolean; [key: string]: unknown }; + [key: string]: unknown; +} +export interface RealtimePlaybackReceipt { work_id?: string; announcement_id?: string } +export type RealtimeCloseOutcome = 'no_active_response' | 'no_content' | 'saved' | 'saved_interrupted'; +export interface RealtimeClosed { request_id: string; outcome: RealtimeCloseOutcome } +export interface RealtimeErrorPayload { code: string; message: string; retryable?: boolean; retry_after_ms?: number; [key: string]: unknown } +export interface RealtimeServerPayloads { + 'voice.state': { state: string }; + 'voice.ready': RealtimeReady; + 'voice.replaced': { reason: string }; + 'transcript.delta': { role: string; text: string; item_id?: string; response_id?: string }; + 'transcript.final': { role: string; text: string; item_id?: string; response_id?: string }; + 'audio.delta': { audio: string }; + 'audio.done': Record; + 'playback.interrupt': { reason: string }; + 'work.accepted': { status: 'accepted'; objective: string }; + 'work.running': { status: 'running' }; + 'work.progress': { kind: string; detail: string; title?: string; is_error?: boolean; event_id?: string }; + 'work.milestone': { announcement_id: string; milestone_type: string; summary: string }; + 'work.completed': { status: 'completed'; result: string; event_id?: string }; + 'work.failed': { status: 'failed'; error: { code: string; [key: string]: unknown }; event_id?: string }; + pong: Record; + 'connection.closed': RealtimeClosed; + error: RealtimeErrorPayload; +} +export interface RealtimeEventEnvelope { + version: typeof REALTIME_VERSION; + event_id: string; + sequence: number; + conversation_id: string; + timestamp: string; + work_id?: string; + announcement_id?: string; + [key: string]: unknown; +} +export type RealtimeKnownEvent = { [K in keyof RealtimeServerPayloads]: RealtimeEventEnvelope & { + type: K; payload: RealtimeServerPayloads[K] & Record; +} }[keyof RealtimeServerPayloads]; +/** Unknown Beta events retain their envelope and complete payload. */ +export type RealtimeServerEvent = RealtimeKnownEvent | (RealtimeEventEnvelope & { type: string; payload: Record }); +export interface RealtimeClientEvent { version: typeof REALTIME_VERSION; type: string; payload: Record } + +/** Narrow a known event payload while continuing to allow future Beta event types. */ +export function isRealtimeEvent(event: RealtimeServerEvent, type: K): event is Extract { + return event.type === type; +} diff --git a/src/index.ts b/src/index.ts index a4c253d..34ac042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,9 @@ export * from './core/pagination.js'; export * from './core/streaming.js'; export { ResumableSessionEventStream } from './core/resumable-session-event-stream.js'; export * from './core/uploads.js'; + +export { Realtime } from './forward/realtime/realtime.js'; +export { RealtimeConversations, RealtimeHistoryPage } from './forward/realtime/conversation.js'; +export { RealtimeConnection, RealtimeProtocolError, RealtimeConnectionClosedError, RealtimeServerError, decodeRealtimeAudio } from './forward/realtime/connection.js'; +export type { RealtimeConnectOptions, RealtimeWaitOptions, RealtimeCloseOptions } from './forward/realtime/connection.js'; +export * from './forward/realtime/types.js'; diff --git a/tests/api-contracts.test.mjs b/tests/api-contracts.test.mjs index b1e29e7..8c49a0b 100644 --- a/tests/api-contracts.test.mjs +++ b/tests/api-contracts.test.mjs @@ -4,12 +4,12 @@ import { test } from 'node:test'; import { sdk, methods, contracts, operations, lookup, argumentsFor, invoke, resource, response, testClient, assertRequest } from './helpers.mjs'; const handwrittenMethods = { - forward: ['sessions.events.resumableStream'], + forward: ['sessions.events.resumableStream', 'realtime.connect'], managed: ['sessions.events.resumableStream'], }; for (const mode of ['forward', 'managed']) { - test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 116 : 98} APIs)`, () => { + test(`${mode}: public resource inventory exactly matches the frozen API surface plus handwritten helpers (${mode === 'forward' ? 118 : 98} APIs)`, () => { const client = testClient(mode, () => { throw Error('inventory must not call network'); }); const expectedRoots = [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]; const actualRoots = Object.entries(client).filter(([, value]) => value && typeof value === 'object' && Object.hasOwn(value, '_client')).map(([key]) => key); @@ -26,7 +26,7 @@ for (const mode of ['forward', 'managed']) { }; for (const entry of [...new Set(methods[mode].map(m => m.entry.split('.')[0]))]) walk(client[entry], entry); const expected = methods[mode].map(m => `${m.entry}.${m.method}`); - assert.equal(expected.length, mode === 'forward' ? 116 : 98); + assert.equal(expected.length, mode === 'forward' ? 118 : 98); assert.equal(new Set(expected).size, expected.length); assert.deepEqual(actual.sort(), [...expected, ...handwrittenMethods[mode]].sort()); }); diff --git a/tests/fixtures/forward/api-contracts.json b/tests/fixtures/forward/api-contracts.json index 4a8c3eb..ca41240 100644 --- a/tests/fixtures/forward/api-contracts.json +++ b/tests/fixtures/forward/api-contracts.json @@ -2803,6 +2803,64 @@ "entry": "Usage", "empty": false }, + { + "id": "realtime.conversations.create", + "operation_id": "createRealtimeConversation", + "entry": "RealtimeConversations", + "response": { + "id": "conv_xxx", + "type": "voice.conversation", + "status": "ready", + "title": "项目分析", + "metadata": { + "source": "desktop" + }, + "config": { + "audio": { + "output": { + "voice": "longanlingxin" + } + } + }, + "created_at": "2026-08-26T02:00:00Z", + "updated_at": "2026-08-26T02:00:01Z" + }, + "empty": false + }, + { + "id": "realtime.conversations.history", + "operation_id": "getRealtimeConversationHistory", + "entry": "RealtimeConversations", + "response": { + "conversation": { + "id": "conv_xxx", + "title": "项目分析", + "initialization_status": "ready", + "metadata": { + "source": "desktop" + }, + "created_at": "2026-08-26T02:00:00Z", + "updated_at": "2026-08-26T02:01:00Z" + }, + "events": [ + { + "id": "vevt_xxx", + "type": "voice.user_message.completed", + "role": "user", + "status": "completed", + "text": "帮我分析当前项目", + "turn_id": "vevt_xxx", + "user_message_event_id": "vevt_xxx", + "occurred_at": "2026-08-26T02:01:00Z" + } + ], + "page": { + "next_before": null, + "has_more": false + } + }, + "empty": false + }, { "operation_id": "createServiceAccountToken", "entry": "ServiceAccountTokens", diff --git a/tests/fixtures/forward/api-methods.json b/tests/fixtures/forward/api-methods.json index 6f9867e..41714de 100644 --- a/tests/fixtures/forward/api-methods.json +++ b/tests/fixtures/forward/api-methods.json @@ -2903,6 +2903,62 @@ ], "paginated": true }, + { + "id": "realtime.conversations.create", + "entry": "realtime.conversations", + "method": "create", + "args": [ + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "identity_id", + "location": "json" + }, + { + "wire": "template_id", + "location": "json" + }, + { + "wire": "idempotency_key", + "location": "header" + } + ] + } + ], + "paginated": false + }, + { + "id": "realtime.conversations.history", + "entry": "realtime.conversations", + "method": "history", + "args": [ + { + "name": "conversationId", + "kind": "path" + }, + { + "name": "params", + "kind": "params", + "fields": [ + { + "wire": "limit", + "location": "query" + }, + { + "wire": "types", + "location": "query" + }, + { + "wire": "before", + "location": "query" + } + ] + } + ], + "paginated": false + }, { "id": "serviceAccountTokens.create", "entry": "serviceAccountTokens", diff --git a/tests/fixtures/forward/api-operation-cases.json b/tests/fixtures/forward/api-operation-cases.json index e15b3db..e5724a1 100644 --- a/tests/fixtures/forward/api-operation-cases.json +++ b/tests/fixtures/forward/api-operation-cases.json @@ -1,7 +1,7 @@ { "schema_version": 1, "openapi_sha256": "cfc0d3556f61838f65174b4c8ae92eded0d7c9fc00dcbd58d07dd6365e711f96", - "operation_count": 116, + "operation_count": 118, "cases": [ { "operation_id": "addSessionResource", @@ -4926,6 +4926,92 @@ "request_body": null, "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" }, + { + "operation_id": "createRealtimeConversation", + "resource": "RealtimeConversations", + "clients": { + "python": "realtime.conversations", + "typescript": "realtime.conversations", + "go": "Realtime.Conversations" + }, + "methods": { + "python": "create", + "typescript": "create", + "go": "New" + }, + "http_method": "POST", + "path": "/realtime/conversations", + "parameters": [ + { + "wire_name": "Idempotency-Key", + "location": "header", + "required": true, + "value": "create-realtime-contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "identity_id": "idn_xxx", + "template_id": "tmpl_xxx", + "title": "项目分析", + "metadata": { + "source": "desktop" + }, + "config": { + "audio": { + "output": { + "voice": "longanlingxin" + } + } + } + } + }, + "source": "docs/release-api/03-Forward层/Realtime/CreateConversation.md" + }, + { + "operation_id": "getRealtimeConversationHistory", + "resource": "RealtimeConversations", + "clients": { + "python": "realtime.conversations", + "typescript": "realtime.conversations", + "go": "Realtime.Conversations" + }, + "methods": { + "python": "history", + "typescript": "history", + "go": "History" + }, + "http_method": "GET", + "path": "/realtime/conversations/{conversation_id}/history", + "parameters": [ + { + "wire_name": "conversation_id", + "location": "path", + "required": true, + "value": "conv_xxx" + }, + { + "wire_name": "limit", + "location": "query", + "required": false, + "value": 50 + }, + { + "wire_name": "types", + "location": "query", + "required": false, + "value": "message,work" + }, + { + "wire_name": "before", + "location": "query", + "required": false, + "value": "cursor_older" + } + ], + "source": "knowledge/api_specs/realtime/Cloud Agents Realtime API.md" + }, { "operation_id": "createServiceAccountToken", "http_method": "POST", diff --git a/tests/fixtures/forward/realtime.json b/tests/fixtures/forward/realtime.json new file mode 100644 index 0000000..7343fc2 --- /dev/null +++ b/tests/fixtures/forward/realtime.json @@ -0,0 +1,74 @@ +{ + "schema_version": 1, + "protocol": "voice.realtime.v1", + "source": "knowledge/api_specs/realtime/Cloud Agents Realtime API.md", + "conversation": { + "id": "conv_xxx", + "type": "voice.conversation", + "status": "ready", + "title": "项目分析", + "metadata": { + "source": "desktop" + }, + "config": { + "audio": { + "output": { + "voice": "longanlingxin" + } + } + }, + "created_at": "2026-08-26T02:00:00Z", + "updated_at": "2026-08-26T02:00:01Z" + }, + "history": { + "conversation": { + "id": "conv_xxx", + "title": "项目分析", + "initialization_status": "ready", + "metadata": { + "source": "desktop" + }, + "created_at": "2026-08-26T02:00:00Z", + "updated_at": "2026-08-26T02:01:00Z" + }, + "events": [ + { + "id": "vevt_xxx", + "type": "voice.user_message.completed", + "role": "user", + "status": "completed", + "text": "帮我分析当前项目", + "turn_id": "vevt_xxx", + "user_message_event_id": "vevt_xxx", + "occurred_at": "2026-08-26T02:01:00Z" + } + ], + "page": { + "next_before": null, + "has_more": false + } + }, + "ready": { + "config": { + "audio": { + "output": { + "voice": "longanlingxin" + } + } + }, + "provider": "qoder", + "input_audio": { + "format": "pcm16", + "sample_rate": 16000, + "channels": 1 + }, + "output_audio": { + "format": "pcm16", + "sample_rate": 24000, + "channels": 1 + }, + "capabilities": { + "graceful_close": true + } + } +} \ No newline at end of file diff --git a/tests/realtime.test.mjs b/tests/realtime.test.mjs new file mode 100644 index 0000000..f404ea0 --- /dev/null +++ b/tests/realtime.test.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import http from 'node:http'; +import { once } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { WebSocketServer } from 'ws'; +import * as sdk from '../dist/index.js'; +import { ForwardClient } from '../dist/forward/index.js'; +const fixture = JSON.parse(readFileSync(new URL('./fixtures/forward/realtime.json', import.meta.url), 'utf8')); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function server(t, behavior, { rejectHTTP } = {}) { + const requests = []; + const messages = []; + const httpServer = http.createServer((_req, response) => response.end()); + const wsServer = new WebSocketServer({ noServer: true }); + httpServer.on('upgrade', (request, socket, head) => { + requests.push({ url: request.url, headers: request.headers }); + if (rejectHTTP) { + socket.end(`HTTP/1.1 ${rejectHTTP.status} Failed\r\nx-request-id: req_upgrade_error\r\n${rejectHTTP.location ? `Location: ${rejectHTTP.location}\r\n` : ''}Content-Length: ${Buffer.byteLength(rejectHTTP.body)}\r\nConnection: close\r\n\r\n${rejectHTTP.body}`); + return; + } + wsServer.handleUpgrade(request, socket, head, ws => { + let sequence = 0; + const emit = (type, payload = {}, extra = {}) => ws.send(JSON.stringify({ version: 'voice.realtime.v1', type, event_id: `evt_${++sequence}`, sequence, conversation_id: new URL(request.url, 'http://local').searchParams.get('conversation_id'), timestamp: '2026-10-13T00:00:00.123456789Z', payload, ...extra })); + ws.on('message', (data, binary) => messages.push({ value: JSON.parse(data.toString()), binary })); + ws.on('error', () => {}); + behavior(ws, emit, request); + }); + }); + wsServer.on('headers', headers => headers.push('X-Request-Id: req_upgrade')); + httpServer.listen(0, '127.0.0.1'); await once(httpServer, 'listening'); + t.after(async () => { for (const ws of wsServer.clients) ws.terminate(); await new Promise(resolve => wsServer.close(resolve)); await new Promise(resolve => httpServer.close(resolve)); }); + const baseURL = `http://127.0.0.1:${httpServer.address().port}/deployment/api/v1/forward`; + return { client: new ForwardClient({ pat: 'secret-pat', baseURL, timeout: 1 }), requests, messages, baseURL }; +} +const ready = (ws, emit) => emit('voice.ready', fixture.ready); +const connect = client => client.realtime.connect({ conversationId: 'conv_test', handshakeTimeout: 1000, readyTimeout: 1000 }); +async function consumeReady(connection) { assert.equal((await connection.receive()).value.type, 'voice.ready'); } + +test('HTTP create carries required stable idempotency header and preserves HTTP responses', async () => { + let calls = 0; + const client = new ForwardClient({ pat: 'pat', maxRetries: 1, fetch: async (input, init) => { + calls++; const request = new Request(input, init); + assert.equal(new URL(request.url).pathname, '/api/v1/forward/realtime/conversations'); + assert.equal(request.headers.get('idempotency-key'), 'stable-operation'); + assert.equal(request.headers.get('authorization'), 'Bearer pat'); + assert.deepEqual(await request.json(), { identity_id: 'idn_xxx', template_id: 'tmpl_xxx', metadata: null }); + return new Response(JSON.stringify(calls === 1 ? { error: { code: 'unavailable' } } : fixture.conversation), { status: calls === 1 ? 503 : 201, headers: { 'retry-after-ms': '1', 'x-request-id': 'req_create' } }); + } }); + const operation = client.realtime.conversations.create({ identity_id: 'idn_xxx', template_id: 'tmpl_xxx', metadata: null, idempotency_key: 'stable-operation' }); + assert.deepEqual(JSON.parse(JSON.stringify(await operation)), fixture.conversation); + assert.equal((await operation.withResponse()).request_id, 'req_create'); + assert.equal(calls, 2); +}); +for (const key of ['', ' key', 'key ', 'a\n', '汉'.repeat(86)]) test(`create rejects invalid idempotency key ${JSON.stringify(key.slice(0, 12))}`, () => { + const client = new ForwardClient({ pat: 'pat', fetch: () => assert.fail('invalid key reached transport') }); + assert.throws(() => client.realtime.conversations.create({ identity_id: 'idn', template_id: 'tmpl', idempotency_key: key }), /Idempotency-Key/); +}); +test('create honors request header priority and does not retry 409', async () => { + let calls = 0; + const client = new ForwardClient({ pat: 'pat', fetch: async (_input, init) => { + calls++; assert.equal(new Headers(init.headers).get('idempotency-key'), 'override'); + return new Response(JSON.stringify({ error: { code: 'idempotency_key_in_progress' } }), { status: 409, headers: { 'retry-after': '2', 'x-request-id': 'req_conflict' } }); + } }); + await assert.rejects(client.realtime.conversations.create({ identity_id: 'idn', template_id: 'tmpl', idempotency_key: 'original' }, { headers: { 'Idempotency-Key': 'override' } }), e => e instanceof sdk.ConflictError && e.code === 'idempotency_key_in_progress' && e.request_id === 'req_conflict' && e.headers.get('retry-after') === '2'); + assert.equal(calls, 1); +}); +test('history uses independent events/before paging, preserves extensions and nanosecond strings', async () => { + const queries = []; + const client = new ForwardClient({ pat: 'pat', fetch: async input => { + const url = new URL(input); queries.push(url.searchParams); + assert.equal(url.pathname, '/api/v1/forward/realtime/conversations/conv%20%2F%3F/history'); + const first = !url.searchParams.has('before'); + return new Response(JSON.stringify({ ...fixture.history, events: first ? fixture.history.events : [], page: { has_more: first, next_before: first ? 'older-cursor' : null }, extension: { future: true } }), { headers: { 'x-request-id': 'req_history' } }); + } }); + const first = await client.realtime.conversations.history('conv /?', { limit: 5, types: 'message,work' }); + assert.equal(first.hasNextPage(), true); assert.equal(first.extension.future, true); assert.equal(first._request_id, 'req_history'); + assert.equal(JSON.stringify(first).includes('"client"'), false); + const events = []; for await (const event of first) events.push(event); + assert.deepEqual(events, fixture.history.events); + assert.equal(queries[1].get('before'), 'older-cursor'); assert.equal(queries[1].get('limit'), '5'); assert.equal(queries[1].get('types'), 'message,work'); + const last = await first.getNextPage(); assert.equal(last.hasNextPage(), false); assert.throws(() => last.getNextPage(), /No next history page/); +}); +for (const params of [{ limit: 0 }, { limit: 101 }, { limit: 1.1 }, { before: '' }, { types: 'messages' }]) test(`history validates ${JSON.stringify(params)}`, () => { + const client = new ForwardClient({ pat: 'pat', fetch: () => assert.fail('invalid history params reached transport') }); + assert.throws(() => client.realtime.conversations.history('conv', params)); +}); +test('history detects repeated cursor instead of looping', async () => { + const client = new ForwardClient({ pat: 'pat', fetch: async () => new Response(JSON.stringify({ ...fixture.history, page: { has_more: true, next_before: 'repeat' } })) }); + const page = await client.realtime.conversations.history('conv'); + await assert.rejects(async () => { for await (const _event of page) { /* exhaust */ } }, /cursor did not advance/); +}); +test('connect waits for validated ready, preserves pre-ready events, and ignores ordinary HTTP timeout', async t => { + const host = await server(t, (ws, emit) => { emit('voice.state', { state: 'connecting' }); setTimeout(() => emit('voice.ready', fixture.ready), 30); }); + const connection = await connect(host.client); t.after(() => connection.close()); + assert.deepEqual(connection.ready, fixture.ready); assert.equal(connection.request_id, 'req_upgrade'); + assert.equal((await connection.receive()).value.type, 'voice.state'); + const event = (await connection.receive()).value; assert.equal(event.type, 'voice.ready'); assert.equal(event.timestamp, '2026-10-13T00:00:00.123456789Z'); + assert.equal(host.requests[0].url, '/deployment/api/v1/forward/realtime?conversation_id=conv_test'); + assert.equal(host.requests[0].headers.authorization, 'Bearer secret-pat'); assert.equal(host.requests[0].url.includes('secret'), false); + await delay(40); await connection.ping(); +}); +const events = { + 'voice.state': { state: 'idle' }, 'transcript.delta': { role: 'assistant', text: '增量' }, 'transcript.final': { role: 'assistant', text: '文本' }, + 'audio.delta': { audio: 'AQACAA==' }, 'audio.done': {}, 'playback.interrupt': { reason: 'user_interrupt' }, + 'work.accepted': { status: 'accepted', objective: 'task' }, 'work.running': { status: 'running' }, + 'work.progress': { kind: 'tool', detail: 'details', is_error: false }, 'work.milestone': { announcement_id: 'ann', milestone_type: 'progress', summary: 'summary' }, + 'work.completed': { status: 'completed', result: 'result' }, 'work.failed': { status: 'failed', error: { code: 'task_failed' } }, + pong: {}, error: { code: 'work_busy', message: 'busy', retryable: true, retry_after_ms: 1000 }, + 'connection.closed': { request_id: 'not_requested', outcome: 'saved' }, 'future.event': { unknown: { nested: true } }, +}; +test('all public server events and unknown extensions remain available without closing on work.failed', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); for (const [type, payload] of Object.entries(events)) emit(type, payload, { work_id: 'work_id', announcement_id: 'ann_id', extension: true }); }); + const connection = await connect(host.client); t.after(() => connection.close()); await consumeReady(connection); + for (const [type, payload] of Object.entries(events)) { + const event = (await connection.receive()).value; + assert.equal(event.type, type); assert.deepEqual(event.payload, payload); assert.equal(event.work_id, 'work_id'); assert.equal(event.extension, true); + if (type === 'audio.delta') assert.deepEqual(sdk.decodeRealtimeAudio(event), Uint8Array.from([1, 0, 2, 0])); + } + await connection.ping(); +}); +test('client helpers use JSON text frames and original playback IDs; sends stay in call order', async t => { + const host = await server(t, ready); const connection = await connect(host.client); t.after(() => connection.close()); + await Promise.all([connection.sendText('你好'), connection.appendAudio(Uint8Array.from([1, 0, 2, 0])), connection.interrupt(), connection.playbackStarted({ work_id: 'work', announcement_id: 'ann' }), connection.playbackEnded({ work_id: 'work', announcement_id: 'ann' }), connection.playbackCancelled({ work_id: 'work', announcement_id: 'ann' }), connection.ping(), connection.sendEvent({ version: 'voice.realtime.v1', type: 'future.client', payload: { extension: true } })]); + await delay(10); + assert.deepEqual(host.messages.map(item => item.value.type), ['text.message', 'audio.append', 'interrupt', 'playback.started', 'playback.ended', 'playback.cancelled', 'ping', 'future.client']); + assert.ok(host.messages.every(item => item.binary === false && item.value.version === 'voice.realtime.v1')); + assert.deepEqual(host.messages[1].value.payload, { audio: 'AQACAA==' }); assert.deepEqual(host.messages[3].value.payload, { work_id: 'work', announcement_id: 'ann' }); +}); +test('UTF-8 text and final Base64 JSON sizes are enforced before sending', async t => { + const host = await server(t, ready); const connection = await connect(host.client); t.after(() => connection.close()); + for (const text of ['', ' ', '汉'.repeat(5462)]) assert.throws(() => connection.sendText(text), /16 KiB/); + for (const pcm of [new Uint8Array(), new Uint8Array(1), new Uint8Array(196608)]) assert.throws(() => connection.appendAudio(pcm)); + assert.throws(() => connection.sendEvent({ version: 'voice.realtime.v1', type: 'future', payload: { huge: '汉'.repeat(100000) } }), /256 KiB/); + assert.throws(() => connection.playbackStarted({ work_id: '' })); + await connection.sendText('汉'.repeat(5461)); +}); +for (const malformed of ['{broken', JSON.stringify({ version: 'bad' }), JSON.stringify({ version: 'voice.realtime.v1', type: 'voice.ready', payload: {} })]) test('malformed ready/envelope is a protocol error', async t => { + const host = await server(t, ws => ws.send(malformed)); + await assert.rejects(connect(host.client), sdk.RealtimeProtocolError); +}); +test('binary frames are rejected', async t => { + const host = await server(t, ws => ws.send(Buffer.from('{}'))); await assert.rejects(connect(host.client), sdk.RealtimeProtocolError); +}); +test('ready timeout releases socket', async t => { + const host = await server(t, () => {}); + await assert.rejects(host.client.realtime.connect({ conversationId: 'conv_test', readyTimeout: 15 }), sdk.APIConnectionTimeoutError); + await delay(10); assert.equal(host.requests.length, 1); +}); +test('initial server error preserves code/retry instructions', async t => { + const host = await server(t, (ws, emit) => emit('error', { code: 'provider_unavailable', message: 'unavailable', retryable: true, retry_after_ms: 2000 })); + await assert.rejects(connect(host.client), e => e instanceof sdk.RealtimeServerError && e.code === 'provider_unavailable' && e.retry_after_ms === 2000); +}); +test('abort during ready and after ready releases transport', async t => { + const host = await server(t, () => {}); const controller = new AbortController(); + const pending = host.client.realtime.connect({ conversationId: 'conv_test', signal: controller.signal }); setTimeout(() => controller.abort(), 15); + await assert.rejects(pending, sdk.APIUserAbortError); + const second = await server(t, ready); const controller2 = new AbortController(); + const connection = await second.client.realtime.connect({ conversationId: 'conv_test', signal: controller2.signal }); await consumeReady(connection); + controller2.abort(); await assert.rejects(connection.receive(), sdk.APIUserAbortError); +}); +test('receive cancellation does not steal the next event and concurrent readers fail clearly', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => emit('pong')); }); + const connection = await connect(host.client); t.after(() => connection.close()); await consumeReady(connection); + const controller = new AbortController(); const reading = connection.receive({ signal: controller.signal }); + await assert.rejects(connection.receive(), /one Realtime event consumer/); controller.abort(); await assert.rejects(reading, sdk.APIUserAbortError); + await connection.ping(); assert.equal((await connection.receive()).value.type, 'pong'); +}); +test('a completed receive cannot clear the next receive registered in the same message callback', async t => { + const host = await server(t, (ws, emit) => { + ready(ws, emit); + ws.on('message', () => { emit('pong'); setTimeout(() => emit('voice.state', { state: 'idle' }), 10); }); + }); + const connection = await connect(host.client); t.after(() => connection.close()); await consumeReady(connection); + const first = connection.receive({ timeout: 500 }); + let second; + // Observe transport delivery to register the next public receive before the first promise settles. + connection.socket.once('message', () => { second = connection.receive({ timeout: 500 }); }); + await connection.ping(); + assert.equal((await first).value.type, 'pong'); + assert.equal((await second).value.type, 'voice.state'); +}); +test('queue overflow and size budgets terminate instead of dropping events', { timeout: 5000 }, async t => { + let observeClose; + const closed = new Promise(resolve => { observeClose = resolve; }); + const host = await server(t, (ws, emit) => { ws.once('close', observeClose); ready(ws, emit); for (let i = 0; i < 5; i++) emit('pong'); }); + const connection = await host.client.realtime.connect({ conversationId: 'conv_test', maxQueuedEvents: 1 }); + await closed; + await consumeReady(connection); + await assert.rejects(connection.receive(), /queue capacity/); + assert.throws(() => connection.ping(), /queue capacity/); +}); +test('voice.replaced is delivered then rejects further reads and writes', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => emit('voice.replaced', { reason: 'new connection' })); }); + const connection = await connect(host.client); t.after(() => connection.close()); await consumeReady(connection); await connection.ping(); + assert.equal((await connection.receive()).value.type, 'voice.replaced'); await assert.rejects(connection.receive(), /replaced/); assert.throws(() => connection.sendText('new input'), /replaced/); +}); +test('graceful close requires matching request ID and valid persisted outcome; repeated call is shared', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', data => { + const event = JSON.parse(data); if (event.type === 'connection.close') { emit('connection.closed', { request_id: 'wrong', outcome: 'no_content' }); emit('transcript.final', { role: 'assistant', text: 'final' }); emit('connection.closed', { request_id: event.payload.request_id, outcome: 'saved_interrupted' }); ws.close(1000); } + }); }); + const connection = await connect(host.client); await consumeReady(connection); + const closing = connection.closeGracefully({ requestId: 'close_123' }); assert.equal(connection.closeGracefully(), closing); + assert.deepEqual(await closing, { request_id: 'close_123', outcome: 'saved_interrupted' }); + assert.equal((await connection.receive()).value.payload.request_id, 'wrong'); assert.equal((await connection.receive()).value.type, 'transcript.final'); assert.equal((await connection.receive()).value.payload.request_id, 'close_123'); assert.equal((await connection.receive()).done, true); + connection.close(); connection.close(); +}); +test('graceful close timeout cannot claim persistence', async t => { + const host = await server(t, ready); const connection = await connect(host.client); + await assert.rejects(connection.closeGracefully({ timeout: 15 }), sdk.APIConnectionTimeoutError); +}); +test('persistence error aborts graceful close and retains the server error', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => emit('error', { code: 'event_persistence_failed', message: 'save failed' })); }); + const connection = await connect(host.client); await assert.rejects(connection.closeGracefully(), e => e instanceof sdk.RealtimeServerError && e.code === 'event_persistence_failed'); +}); +test('abnormal remote close exposes code and reason', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => ws.close(1011, 'service failed')); }); + const connection = await connect(host.client); await consumeReady(connection); await connection.ping(); + await assert.rejects(connection.receive(), e => e instanceof sdk.RealtimeConnectionClosedError && e.closeCode === 1011 && e.closeReason === 'service failed'); +}); +for (const response of [{ status: 401, body: '{"code":"TOKEN_INVALID","message":"invalid"}' }, { status: 503, body: 'gateway unavailable' }]) test(`HTTP ${response.status} handshake maps to APIError without retry`, async t => { + const host = await server(t, ready, { rejectHTTP: response }); + await assert.rejects(connect(host.client), e => e instanceof sdk.APIError && e.status === response.status && e.request_id === 'req_upgrade_error' && (response.status !== 401 || e.code === 'TOKEN_INVALID')); + assert.equal(host.requests.length, 1); +}); +test('handshake redirect never forwards credential to target', async t => { + const target = await server(t, ready); + const host = await server(t, ready, { rejectHTTP: { status: 302, location: `${target.baseURL}/realtime`, body: '' } }); + await assert.rejects(connect(host.client), e => e instanceof sdk.APIError && e.status === 302); + assert.equal(target.requests.length, 0); +}); +test('PAT, static SAT, dynamic Credential use shared resolution and per-connection refresh', async t => { + const host = await server(t, ready); let calls = 0; + const client = new ForwardClient({ authMode: 'sa', credential: { getToken: async () => `sat_${++calls}` }, baseURL: host.baseURL }); + const first = await connect(client); first.close(); const second = await connect(client); second.close(); + assert.deepEqual(host.requests.map(request => request.headers.authorization), ['Bearer sat_1', 'Bearer sat_2']); + const staticClient = new ForwardClient({ authMode: 'sa', accessToken: 'static-sat', baseURL: host.baseURL }); const third = await connect(staticClient); third.close(); + const extra = await client.realtime.connect({ conversationId: 'conv_test', headers: { Authorization: 'Bearer override' } }); extra.close(); + assert.equal(calls, 2); assert.equal(host.requests[2].headers.authorization, 'Bearer static-sat'); assert.equal(host.requests[3].headers.authorization, 'Bearer override'); +}); +test('Node-only check runs before auth and loading ws; existing HTTP browser import remains usable', () => { + const execution = spawnSync(process.execPath, ['--input-type=module', '-e', `const realProcess = globalThis.process; globalThis.process = undefined; const sdk = await import('./dist/esm/index.js'); const client = new sdk.ForwardClient({ pat: 'pat', fetch: async () => new Response('{"data":[],"has_more":false}') }); await client.models.list(); try { await client.realtime.connect({conversationId:'conv'}); realProcess.exit(2); } catch (error) { if (!error.message.includes('requires Node.js')) realProcess.exit(3); }`], { cwd: new URL('..', import.meta.url), encoding: 'utf8' }); + assert.equal(execution.status, 0, execution.stderr); +}); +test('Realtime exports in CJS and ESM and Forward subpath; Managed has no Realtime resource', async () => { + const esm = await import('../dist/esm/index.js'); const forward = await import('../dist/esm/forward/index.js'); + for (const name of ['Realtime', 'RealtimeConnection', 'RealtimeConversations', 'RealtimeHistoryPage', 'decodeRealtimeAudio', 'REALTIME_VERSION']) { assert.ok(sdk[name]); assert.ok(esm[name]); assert.ok(forward[name]); } + assert.equal('realtime' in new sdk.ManagedClient({ pat: 'pat' }), false); +}); + +test('ready graceful-close capability false is preserved and rejects business close', async t => { + const host = await server(t, (ws, emit) => emit('voice.ready', { ...fixture.ready, capabilities: { graceful_close: false } })); + const connection = await connect(host.client); t.after(() => connection.close()); + await assert.rejects(connection.closeGracefully(), /does not support/); +}); +test('shared credential resolution can be canceled without disrupting another HTTP request', async t => { + const host = await server(t, ready); + let release; const shared = new Promise(resolve => { release = resolve; }); + const client = new ForwardClient({ credential: { getToken: () => shared }, baseURL: host.baseURL, fetch: async () => new Response('{"data":[],"has_more":false}') }); + const controller = new AbortController(); + const websocket = client.realtime.connect({ conversationId: 'conv_test', signal: controller.signal }); + const httpRequest = client.models.list(); + controller.abort(); await assert.rejects(websocket, sdk.APIUserAbortError); + release('sat-after-refresh'); assert.deepEqual((await httpRequest).data, []); + assert.equal(host.requests.length, 0); +}); +test('handshake deadline includes slow credential resolution and does not make a late connection', async t => { + const host = await server(t, ready); + const client = new ForwardClient({ credential: { getToken: async () => { await delay(40); return 'late-token'; } }, baseURL: host.baseURL }); + await assert.rejects(client.realtime.connect({ conversationId: 'conv_test', handshakeTimeout: 10 }), sdk.APIConnectionTimeoutError); + await delay(45); assert.equal(host.requests.length, 0); +}); +test('socket handshake timeout is a timeout error and releases pending socket', async t => { + const raw = http.createServer(); const pendingSockets = new Set(); raw.on('upgrade', (_req, socket) => { pendingSockets.add(socket); }); + raw.listen(0, '127.0.0.1'); await once(raw, 'listening'); t.after(async () => { for (const socket of pendingSockets) socket.destroy(); await new Promise(resolve => raw.close(resolve)); }); + const client = new ForwardClient({ pat: 'pat', baseURL: `http://127.0.0.1:${raw.address().port}/api/v1/forward` }); + await assert.rejects(client.realtime.connect({ conversationId: 'conv', handshakeTimeout: 15 }), sdk.APIConnectionTimeoutError); +}); +test('receive frame size and byte queue budgets are bounded independently', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => emit('future.large', { huge: 'x'.repeat(2000) })); }); + const connection = await host.client.realtime.connect({ conversationId: 'conv_test', maxReceiveBytes: 1024 }); await consumeReady(connection); await connection.ping(); + await assert.rejects(connection.receive(), sdk.APIConnectionError); + const second = await server(t, ready); + await assert.rejects(second.client.realtime.connect({ conversationId: 'conv_test', maxQueueBytes: 32 }), /queue capacity/); +}); +test('duplicate connection-local sequence is rejected', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => emit('pong', {}, { sequence: 1 })); }); + const connection = await connect(host.client); await consumeReady(connection); await connection.ping(); + await assert.rejects(connection.receive(), /sequence did not increase/); +}); +test('bounded send queue rejects overflow before writing or dropping input', async t => { + const host = await server(t, ready); const connection = await host.client.realtime.connect({ conversationId: 'conv_test', maxQueuedEvents: 1 }); t.after(() => connection.close()); await consumeReady(connection); + const first = connection.sendText('first'); assert.throws(() => connection.sendText('second'), /send queue capacity/); await first; + await delay(5); assert.deepEqual(host.messages.map(message => message.value.payload.text), ['first']); +}); +test('breaking event iteration leaves the bidirectional connection open for graceful close', async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', data => { + const event = JSON.parse(data); if (event.type === 'connection.close') emit('connection.closed', { request_id: event.payload.request_id, outcome: 'no_content' }); + }); }); + const connection = await connect(host.client); t.after(() => connection.close()); + for await (const event of connection) { assert.equal(event.type, 'voice.ready'); break; } + await connection.ping(); assert.equal((await connection.closeGracefully()).outcome, 'no_content'); +}); + +test('explicit CN/Global Realtime runner rejects missing inputs before resource creation', async () => { + const { validateRealtimeEnvironment } = await import('../scripts/run-realtime-live.mjs'); + const configured = { QODER_REALTIME_LIVE_ALLOW_WRITE: 'true', QODER_REALTIME_LIVE_ALLOW_EXECUTION: 'true', QODER_REALTIME_CN_SA_TEMPLATE_ID: 'cn_tmpl', QODER_REALTIME_GLOBAL_SA_TEMPLATE_ID: 'global_tmpl', QODER_SA_CN_KEY: 'cn_key', QODER_SA_GLOBAL_KEY: 'global_key' }; + assert.doesNotThrow(() => validateRealtimeEnvironment(configured, 'sa', 'basic')); + assert.throws(() => validateRealtimeEnvironment({ ...configured, QODER_SA_GLOBAL_KEY: '' }, 'sa', 'basic'), /GLOBAL_SAT or QODER_SA_GLOBAL_KEY/); + assert.doesNotThrow(() => validateRealtimeEnvironment({ ...configured, QODER_SA_CN_KEY: '', QODER_SA_GLOBAL_KEY: '', QODER_REALTIME_CN_SAT: 'cn_bound_sat', QODER_REALTIME_GLOBAL_SAT: 'global_bound_sat' }, 'sa', 'basic')); + assert.throws(() => validateRealtimeEnvironment({ ...configured, QODER_REALTIME_LIVE_ALLOW_EXECUTION: 'false' }, 'sa', 'basic'), /ALLOW_EXECUTION/); + assert.throws(() => validateRealtimeEnvironment(configured, 'sa', 'pcm'), /PCM_FILE/); + assert.throws(() => validateRealtimeEnvironment(configured, 'sa', 'work'), /WORK_PROMPT/); +}); +test('live diagnostics locate assertions without logging values, bodies or credentials', async () => { + const { safeRealtimeFailure, safeRealtimeEvent } = await import('./realtime/diagnostics.mjs'); + let error; + try { assert.equal('secret-token-actual', 'secret-token-expected'); } catch (caught) { error = caught; } + const output = safeRealtimeFailure(error, 'history.filter.work'); + assert.equal(output.includes('secret-token'), false); + assert.deepEqual(JSON.parse(output), { phase: 'history.filter.work', error_type: 'AssertionError', code: 'ERR_ASSERTION', assertion: { operator: 'strictEqual', expected_type: 'string', actual_type: 'string' } }); + try { assert.equal(false, true); } catch (caught) { error = caught; } + assert.equal(JSON.parse(safeRealtimeFailure(error, 'history.unique_event_id')).assertion.expected, true); + assert.deepEqual(safeRealtimeEvent({ type: 'voice.work.completed', id: 'event_123', work_id: 'work_123', payload: { access_token: 'secret-token' } }), { event_type: 'voice.work.completed', event_id: 'event_123', work_id: 'work_123', announcement_id: undefined }); +}); + +for (const invalid of [ + { type: 'pong', payload: {}, extra: { timestamp: 'bad' } }, + { type: 'pong', payload: {}, extra: { timestamp: '2026-02-30T00:00:00Z' } }, + { type: 'work.progress', payload: { kind: 'tool', detail: 'detail', is_error: 'false' } }, + { type: 'error', payload: { code: 'work_busy', message: 'busy', retryable: 'true' } }, + { type: 'error', payload: { code: 'work_busy', message: 'busy', retry_after_ms: 1.5 } }, + { type: 'transcript.delta', payload: { role: 'assistant', text: 'text', item_id: 123 } }, + { type: 'audio.delta', payload: { audio: '' } }, + { type: 'audio.delta', payload: { audio: 'AQ==' } }, + { type: 'work.completed', payload: { status: 'failed', result: 'text' } }, +]) test(`known event rejects invalid typed field: ${JSON.stringify(invalid)}`, async t => { + const host = await server(t, (ws, emit) => { ready(ws, emit); ws.on('message', () => emit(invalid.type, invalid.payload, invalid.extra)); }); + const connection = await connect(host.client); await consumeReady(connection); await connection.ping(); + await assert.rejects(connection.receive(), sdk.RealtimeProtocolError); +}); +test('audio decode rejects empty/odd samples and SendText trims server-style whitespace', async t => { + assert.throws(() => sdk.decodeRealtimeAudio({ type: 'audio.delta', payload: { audio: '' } })); + assert.throws(() => sdk.decodeRealtimeAudio({ type: 'audio.delta', payload: { audio: 'AQ==' } })); + const host = await server(t, ready); const connection = await connect(host.client); t.after(() => connection.close()); + await connection.sendText(' trim me '); await delay(5); assert.equal(host.messages[0].value.payload.text, 'trim me'); +}); diff --git a/tests/realtime/diagnostics.mjs b/tests/realtime/diagnostics.mjs new file mode 100644 index 0000000..c71d958 --- /dev/null +++ b/tests/realtime/diagnostics.mjs @@ -0,0 +1,22 @@ +const safeID = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,256}$/.test(value) ? value : undefined; +const typeOf = value => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; +const safeExpected = value => typeof value === 'boolean' || typeof value === 'number' || value === null + || ['string', 'boolean', 'number', 'undefined', 'object'].includes(value) ? value : undefined; + +export function safeRealtimeFailure(error, phase) { + const assertion = error?.code === 'ERR_ASSERTION' ? { + operator: ['==', '!=', '===', '!==', 'strictEqual', 'notStrictEqual', 'deepStrictEqual', 'notDeepStrictEqual', 'ok', 'match'].includes(error.operator) ? error.operator : undefined, + expected: safeExpected(error.expected), + expected_type: typeOf(error.expected), + actual_type: typeOf(error.actual), + } : undefined; + return JSON.stringify({ phase, error_type: /^[A-Za-z]+Error$/.test(error?.name ?? '') ? error.name : 'Error', + status: Number.isInteger(error?.status) ? error.status : undefined, + code: /^[A-Za-z0-9_-]{1,100}$/.test(error?.code ?? '') ? error.code : undefined, + request_id: safeID(error?.request_id), assertion }); +} + +export function safeRealtimeEvent(event) { + return { event_type: typeof event.type === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(event.type) ? event.type : undefined, + event_id: safeID(event.event_id ?? event.id), work_id: safeID(event.work_id), announcement_id: safeID(event.announcement_id) }; +} diff --git a/tests/realtime/live.mjs b/tests/realtime/live.mjs new file mode 100644 index 0000000..ebffa70 --- /dev/null +++ b/tests/realtime/live.mjs @@ -0,0 +1,121 @@ +// Explicit opt-in: no ordinary test or PAT suite imports this module. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ForwardClient, ServiceAccountCredential, decodeRealtimeAudio } from '../../dist/index.js'; +import { validateRealtimeEnvironment } from '../../scripts/run-realtime-live.mjs'; +import { safeRealtimeFailure, safeRealtimeEvent } from './diagnostics.mjs'; +const selectedAuth = process.env.QCA_REALTIME_TEST_AUTH_MODE ?? 'all'; +const scenario = process.env.QCA_REALTIME_TEST_SCENARIO ?? 'basic'; +validateRealtimeEnvironment(process.env, selectedAuth, scenario); +for (const authMode of selectedAuth === 'all' ? ['pat', 'sa'] : [selectedAuth]) for (const region of ['cn', 'global']) test(`Realtime ${region} ${authMode} ${scenario}`, { timeout: 240_000 }, async t => { + const suffix = region.toUpperCase(); + const baseURL = `https://${region === 'cn' ? 'api.qoder.com.cn' : 'api.qoder.com'}/api/v1/forward`; + let phase = 'authentication'; + const accessToken = authMode === 'sa' ? process.env[`QODER_REALTIME_${suffix}_SAT`]?.trim() || undefined : undefined; + const credential = authMode === 'sa' && !accessToken ? new ServiceAccountCredential({ saKey: process.env[`QODER_SA_${suffix}_KEY`], region, scopes: ['qca.access', 'forward.access'] }) : undefined; + const authentication = authMode === 'sa' ? (accessToken ? { authMode: 'sa', accessToken } : { credential }) : { pat: process.env[`QODER_FORWARD_${suffix}_PAT`] }; + const client = new ForwardClient({ baseURL, maxRetries: 0, timeout: 30_000, ...authentication, fetch: async (input, init) => { + const response = await fetch(input, init); + const url = new URL(input instanceof Request ? input.url : String(input)); + t.diagnostic(JSON.stringify({ phase, method: init?.method ?? 'GET', path: url.pathname, status: response.status, request_id: response.headers.get('x-request-id') })); + return response; + } }); + let connection; + let conversation; + let identity; + const failures = []; + try { + phase = 'identity.create'; + identity = await client.identities.create({ external_id: `sdk-realtime-${authMode}-${region}-${randomUUID()}`, metadata: { source: 'sdk-realtime-live' } }); + t.diagnostic(JSON.stringify({ region, auth_mode: authMode, resource_id: identity.id })); + phase = 'conversation.create'; + conversation = await client.realtime.conversations.create({ identity_id: identity.id, template_id: process.env[`QODER_REALTIME_${suffix}_${authMode.toUpperCase()}_TEMPLATE_ID`], idempotency_key: randomUUID(), metadata: { source: 'sdk-ts-realtime-verification', region, scenario } }); + t.diagnostic(JSON.stringify({ region, conversation_id: conversation.id, request_id: conversation._request_id, retention: 'persisted; no public delete API' })); + phase = 'connection.ready'; + connection = await client.realtime.connect({ conversationId: conversation.id, signal: AbortSignal.timeout(210_000) }); + assert.equal(connection.ready.input_audio.sample_rate, 16000); assert.equal(connection.ready.output_audio.sample_rate, 24000); + t.diagnostic(JSON.stringify({ region, handshake_request_id: connection.request_id })); + phase = 'connection.ping'; + await connection.ping(); + for (;;) { const item = await connection.receive({ timeout: 30_000 }); assert.equal(item.done, false); if (item.value.type === 'pong') break; } + if (scenario !== 'basic') { + const receiving = (async () => { + let audio = false; + let accepted = false; + let assistantText = false; + let audioEnded = false; + let inputTranscript = false; + let receipt = {}; + for (;;) { + phase = 'scenario.receive'; + const item = await connection.receive({ timeout: 120_000 }); assert.equal(item.done, false, 'closed before scenario completion'); + const event = item.value; + const diagnostic = safeRealtimeEvent(event); + phase = diagnostic.event_type ? `scenario.${diagnostic.event_type}` : 'scenario.unknown_event'; + if (!['audio.delta', 'transcript.delta'].includes(event.type)) t.diagnostic(JSON.stringify({ phase, ...diagnostic })); + if (event.type === 'error' || event.type === 'work.failed') throw new Error(`Realtime scenario failed: ${event.type}`); + if (event.type === 'audio.delta') { assert.ok(decodeRealtimeAudio(event).byteLength > 0); audio = true; receipt = { ...(event.work_id ? { work_id: event.work_id } : {}), ...(event.announcement_id ? { announcement_id: event.announcement_id } : {}) }; } + if (event.type === 'audio.done') { await connection.playbackCancelled(receipt); audioEnded = true; } + if (event.type === 'transcript.final' && event.payload.role === 'user') { assert.equal(typeof event.payload.text, 'string'); inputTranscript = Boolean(event.payload.text.length); } + if (['text', 'pcm'].includes(scenario) && event.type === 'transcript.final' && event.payload.role === 'assistant') { assert.equal(typeof event.payload.text, 'string'); assert.ok(event.payload.text.length); assistantText = true; } + if ((scenario === 'text' || (scenario === 'pcm' && inputTranscript)) && assistantText && audioEnded) { assert.ok(audio, 'no output audio observed'); return; } + if (event.type === 'work.accepted') { accepted = true; assert.ok(event.work_id); } + if (scenario === 'work' && event.type === 'work.completed') { assert.ok(accepted, 'completion without acceptance'); assert.ok(event.work_id); return; } + } + })(); + void receiving.catch(() => {}); + phase = 'scenario.input'; + if (scenario === 'pcm') { + const pcm = await readFile(process.env.QODER_REALTIME_PCM_FILE); + assert.ok(pcm.length > 0 && pcm.length % 2 === 0 && pcm.length <= 16_000 * 2 * 30, 'input must be complete PCM16 samples, at most 30 seconds'); + assert.notEqual(pcm.toString('ascii', 0, 4), 'RIFF', 'decode WAV before using the fixture'); + for (let offset = 0; offset < pcm.length; offset += 640) { await connection.appendAudio(pcm.subarray(offset, offset + 640)); await delay(20); } + } else await connection.sendText(scenario === 'work' ? process.env.QODER_REALTIME_WORK_PROMPT : '请简短介绍你能提供的帮助。'); + await receiving; + } + phase = 'connection.close_gracefully'; + assert.ok(['no_active_response', 'no_content', 'saved', 'saved_interrupted'].includes((await connection.closeGracefully()).outcome)); connection.close(); + phase = 'history.initial'; + const history = await client.realtime.conversations.history(conversation.id, { limit: 1, types: scenario === 'work' ? 'work' : 'message,work' }); + phase = 'history.conversation_id'; assert.equal(history.conversation.id, conversation.id); + phase = 'history.events_array'; assert.ok(Array.isArray(history.events)); + if (scenario !== 'basic') { + phase = 'history.collect'; + const events = []; for await (const event of history) { t.diagnostic(JSON.stringify({ phase, filter: scenario === 'work' ? 'work' : 'message,work', ...safeRealtimeEvent(event) })); events.push(event); } + phase = 'history.nonempty'; + assert.ok(events.length > 0, 'executed interaction missing persisted history'); + const ids = new Set(); for (const event of events) { + t.diagnostic(JSON.stringify({ phase: 'history.validate_event', ...safeRealtimeEvent(event) })); + phase = 'history.event_id_type'; assert.equal(typeof event.id, 'string'); + phase = 'history.occurred_at_type'; assert.equal(typeof event.occurred_at, 'string'); + phase = 'history.unique_event_id'; assert.ok(!ids.has(event.id), 'duplicate history ID'); ids.add(event.id); + } + for (const types of ['message', 'work']) { + phase = `history.filter.${types}`; + const filtered = await client.realtime.conversations.history(conversation.id, { limit: 1, types }); + for await (const event of filtered) { t.diagnostic(JSON.stringify({ phase, filter: types, ...safeRealtimeEvent(event) })); assert.equal(types === 'work' ? event.type.startsWith('voice.work.') : event.type.includes('_message.'), true, 'history source filter mismatch'); } + } + } + if (authMode === 'sa') { + phase = 'connection.direct_sat_reconnect'; + // Explicitly verify direct SAT plus a new connection after normal transport cleanup. + const direct = new ForwardClient({ baseURL, authMode: 'sa', accessToken: accessToken ?? await credential.getToken(), maxRetries: 0 }); + connection = await direct.realtime.connect({ conversationId: conversation.id, signal: AbortSignal.timeout(90_000) }); + await connection.ping(); + for (;;) { const item = await connection.receive({ timeout: 30_000 }); assert.equal(item.done, false); if (item.value.type === 'pong') break; } + await connection.closeGracefully(); + } + } catch (error) { failures.push({ error, phase }); } + finally { + connection?.close(); + if (identity) { + phase = 'identity.cleanup'; + try { await client.identities.delete(identity.id, { timeout: 30_000 }); t.diagnostic(JSON.stringify({ region, resource_id: identity.id, cleanup: 'deleted' })); } + catch (error) { failures.push({ error, phase }); } + } + } + if (failures.length) throw new Error(failures.map(({ error, phase: failedPhase }) => safeRealtimeFailure(error, failedPhase)).join('; ')); +}); diff --git a/tests/types-smoke.ts b/tests/types-smoke.ts index 809827c..ffbcf64 100644 --- a/tests/types-smoke.ts +++ b/tests/types-smoke.ts @@ -170,3 +170,31 @@ new ManagedClient({ credential: saCredential, authMode: 'sa' }); new ForwardClient({ authMode: 'sa', accessToken: async () => 'sat' }).withOptions({ authMode: 'pat', pat: 'pat' }); new Forward({ credential: new ForwardSACredential({ saKey: 'test', region: 'global', scopes: ['forward.access'] }) }); new Managed({ credential: new ManagedSACredential({ saKey: 'test', region: 'global', scopes: ['qca.access'] }) }); + +// Forward Realtime Beta; HTTP and Node WebSocket APIs. +void forward.realtime.conversations.create({ identity_id: 'idn', template_id: 'tmpl', idempotency_key: 'operation', config: { audio: { output: { voice: 'longanqian' } } } }); +void forward.realtime.conversations.history('conv', { limit: 10, types: 'message,work' }).then(async page => { + const hasMore: boolean = page.hasNextPage(); void hasMore; + if (page.hasNextPage()) await page.getNextPage(); + for await (const event of page) { const historyID: string = event.id; void historyID; } +}); +void forward.realtime.connect({ conversationId: 'conv', readyTimeout: 1000, signal: new AbortController().signal }).then(async connection => { + await connection.sendText('text'); await connection.appendAudio(new Uint8Array([1, 0])); + await connection.playbackStarted({ work_id: 'work', announcement_id: 'announcement' }); + await connection.closeGracefully({ requestId: 'close-operation' }); connection.close(); +}); +// @ts-expect-error Realtime creation requires a stable idempotency key. +forward.realtime.conversations.create({ identity_id: 'idn', template_id: 'tmpl' }); +// @ts-expect-error History does not use ordinary data/next_page pagination. +forward.realtime.conversations.history('conv', { next_page: 'cursor' }); +// @ts-expect-error Managed does not expose Forward Realtime. +managed.realtime; +// @ts-expect-error Template Realtime management is outside this feature. +forward.realtime.templates; + +import { isRealtimeEvent, type RealtimeServerEvent } from 'qca-sdk/forward'; +function typedRealtimeEvent(event: RealtimeServerEvent): void { + if (isRealtimeEvent(event, 'transcript.delta')) { const text: string = event.payload.text; void text; } + if (isRealtimeEvent(event, 'voice.ready')) { const rate: number = event.payload.input_audio.sample_rate; void rate; } +} +void typedRealtimeEvent;