ai 7.0.100 → 7.0.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/index.d.ts +82 -20
- package/dist/index.js +2017 -330
- package/dist/index.js.map +1 -1
- package/dist/internal/index.js +12 -5
- package/dist/internal/index.js.map +1 -1
- package/docs/03-agents/07-workflow-agent.mdx +1 -1
- package/docs/03-ai-sdk-core/36-realtime.mdx +41 -18
- package/docs/04-ai-sdk-ui/21-transport.mdx +1 -1
- package/docs/07-reference/02-ai-sdk-ui/05-use-realtime.mdx +364 -48
- package/docs/07-reference/04-ai-sdk-workflow/02-workflow-chat-transport.mdx +4 -4
- package/docs/07-reference/05-ai-sdk-errors/index.mdx +131 -36
- package/package.json +12 -12
- package/src/generate-text/execute-tools-from-stream.ts +7 -0
- package/src/generate-text/stream-text.ts +3 -1
- package/src/prompt/create-tool-model-output.ts +16 -5
- package/src/realtime/__fixtures__/fake-live-websocket.ts +71 -0
- package/src/realtime/__fixtures__/fake-realtime.ts +36 -0
- package/src/realtime/__fixtures__/fake-webrtc.ts +133 -0
- package/src/realtime/browser-realtime-audio.ts +107 -10
- package/src/realtime/browser-realtime-live-websocket.ts +247 -0
- package/src/realtime/browser-realtime-transport.ts +235 -69
- package/src/realtime/browser-realtime-webrtc.ts +582 -0
- package/src/realtime/encode-realtime-frame.ts +33 -0
- package/src/realtime/index.ts +1 -0
- package/src/realtime/realtime-attempt.ts +45 -0
- package/src/realtime/realtime-command-tracker.ts +81 -0
- package/src/realtime/realtime-event-channel.ts +170 -0
- package/src/realtime/realtime-event-reducer.ts +3 -0
- package/src/realtime/realtime-session-state.ts +65 -0
- package/src/realtime/realtime-session.ts +768 -218
- package/src/realtime/realtime-types.ts +1 -1
- package/src/realtime/validate-realtime-setup.ts +37 -0
|
@@ -1,43 +1,138 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: AI SDK Errors
|
|
3
|
-
description:
|
|
3
|
+
description: Reference for AI SDK error classes and typed error handling.
|
|
4
4
|
collapsed: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# AI SDK Errors
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
9
|
+
The AI SDK exposes typed errors so applications can handle expected failure
|
|
10
|
+
modes without matching error message strings.
|
|
11
|
+
|
|
12
|
+
## Importing Errors
|
|
13
|
+
|
|
14
|
+
The application package is named `ai`, not `@ai-sdk/ai`. It re-exports common
|
|
15
|
+
provider-level errors from `@ai-sdk/provider` together with the higher-level
|
|
16
|
+
errors raised by AI SDK Core:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { APICallError, NoObjectGeneratedError } from 'ai';
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Provider implementations that depend directly on `@ai-sdk/provider` can import
|
|
23
|
+
its provider-level errors from that package instead:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { APICallError, InvalidResponseDataError } from '@ai-sdk/provider';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Migrating to Typed Error Handling
|
|
30
|
+
|
|
31
|
+
Replace message matching or an unconditionally generic `catch` block with the
|
|
32
|
+
most specific static `isInstance` guard available. Check `AISDKError` last when
|
|
33
|
+
you need a fallback for any AI SDK error:
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { AISDKError, APICallError, generateText } from 'ai';
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await generateText({
|
|
40
|
+
model,
|
|
41
|
+
prompt: 'Write a vegetarian lasagna recipe for 4 people.',
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (APICallError.isInstance(error)) {
|
|
45
|
+
console.error('Provider request failed:', error.statusCode);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (AISDKError.isInstance(error)) {
|
|
50
|
+
console.error('AI SDK error:', error.name);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Prefer `ErrorClass.isInstance(error)` over `error instanceof ErrorClass` when
|
|
59
|
+
the class provides it. The static guard also works when multiple AI SDK package
|
|
60
|
+
versions are loaded.
|
|
61
|
+
|
|
62
|
+
## Common Failure Modes
|
|
63
|
+
|
|
64
|
+
| Failure mode | Error to check |
|
|
65
|
+
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| A provider request fails because of a network error or non-success response | [`APICallError`](/docs/reference/ai-sdk-errors/ai-api-call-error) |
|
|
67
|
+
| Automatic retries are exhausted | [`RetryError`](/docs/reference/ai-sdk-errors/ai-retry-error) |
|
|
68
|
+
| A provider reports an error after a response stream has started | [`StreamProviderError`](/docs/reference/ai-sdk-errors/ai-stream-provider-error) |
|
|
69
|
+
| A structured output cannot be parsed or validated | [`NoObjectGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-object-generated-error); inspect its `cause` for [`JSONParseError`](/docs/reference/ai-sdk-errors/ai-json-parse-error) or [`TypeValidationError`](/docs/reference/ai-sdk-errors/ai-type-validation-error) |
|
|
70
|
+
| A generation call returns no usable output | [`NoOutputGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-output-generated-error) or the modality-specific `No*GeneratedError` |
|
|
71
|
+
| A model calls a missing tool or supplies invalid tool input | [`NoSuchToolError`](/docs/reference/ai-sdk-errors/ai-no-such-tool-error) or [`InvalidToolInputError`](/docs/reference/ai-sdk-errors/ai-invalid-tool-input-error) |
|
|
72
|
+
| Tool-call repair fails | [`ToolCallRepairError`](/docs/reference/ai-sdk-errors/ai-tool-call-repair-error) |
|
|
73
|
+
| A response violates an enforced `toolChoice` | [`ToolChoiceViolationError`](/docs/reference/ai-sdk-errors/ai-tool-choice-violation-error) |
|
|
74
|
+
| Message history contains unresolved tool calls or invalid approvals | [`MissingToolResultsError`](/docs/troubleshooting/missing-tool-results-error), [`InvalidToolApprovalError`](/docs/reference/ai-sdk-errors/ai-invalid-tool-approval-error), or [`InvalidToolApprovalSignatureError`](/docs/reference/ai-sdk-errors/ai-invalid-tool-approval-signature-error) |
|
|
75
|
+
| A provider or model cannot be resolved | [`NoSuchProviderError`](/docs/reference/ai-sdk-errors/ai-no-such-provider-error), [`NoSuchModelError`](/docs/reference/ai-sdk-errors/ai-no-such-model-error), or [`NoSuchProviderReferenceError`](/docs/reference/ai-sdk-errors/ai-no-such-provider-reference-error) |
|
|
76
|
+
| A provider response is empty, malformed, or invalid | [`EmptyResponseBodyError`](/docs/reference/ai-sdk-errors/ai-empty-response-body-error), [`JSONParseError`](/docs/reference/ai-sdk-errors/ai-json-parse-error), or [`InvalidResponseDataError`](/docs/reference/ai-sdk-errors/ai-invalid-response-data-error) |
|
|
77
|
+
| The requested model or provider does not support a capability or specification version | [`UnsupportedFunctionalityError`](/docs/reference/ai-sdk-errors/ai-unsupported-functionality-error) or [`UnsupportedModelVersionError`](/docs/troubleshooting/unsupported-model-version) |
|
|
78
|
+
| UI messages cannot be converted or a UI message stream is invalid | [`MessageConversionError`](/docs/reference/ai-sdk-errors/ai-message-conversion-error) or [`UIMessageStreamError`](/docs/reference/ai-sdk-errors/ai-ui-message-stream-error) |
|
|
79
|
+
|
|
80
|
+
## Error Reference
|
|
81
|
+
|
|
82
|
+
### Base Error
|
|
83
|
+
|
|
84
|
+
- `AISDKError`: Base class and broad type guard for AI SDK errors.
|
|
85
|
+
|
|
86
|
+
### Provider Requests and Responses
|
|
87
|
+
|
|
88
|
+
- [`APICallError`](/docs/reference/ai-sdk-errors/ai-api-call-error)
|
|
89
|
+
- [`DownloadError`](/docs/reference/ai-sdk-errors/ai-download-error)
|
|
90
|
+
- [`EmptyResponseBodyError`](/docs/reference/ai-sdk-errors/ai-empty-response-body-error)
|
|
91
|
+
- [`InvalidPromptError`](/docs/reference/ai-sdk-errors/ai-invalid-prompt-error)
|
|
92
|
+
- [`InvalidResponseDataError`](/docs/reference/ai-sdk-errors/ai-invalid-response-data-error)
|
|
93
|
+
- [`JSONParseError`](/docs/reference/ai-sdk-errors/ai-json-parse-error)
|
|
94
|
+
- [`LoadAPIKeyError`](/docs/reference/ai-sdk-errors/ai-load-api-key-error)
|
|
95
|
+
- [`LoadSettingError`](/docs/reference/ai-sdk-errors/ai-load-setting-error)
|
|
96
|
+
- [`NoContentGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-content-generated-error)
|
|
97
|
+
- [`NoSuchModelError`](/docs/reference/ai-sdk-errors/ai-no-such-model-error)
|
|
98
|
+
- [`NoSuchProviderReferenceError`](/docs/reference/ai-sdk-errors/ai-no-such-provider-reference-error)
|
|
99
|
+
- [`RetryError`](/docs/reference/ai-sdk-errors/ai-retry-error)
|
|
100
|
+
- [`StreamProviderError`](/docs/reference/ai-sdk-errors/ai-stream-provider-error)
|
|
101
|
+
- [`TooManyEmbeddingValuesForCallError`](/docs/reference/ai-sdk-errors/ai-too-many-embedding-values-for-call-error)
|
|
102
|
+
- [`TypeValidationError`](/docs/reference/ai-sdk-errors/ai-type-validation-error)
|
|
103
|
+
- [`UnsupportedFunctionalityError`](/docs/reference/ai-sdk-errors/ai-unsupported-functionality-error)
|
|
104
|
+
|
|
105
|
+
### Inputs and Message Streams
|
|
106
|
+
|
|
107
|
+
- [`InvalidArgumentError`](/docs/reference/ai-sdk-errors/ai-invalid-argument-error)
|
|
108
|
+
- [`InvalidDataContentError`](/docs/reference/ai-sdk-errors/ai-invalid-data-content-error)
|
|
109
|
+
- [`InvalidMessageRoleError`](/docs/reference/ai-sdk-errors/ai-invalid-message-role-error)
|
|
110
|
+
- `InvalidStreamPartError`
|
|
111
|
+
- [`MessageConversionError`](/docs/reference/ai-sdk-errors/ai-message-conversion-error)
|
|
112
|
+
- [`UIMessageStreamError`](/docs/reference/ai-sdk-errors/ai-ui-message-stream-error)
|
|
113
|
+
|
|
114
|
+
### Generated Output
|
|
115
|
+
|
|
116
|
+
- [`NoImageGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-image-generated-error)
|
|
117
|
+
- [`NoObjectGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-object-generated-error)
|
|
118
|
+
- [`NoOutputGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-output-generated-error)
|
|
119
|
+
- [`NoSpeechGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-speech-generated-error)
|
|
120
|
+
- [`NoTranscriptGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-transcript-generated-error)
|
|
121
|
+
- [`NoTranslationGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-translation-generated-error)
|
|
122
|
+
- [`NoVideoGeneratedError`](/docs/reference/ai-sdk-errors/ai-no-video-generated-error)
|
|
123
|
+
|
|
124
|
+
### Providers and Model Compatibility
|
|
125
|
+
|
|
126
|
+
- [`NoSuchProviderError`](/docs/reference/ai-sdk-errors/ai-no-such-provider-error)
|
|
127
|
+
- [`UnsupportedModelVersionError`](/docs/troubleshooting/unsupported-model-version)
|
|
128
|
+
|
|
129
|
+
### Tools and Approvals
|
|
130
|
+
|
|
131
|
+
- [`InvalidToolApprovalError`](/docs/reference/ai-sdk-errors/ai-invalid-tool-approval-error)
|
|
132
|
+
- [`InvalidToolApprovalSignatureError`](/docs/reference/ai-sdk-errors/ai-invalid-tool-approval-signature-error)
|
|
133
|
+
- [`InvalidToolInputError`](/docs/reference/ai-sdk-errors/ai-invalid-tool-input-error)
|
|
134
|
+
- [`MissingToolResultsError`](/docs/troubleshooting/missing-tool-results-error)
|
|
135
|
+
- [`NoSuchToolError`](/docs/reference/ai-sdk-errors/ai-no-such-tool-error)
|
|
136
|
+
- [`ToolCallNotFoundForApprovalError`](/docs/reference/ai-sdk-errors/ai-tool-call-not-found-for-approval-error)
|
|
137
|
+
- [`ToolCallRepairError`](/docs/reference/ai-sdk-errors/ai-tool-call-repair-error)
|
|
138
|
+
- [`ToolChoiceViolationError`](/docs/reference/ai-sdk-errors/ai-tool-choice-violation-error)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.102",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -42,20 +42,20 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@ai-sdk/gateway": "4.0.
|
|
46
|
-
"@ai-sdk/provider": "4.0.
|
|
47
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
45
|
+
"@ai-sdk/gateway": "4.0.82",
|
|
46
|
+
"@ai-sdk/provider": "4.0.15",
|
|
47
|
+
"@ai-sdk/provider-utils": "5.0.41"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@ai-sdk/amazon-bedrock": "5.0.
|
|
51
|
-
"@ai-sdk/deepseek": "3.0.
|
|
52
|
-
"@ai-sdk/google": "4.0.
|
|
53
|
-
"@ai-sdk/groq": "4.0.
|
|
54
|
-
"@ai-sdk/huggingface": "2.0.
|
|
55
|
-
"@ai-sdk/moonshotai": "3.0.
|
|
56
|
-
"@ai-sdk/openai": "4.0.
|
|
50
|
+
"@ai-sdk/amazon-bedrock": "5.0.83",
|
|
51
|
+
"@ai-sdk/deepseek": "3.0.45",
|
|
52
|
+
"@ai-sdk/google": "4.0.71",
|
|
53
|
+
"@ai-sdk/groq": "4.0.42",
|
|
54
|
+
"@ai-sdk/huggingface": "2.0.49",
|
|
55
|
+
"@ai-sdk/moonshotai": "3.0.50",
|
|
56
|
+
"@ai-sdk/openai": "4.0.67",
|
|
57
57
|
"@ai-sdk/test-server": "2.0.1",
|
|
58
|
-
"@ai-sdk/xai": "4.0.
|
|
58
|
+
"@ai-sdk/xai": "4.0.59",
|
|
59
59
|
"@edge-runtime/vm": "^5.0.0",
|
|
60
60
|
"@smithy/eventstream-codec": "^4.3.3",
|
|
61
61
|
"@smithy/util-utf8": "^4.3.3",
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
OnToolExecutionEndCallback,
|
|
26
26
|
OnToolExecutionStartCallback,
|
|
27
27
|
} from './tool-execution-events';
|
|
28
|
+
import type { StaticToolOutputDenied } from './tool-output-denied';
|
|
28
29
|
|
|
29
30
|
export type ToolExecutionEndStreamPart = {
|
|
30
31
|
type: 'tool-execution-end';
|
|
@@ -34,6 +35,7 @@ export type ToolExecutionEndStreamPart = {
|
|
|
34
35
|
|
|
35
36
|
export type ExecuteToolsStreamPart<TOOLS extends ToolSet = ToolSet> =
|
|
36
37
|
| LanguageModelStreamPart<TOOLS>
|
|
38
|
+
| StaticToolOutputDenied<TOOLS>
|
|
37
39
|
| ToolExecutionEndStreamPart
|
|
38
40
|
| StreamRetryAttemptBoundaryPart;
|
|
39
41
|
|
|
@@ -180,6 +182,11 @@ export function executeToolsFromStream<
|
|
|
180
182
|
reason: toolApprovalStatus.reason,
|
|
181
183
|
providerExecuted: chunk.providerExecuted,
|
|
182
184
|
});
|
|
185
|
+
controller.enqueue({
|
|
186
|
+
type: 'tool-output-denied',
|
|
187
|
+
toolCallId: chunk.toolCallId,
|
|
188
|
+
toolName: chunk.toolName,
|
|
189
|
+
} as StaticToolOutputDenied<TOOLS>);
|
|
183
190
|
|
|
184
191
|
return; // don't execute tool
|
|
185
192
|
}
|
|
@@ -196,6 +196,7 @@ const isOutputChunkType = {
|
|
|
196
196
|
'tool-call': true,
|
|
197
197
|
'tool-result': false,
|
|
198
198
|
'tool-error': false,
|
|
199
|
+
'tool-output-denied': false,
|
|
199
200
|
'tool-execution-end': false,
|
|
200
201
|
'model-call-start': false,
|
|
201
202
|
'model-call-response-metadata': false,
|
|
@@ -2833,7 +2834,8 @@ class DefaultStreamTextResult<
|
|
|
2833
2834
|
case 'tool-input-start':
|
|
2834
2835
|
case 'tool-input-end':
|
|
2835
2836
|
case 'tool-input-delta':
|
|
2836
|
-
case 'tool-approval-request':
|
|
2837
|
+
case 'tool-approval-request':
|
|
2838
|
+
case 'tool-output-denied': {
|
|
2837
2839
|
enqueueStepPart(controller, chunk);
|
|
2838
2840
|
break;
|
|
2839
2841
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { getErrorMessage, type JSONValue } from '@ai-sdk/provider';
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
parseJSON,
|
|
4
|
+
type Tool,
|
|
5
|
+
type ToolResultOutput,
|
|
6
|
+
} from '@ai-sdk/provider-utils';
|
|
3
7
|
|
|
4
8
|
export async function createToolModelOutput({
|
|
5
9
|
toolCallId,
|
|
@@ -17,7 +21,7 @@ export async function createToolModelOutput({
|
|
|
17
21
|
if (errorMode === 'text') {
|
|
18
22
|
return { type: 'error-text', value: getErrorMessage(output) };
|
|
19
23
|
} else if (errorMode === 'json') {
|
|
20
|
-
return { type: 'error-json', value: toJSONValue(output) };
|
|
24
|
+
return { type: 'error-json', value: await toJSONValue(output) };
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
if (tool?.toModelOutput) {
|
|
@@ -26,9 +30,16 @@ export async function createToolModelOutput({
|
|
|
26
30
|
|
|
27
31
|
return typeof output === 'string'
|
|
28
32
|
? { type: 'text', value: output }
|
|
29
|
-
: { type: 'json', value: toJSONValue(output) };
|
|
33
|
+
: { type: 'json', value: await toJSONValue(output) };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
function toJSONValue(value: unknown): JSONValue {
|
|
33
|
-
|
|
36
|
+
async function toJSONValue(value: unknown): Promise<JSONValue> {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const serialized = JSON.stringify(value);
|
|
42
|
+
return serialized === undefined
|
|
43
|
+
? null
|
|
44
|
+
: await parseJSON({ text: serialized });
|
|
34
45
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { vi, type Mock } from 'vitest';
|
|
2
|
+
import type { RealtimeServerEvent } from '../../types/realtime-model';
|
|
3
|
+
import { fakeStream } from './fake-realtime';
|
|
4
|
+
|
|
5
|
+
export class FakeWebSocket {
|
|
6
|
+
static OPEN = 1;
|
|
7
|
+
static instances: FakeWebSocket[] = [];
|
|
8
|
+
readyState = 0;
|
|
9
|
+
bufferedAmount = 0;
|
|
10
|
+
onopen: (() => void) | null = null;
|
|
11
|
+
onclose: ((event: CloseEvent) => void) | null = null;
|
|
12
|
+
onerror: (() => void) | null = null;
|
|
13
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
14
|
+
sent: Array<{ type: string; [key: string]: unknown }> = [];
|
|
15
|
+
constructor(public url: string, public protocols?: string[]) { FakeWebSocket.instances.push(this); }
|
|
16
|
+
send = vi.fn((data: string) => { this.sent.push(JSON.parse(data)); });
|
|
17
|
+
close = vi.fn(() => { this.closeFromServer(); });
|
|
18
|
+
open() { this.readyState = 1; this.onopen?.(); }
|
|
19
|
+
emit(event: RealtimeServerEvent) { this.onmessage?.({ data: JSON.stringify(event) }); }
|
|
20
|
+
closeFromServer({ code = 1000, reason = '', wasClean = true }: Partial<Pick<CloseEvent, 'code' | 'reason' | 'wasClean'>> = {}) {
|
|
21
|
+
this.readyState = 3;
|
|
22
|
+
this.onclose?.({ code, reason, wasClean } as CloseEvent);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class FakeAudioNode {
|
|
27
|
+
connect: Mock = vi.fn();
|
|
28
|
+
disconnect: Mock = vi.fn();
|
|
29
|
+
onaudioprocess: ((event: { inputBuffer: { getChannelData: (channel: number) => Float32Array } }) => void) | null = null;
|
|
30
|
+
onended: (() => void) | null = null;
|
|
31
|
+
buffer: unknown;
|
|
32
|
+
start: Mock = vi.fn();
|
|
33
|
+
stop: Mock = vi.fn();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FakeAudioContext {
|
|
37
|
+
static instances: FakeAudioContext[] = [];
|
|
38
|
+
state = 'suspended';
|
|
39
|
+
currentTime = 0;
|
|
40
|
+
sampleRate: number;
|
|
41
|
+
destination = {};
|
|
42
|
+
processors: FakeAudioNode[] = [];
|
|
43
|
+
sources: FakeAudioNode[] = [];
|
|
44
|
+
mediaSources: FakeAudioNode[] = [];
|
|
45
|
+
constructor(options: { sampleRate: number }) { this.sampleRate = options.sampleRate; FakeAudioContext.instances.push(this); }
|
|
46
|
+
resume = vi.fn(async () => { this.state = 'running'; });
|
|
47
|
+
close = vi.fn(async () => { this.state = 'closed'; });
|
|
48
|
+
createScriptProcessor = vi.fn(() => {
|
|
49
|
+
const node = new FakeAudioNode(); this.processors.push(node); return node;
|
|
50
|
+
});
|
|
51
|
+
createMediaStreamSource = vi.fn(() => {
|
|
52
|
+
const node = new FakeAudioNode(); this.mediaSources.push(node); return node;
|
|
53
|
+
});
|
|
54
|
+
createBufferSource = vi.fn(() => {
|
|
55
|
+
const node = new FakeAudioNode(); this.sources.push(node); return node;
|
|
56
|
+
});
|
|
57
|
+
createBuffer = vi.fn((_channels: number, size: number, rate: number) => ({ duration: size / rate, getChannelData: () => new Float32Array(size) }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function installLiveWebSocket() {
|
|
61
|
+
const media = fakeStream();
|
|
62
|
+
const getUserMedia = vi.fn(async () => media.stream);
|
|
63
|
+
const fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
|
|
64
|
+
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
|
|
65
|
+
vi.stubGlobal('fetch', fetch);
|
|
66
|
+
FakeWebSocket.instances = [];
|
|
67
|
+
FakeAudioContext.instances = [];
|
|
68
|
+
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
69
|
+
vi.stubGlobal('AudioContext', FakeAudioContext);
|
|
70
|
+
return { ...media, getUserMedia, fetch };
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
import type { RealtimeModel, RealtimeServerEvent } from '../../types/realtime-model';
|
|
3
|
+
|
|
4
|
+
export class FakeTrack extends EventTarget {
|
|
5
|
+
enabled = true;
|
|
6
|
+
muted = false;
|
|
7
|
+
readyState = 'live';
|
|
8
|
+
stop = vi.fn(() => { this.readyState = 'ended'; });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fakeStream() {
|
|
12
|
+
const track = new FakeTrack();
|
|
13
|
+
return { track, stream: { getTracks: () => [track], getAudioTracks: () => [track] } as unknown as MediaStream };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function liveModel(): RealtimeModel {
|
|
17
|
+
return {
|
|
18
|
+
specificationVersion: 'v4', provider: 'test', modelId: 'live',
|
|
19
|
+
capabilities: { conversation: 'continuous', transports: ['websocket'], connections: ['server-websocket'], startup: 'session-start', finalization: 'session-close' },
|
|
20
|
+
getWebSocketConfig: vi.fn(),
|
|
21
|
+
doCreateClientSecret: vi.fn(),
|
|
22
|
+
buildSessionConfig: vi.fn(),
|
|
23
|
+
parseServerEvent: event => event as RealtimeServerEvent,
|
|
24
|
+
serializeClientEvent: async event => event,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function flushEvents() {
|
|
29
|
+
for (let i = 0; i < 100; i++) await Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function deferred<T>() {
|
|
33
|
+
let resolve: (value: T) => void = () => {};
|
|
34
|
+
const promise = new Promise<T>(r => { resolve = r; });
|
|
35
|
+
return { promise, resolve };
|
|
36
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { vi, type Mock } from 'vitest';
|
|
2
|
+
import type { RealtimeModel, RealtimeServerEvent } from '../../types/realtime-model';
|
|
3
|
+
|
|
4
|
+
export class FakeTrack extends EventTarget {
|
|
5
|
+
enabled = true;
|
|
6
|
+
muted = false;
|
|
7
|
+
readyState = 'live';
|
|
8
|
+
stop = vi.fn(() => { this.readyState = 'ended'; });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fakeStream(tracks = [new FakeTrack()]) {
|
|
12
|
+
return { track: tracks[0], stream: { getTracks: () => tracks, getAudioTracks: () => tracks } as unknown as MediaStream };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class FakeDataChannel {
|
|
16
|
+
readyState = 'connecting';
|
|
17
|
+
bufferedAmount = 0;
|
|
18
|
+
onopen: (() => void) | null = null;
|
|
19
|
+
onclose: (() => void) | null = null;
|
|
20
|
+
onerror: (() => void) | null = null;
|
|
21
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
22
|
+
sent: Array<{ type: string }> = [];
|
|
23
|
+
send = vi.fn((data: string) => { this.sent.push(JSON.parse(data)); });
|
|
24
|
+
close = vi.fn(() => { this.readyState = 'closed'; this.onclose?.(); });
|
|
25
|
+
emit(event: RealtimeServerEvent) { this.onmessage?.({ data: JSON.stringify(event) }); }
|
|
26
|
+
open() { this.readyState = 'open'; this.onopen?.(); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FakePeerConnection {
|
|
30
|
+
static instances: FakePeerConnection[] = [];
|
|
31
|
+
static autoStart = true;
|
|
32
|
+
static autoOpen = true;
|
|
33
|
+
static gather = true;
|
|
34
|
+
dc = new FakeDataChannel();
|
|
35
|
+
iceGatheringState = FakePeerConnection.gather ? 'complete' : 'gathering';
|
|
36
|
+
connectionState = 'new';
|
|
37
|
+
localDescription: { sdp: string } | null = null;
|
|
38
|
+
ontrack: ((event: { streams: MediaStream[] }) => void) | null = null;
|
|
39
|
+
onconnectionstatechange: (() => void) | null = null;
|
|
40
|
+
iceConnectionState = 'new';
|
|
41
|
+
oniceconnectionstatechange: (() => void) | null = null;
|
|
42
|
+
onicegatheringstatechange: (() => void) | null = null;
|
|
43
|
+
constructor() { FakePeerConnection.instances.push(this); }
|
|
44
|
+
createDataChannel = vi.fn(() => this.dc);
|
|
45
|
+
sender = {
|
|
46
|
+
track: null as MediaStreamTrack | null,
|
|
47
|
+
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => { this.sender.track = track; }),
|
|
48
|
+
};
|
|
49
|
+
addTrack: Mock = vi.fn((track: MediaStreamTrack) => { this.sender.track = track; return this.sender; });
|
|
50
|
+
addTransceiver = vi.fn(() => ({ sender: this.sender }));
|
|
51
|
+
createOffer = vi.fn(async () => {
|
|
52
|
+
if (this.dc.onmessage == null || this.ontrack == null || this.onicegatheringstatechange == null || this.dc.onopen == null) throw new Error('Listeners were installed too late');
|
|
53
|
+
return { type: 'offer', sdp: 'local-offer' };
|
|
54
|
+
});
|
|
55
|
+
setLocalDescription = vi.fn(async (offer: { sdp: string }) => { this.localDescription = offer; });
|
|
56
|
+
setRemoteDescription = vi.fn(async () => {
|
|
57
|
+
if (FakePeerConnection.autoOpen) this.dc.open();
|
|
58
|
+
if (FakePeerConnection.autoStart) this.dc.emit({ type: 'session-started', sessionId: 'session-1', delegationMode: 'client', raw: {} });
|
|
59
|
+
});
|
|
60
|
+
close = vi.fn(() => { this.connectionState = 'closed'; this.onconnectionstatechange?.(); });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type FakeAudio = {
|
|
64
|
+
autoplay: boolean;
|
|
65
|
+
srcObject: MediaStream | null;
|
|
66
|
+
onplaying: (() => void) | null;
|
|
67
|
+
onpause: (() => void) | null;
|
|
68
|
+
onwaiting: (() => void) | null;
|
|
69
|
+
onended: (() => void) | null;
|
|
70
|
+
play: Mock<() => Promise<void>>;
|
|
71
|
+
pause: Mock;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function installWebRTC(): {
|
|
75
|
+
track: FakeTrack;
|
|
76
|
+
stream: MediaStream;
|
|
77
|
+
getUserMedia: Mock<() => Promise<MediaStream>>;
|
|
78
|
+
audio: FakeAudio;
|
|
79
|
+
fetch: Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>;
|
|
80
|
+
} {
|
|
81
|
+
FakePeerConnection.instances = [];
|
|
82
|
+
FakePeerConnection.autoOpen = true;
|
|
83
|
+
FakePeerConnection.autoStart = true;
|
|
84
|
+
FakePeerConnection.gather = true;
|
|
85
|
+
const media = fakeStream();
|
|
86
|
+
const getUserMedia = vi.fn(async () => media.stream);
|
|
87
|
+
const audio = {
|
|
88
|
+
autoplay: false,
|
|
89
|
+
srcObject: null as MediaStream | null,
|
|
90
|
+
onplaying: null as (() => void) | null,
|
|
91
|
+
onpause: null as (() => void) | null,
|
|
92
|
+
onwaiting: null as (() => void) | null,
|
|
93
|
+
onended: null as (() => void) | null,
|
|
94
|
+
play: vi.fn(async () => {}),
|
|
95
|
+
pause: vi.fn(),
|
|
96
|
+
};
|
|
97
|
+
vi.stubGlobal('RTCPeerConnection', FakePeerConnection);
|
|
98
|
+
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
|
|
99
|
+
// Preserve the DOM used by React tests while substituting its audio element.
|
|
100
|
+
if (typeof document === 'undefined') {
|
|
101
|
+
vi.stubGlobal('document', { createElement: vi.fn(() => audio) });
|
|
102
|
+
} else {
|
|
103
|
+
const createElement = document.createElement.bind(document);
|
|
104
|
+
vi.spyOn(document, 'createElement').mockImplementation(((tag: string, options?: ElementCreationOptions) =>
|
|
105
|
+
tag === 'audio' ? audio : createElement(tag, options)) as typeof document.createElement);
|
|
106
|
+
}
|
|
107
|
+
const fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => Response.json({ sessionId: 'session-1', sdp: 'remote-answer' }));
|
|
108
|
+
vi.stubGlobal('fetch', fetch);
|
|
109
|
+
return { ...media, getUserMedia, audio, fetch };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function liveModel(): RealtimeModel & { capabilities: NonNullable<RealtimeModel['capabilities']> } {
|
|
113
|
+
return {
|
|
114
|
+
specificationVersion: 'v4', provider: 'test', modelId: 'live',
|
|
115
|
+
capabilities: { conversation: 'continuous', transports: ['webrtc', 'websocket'], connections: ['webrtc', 'server-websocket'], startup: 'session-start', finalization: 'session-close' },
|
|
116
|
+
getWebRTCConfig: () => ({ dataChannelLabel: 'model-events' }),
|
|
117
|
+
getWebSocketConfig: vi.fn(),
|
|
118
|
+
doCreateClientSecret: vi.fn(),
|
|
119
|
+
buildSessionConfig: vi.fn(),
|
|
120
|
+
parseServerEvent: event => event as RealtimeServerEvent,
|
|
121
|
+
serializeClientEvent: async event => event,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function flushEvents() {
|
|
126
|
+
for (let i = 0; i < 100; i++) await Promise.resolve();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function deferred<T>() {
|
|
130
|
+
let resolve: (value: T) => void = () => {};
|
|
131
|
+
const promise = new Promise<T>(r => { resolve = r; });
|
|
132
|
+
return { promise, resolve };
|
|
133
|
+
}
|