@ai-sdk/provider-utils 5.0.66 → 5.0.68
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 +20 -0
- package/README.md +4 -0
- package/dist/index.d.ts +113 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +241 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +11 -0
- package/src/speech-stream-envelope.ts +418 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/provider-utils",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.68",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@ai-sdk/provider": "4.0.
|
|
45
|
+
"@ai-sdk/provider": "4.0.34",
|
|
46
46
|
"@standard-schema/spec": "^1.1.0",
|
|
47
47
|
"@workflow/serde": "4.1.0",
|
|
48
48
|
"eventsource-parser": "^3.0.8",
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,17 @@ export {
|
|
|
113
113
|
export { serializeModelOptions } from './serialize-model-options';
|
|
114
114
|
export { SerializationError } from './serialization-error';
|
|
115
115
|
export { secureJsonParse } from './secure-json-parse';
|
|
116
|
+
export {
|
|
117
|
+
SPEECH_STREAM_START_FRAME_TYPE as EXPERIMENTAL_SPEECH_STREAM_START_FRAME_TYPE,
|
|
118
|
+
SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE as EXPERIMENTAL_SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE,
|
|
119
|
+
SPEECH_STREAM_TEXT_DONE_FRAME_TYPE as EXPERIMENTAL_SPEECH_STREAM_TEXT_DONE_FRAME_TYPE,
|
|
120
|
+
parseSpeechStreamClientFrame as experimental_parseSpeechStreamClientFrame,
|
|
121
|
+
parseSpeechStreamPart as experimental_parseSpeechStreamPart,
|
|
122
|
+
serializeSpeechStreamPart as experimental_serializeSpeechStreamPart,
|
|
123
|
+
type SpeechStreamClientFrame as Experimental_SpeechStreamClientFrame,
|
|
124
|
+
type SpeechStreamStartFrame as Experimental_SpeechStreamStartFrame,
|
|
125
|
+
type SpeechStreamTextDeltaFrame as Experimental_SpeechStreamTextDeltaFrame,
|
|
126
|
+
} from './speech-stream-envelope';
|
|
116
127
|
export {
|
|
117
128
|
StreamingToolCallTracker,
|
|
118
129
|
type StreamingToolCallDelta,
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Experimental_SpeechModelV4StreamPart as SpeechModelV4StreamPart,
|
|
3
|
+
JSONObject,
|
|
4
|
+
SharedV4AudioFormat,
|
|
5
|
+
} from '@ai-sdk/provider';
|
|
6
|
+
import { isRecord } from './is-record';
|
|
7
|
+
import { secureJsonParse } from './secure-json-parse';
|
|
8
|
+
import { convertUint8ArrayToBase64 } from './uint8-utils';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Experimental speech-stream WebSocket envelope (v1): the standard
|
|
12
|
+
* serialization of `Experimental_StreamingSpeechModelV4.doStream` over a WebSocket. Clients (e.g.
|
|
13
|
+
* the `@ai-sdk/gateway` provider) encode with this module and servers (e.g.
|
|
14
|
+
* AI Gateway) decode with it, so the two sides cannot drift.
|
|
15
|
+
*
|
|
16
|
+
* Envelope rules:
|
|
17
|
+
*
|
|
18
|
+
* 1. The client sends exactly one `speech-stream.start` TEXT frame first.
|
|
19
|
+
* 2. Input text rides `speech-stream.text-delta` TEXT frames, in order. A
|
|
20
|
+
* frame may carry its text part's `voice` and `providerOptions`. A long
|
|
21
|
+
* part may span several frames: `voice` is repeated on each, and
|
|
22
|
+
* `providerOptions` appear on the first only, so they must describe an
|
|
23
|
+
* event at the start of the part (e.g. a turn break), not a setting. An
|
|
24
|
+
* empty `delta` is valid and carries only part metadata; it does not end
|
|
25
|
+
* the input.
|
|
26
|
+
* 3. The client signals the end of the input text with the
|
|
27
|
+
* `speech-stream.text-done` TEXT frame; a plain close without it is an
|
|
28
|
+
* abort.
|
|
29
|
+
* 4. Every server→client TEXT frame is one JSON-serialized
|
|
30
|
+
* `SpeechModelV4StreamPart` (flattened, no wrapper). `audio` payloads are
|
|
31
|
+
* base64 strings. `Date` values (`response-metadata.timestamp`) serialize
|
|
32
|
+
* to ISO 8601 strings and are revived by `parseSpeechStreamPart`. `Error`
|
|
33
|
+
* payloads in `error` parts serialize as `{ name, message }`; servers may
|
|
34
|
+
* instead send `{ type, message }`, where `type` names an error class the
|
|
35
|
+
* client can map to a typed error (e.g. `invalid_request_error`).
|
|
36
|
+
* 5. The server closes with code 1000 after the `finish` part. Over this
|
|
37
|
+
* envelope an `error` part is terminal: the server sends it on failure and
|
|
38
|
+
* then closes non-1000. A close without a prior `finish` is an error.
|
|
39
|
+
* 6. Unknown frame/part types are ignored in both directions (forward
|
|
40
|
+
* compatibility).
|
|
41
|
+
* 7. Servers may enforce a maximum frame size (the AI Gateway rejects frames
|
|
42
|
+
* over 256 KiB); clients should keep text-delta frames under 64 KiB.
|
|
43
|
+
* 8. Connection establishment (URL, auth) is transport-specific and out of
|
|
44
|
+
* scope.
|
|
45
|
+
*
|
|
46
|
+
* The envelope validates frame shape only; server policy layers on top. Both
|
|
47
|
+
* parsers use `secureJsonParse`, so frames carrying `__proto__` /
|
|
48
|
+
* `constructor.prototype` keys are rejected rather than parsed.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** Type of the first client TEXT frame. */
|
|
52
|
+
export const SPEECH_STREAM_START_FRAME_TYPE = 'speech-stream.start';
|
|
53
|
+
|
|
54
|
+
/** Type of the client TEXT frame that carries one input text delta. */
|
|
55
|
+
export const SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE = 'speech-stream.text-delta';
|
|
56
|
+
|
|
57
|
+
/** Type of the client TEXT frame that signals the end of the input text. */
|
|
58
|
+
export const SPEECH_STREAM_TEXT_DONE_FRAME_TYPE = 'speech-stream.text-done';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The client's session start frame. Optional keys are omitted when undefined.
|
|
62
|
+
*/
|
|
63
|
+
export type SpeechStreamStartFrame = {
|
|
64
|
+
type: typeof SPEECH_STREAM_START_FRAME_TYPE;
|
|
65
|
+
voice?: string;
|
|
66
|
+
outputFormat?: SharedV4AudioFormat;
|
|
67
|
+
instructions?: string;
|
|
68
|
+
speed?: number;
|
|
69
|
+
language?: string;
|
|
70
|
+
|
|
71
|
+
/** Provider-specific options, passed through verbatim. */
|
|
72
|
+
providerOptions?: Record<string, JSONObject>;
|
|
73
|
+
|
|
74
|
+
/** When true, the server should include `raw` parts in the stream. */
|
|
75
|
+
includeRawChunks?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A client frame carrying one input text delta, i.e. one text part. Optional
|
|
80
|
+
* keys are omitted when undefined.
|
|
81
|
+
*/
|
|
82
|
+
export type SpeechStreamTextDeltaFrame = {
|
|
83
|
+
type: typeof SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE;
|
|
84
|
+
delta: string;
|
|
85
|
+
voice?: string;
|
|
86
|
+
providerOptions?: Record<string, JSONObject>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Server-side classification of a client TEXT frame. */
|
|
90
|
+
export type SpeechStreamClientFrame =
|
|
91
|
+
| { type: 'start'; frame: SpeechStreamStartFrame }
|
|
92
|
+
| {
|
|
93
|
+
type: 'text-delta';
|
|
94
|
+
delta: string;
|
|
95
|
+
voice?: string;
|
|
96
|
+
providerOptions?: Record<string, JSONObject>;
|
|
97
|
+
}
|
|
98
|
+
| { type: 'text-done' }
|
|
99
|
+
| {
|
|
100
|
+
/** Malformed JSON or a recognized frame with an invalid shape. */
|
|
101
|
+
type: 'invalid';
|
|
102
|
+
message: string;
|
|
103
|
+
}
|
|
104
|
+
| {
|
|
105
|
+
/** Unrecognized frame type; ignore for forward compatibility. */
|
|
106
|
+
type: 'unknown';
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const optionalStartFrameFields = {
|
|
110
|
+
instructions: 'string',
|
|
111
|
+
language: 'string',
|
|
112
|
+
speed: 'number',
|
|
113
|
+
voice: 'string',
|
|
114
|
+
includeRawChunks: 'boolean',
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Server-side: parse a client TEXT frame. Validates envelope shape only and
|
|
119
|
+
* rejects prototype-pollution payloads. Never throws.
|
|
120
|
+
*/
|
|
121
|
+
export function parseSpeechStreamClientFrame(
|
|
122
|
+
text: string,
|
|
123
|
+
): SpeechStreamClientFrame {
|
|
124
|
+
let value: unknown;
|
|
125
|
+
try {
|
|
126
|
+
value = secureJsonParse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
return { type: 'invalid', message: 'malformed JSON' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!isRecord(value)) {
|
|
132
|
+
return { type: 'invalid', message: 'frame must be a JSON object' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof value.type !== 'string') {
|
|
136
|
+
return { type: 'invalid', message: 'frame type must be a string' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
switch (value.type) {
|
|
140
|
+
case SPEECH_STREAM_START_FRAME_TYPE: {
|
|
141
|
+
for (const [field, type] of Object.entries(optionalStartFrameFields)) {
|
|
142
|
+
if (value[field] !== undefined && typeof value[field] !== type) {
|
|
143
|
+
return {
|
|
144
|
+
type: 'invalid',
|
|
145
|
+
message: `${field} must be a ${type} when present`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
value.outputFormat !== undefined &&
|
|
151
|
+
!isAudioFormat(value.outputFormat)
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
type: 'invalid',
|
|
155
|
+
message:
|
|
156
|
+
'outputFormat must be an audio format object ({ type, rate? }) when present',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
value.providerOptions !== undefined &&
|
|
161
|
+
!isProviderOptions(value.providerOptions)
|
|
162
|
+
) {
|
|
163
|
+
return {
|
|
164
|
+
type: 'invalid',
|
|
165
|
+
message:
|
|
166
|
+
'providerOptions must be an object of provider objects when present',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Only known keys are passed on, so servers never see client extras.
|
|
170
|
+
const frame: SpeechStreamStartFrame = {
|
|
171
|
+
type: SPEECH_STREAM_START_FRAME_TYPE,
|
|
172
|
+
};
|
|
173
|
+
for (const field of Object.keys(optionalStartFrameFields) as Array<
|
|
174
|
+
keyof typeof optionalStartFrameFields
|
|
175
|
+
>) {
|
|
176
|
+
if (value[field] !== undefined) {
|
|
177
|
+
(frame as Record<string, unknown>)[field] = value[field];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (value.outputFormat !== undefined) {
|
|
181
|
+
const { type, rate } = value.outputFormat as SharedV4AudioFormat;
|
|
182
|
+
frame.outputFormat = { type, ...(rate !== undefined && { rate }) };
|
|
183
|
+
}
|
|
184
|
+
if (value.providerOptions !== undefined) {
|
|
185
|
+
frame.providerOptions = value.providerOptions as Record<
|
|
186
|
+
string,
|
|
187
|
+
JSONObject
|
|
188
|
+
>;
|
|
189
|
+
}
|
|
190
|
+
return { type: 'start', frame };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE: {
|
|
194
|
+
if (typeof value.delta !== 'string') {
|
|
195
|
+
return {
|
|
196
|
+
type: 'invalid',
|
|
197
|
+
message: 'text-delta frame must have a string delta',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (value.voice !== undefined && typeof value.voice !== 'string') {
|
|
201
|
+
return {
|
|
202
|
+
type: 'invalid',
|
|
203
|
+
message: 'text-delta voice must be a string when present',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
value.providerOptions !== undefined &&
|
|
208
|
+
!isProviderOptions(value.providerOptions)
|
|
209
|
+
) {
|
|
210
|
+
return {
|
|
211
|
+
type: 'invalid',
|
|
212
|
+
message:
|
|
213
|
+
'text-delta providerOptions must be an object of provider objects when present',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
type: 'text-delta',
|
|
218
|
+
delta: value.delta,
|
|
219
|
+
...(value.voice !== undefined && { voice: value.voice }),
|
|
220
|
+
...(value.providerOptions !== undefined && {
|
|
221
|
+
providerOptions: value.providerOptions as Record<string, JSONObject>,
|
|
222
|
+
}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case SPEECH_STREAM_TEXT_DONE_FRAME_TYPE:
|
|
227
|
+
return { type: 'text-done' };
|
|
228
|
+
|
|
229
|
+
default:
|
|
230
|
+
return { type: 'unknown' };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Server-side: serialize a speech stream part as one TEXT frame. Binary audio
|
|
236
|
+
* is base64-encoded and `Error` payloads serialize as `{ name, message }`.
|
|
237
|
+
* Returns `undefined` for payloads that are not JSON-serializable; callers
|
|
238
|
+
* drop the frame.
|
|
239
|
+
*/
|
|
240
|
+
export function serializeSpeechStreamPart(
|
|
241
|
+
part: SpeechModelV4StreamPart,
|
|
242
|
+
): string | undefined {
|
|
243
|
+
try {
|
|
244
|
+
if (part.type === 'audio' && part.audio instanceof Uint8Array) {
|
|
245
|
+
return JSON.stringify({
|
|
246
|
+
...part,
|
|
247
|
+
audio: convertUint8ArrayToBase64(part.audio),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (part.type === 'error' && isError(part.error)) {
|
|
251
|
+
return JSON.stringify({
|
|
252
|
+
...part,
|
|
253
|
+
error: { name: part.error.name, message: part.error.message },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return JSON.stringify(part);
|
|
257
|
+
} catch {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// `instanceof` misses cross-realm Errors; the brand check does not.
|
|
263
|
+
function isError(value: unknown): value is Error {
|
|
264
|
+
return (
|
|
265
|
+
value instanceof Error ||
|
|
266
|
+
Object.prototype.toString.call(value) === '[object Error]'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Client-side: parse a server TEXT frame into a speech stream part. Returns
|
|
272
|
+
* `undefined` for malformed or unsafe JSON, unknown part types, and known
|
|
273
|
+
* part types whose fields are missing or mistyped. Revives
|
|
274
|
+
* `response-metadata.timestamp` to a `Date`.
|
|
275
|
+
*/
|
|
276
|
+
export function parseSpeechStreamPart(
|
|
277
|
+
text: string,
|
|
278
|
+
): SpeechModelV4StreamPart | undefined {
|
|
279
|
+
let value: unknown;
|
|
280
|
+
try {
|
|
281
|
+
value = secureJsonParse(text);
|
|
282
|
+
} catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!isRecord(value)) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const part = value as SpeechModelV4StreamPart;
|
|
291
|
+
|
|
292
|
+
switch (part.type) {
|
|
293
|
+
case 'stream-start': {
|
|
294
|
+
if (!Array.isArray(part.warnings)) {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
// Unknown warning types are dropped (forward compatibility); a known
|
|
298
|
+
// type with a malformed shape invalidates the part.
|
|
299
|
+
const warnings = part.warnings.filter(
|
|
300
|
+
warning => !isUnknownWarningType(warning),
|
|
301
|
+
);
|
|
302
|
+
return warnings.every(isWarning) ? { ...part, warnings } : undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case 'audio':
|
|
306
|
+
return isString(part.audio) &&
|
|
307
|
+
isAudioFormat(part.audioFormat) &&
|
|
308
|
+
isOptional(part.id, isString) &&
|
|
309
|
+
isOptional(part.providerMetadata, isRecord)
|
|
310
|
+
? part
|
|
311
|
+
: undefined;
|
|
312
|
+
|
|
313
|
+
case 'finish':
|
|
314
|
+
return isOptional(part.usage, isUsage) &&
|
|
315
|
+
isOptional(part.providerMetadata, isRecord)
|
|
316
|
+
? part
|
|
317
|
+
: undefined;
|
|
318
|
+
|
|
319
|
+
case 'response-metadata': {
|
|
320
|
+
if (
|
|
321
|
+
!(
|
|
322
|
+
isOptional(part.modelId, isString) &&
|
|
323
|
+
isOptional(part.headers, isRecord)
|
|
324
|
+
)
|
|
325
|
+
) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
const timestamp: unknown = part.timestamp;
|
|
329
|
+
if (timestamp == null) {
|
|
330
|
+
return { ...part, timestamp: undefined };
|
|
331
|
+
}
|
|
332
|
+
if (typeof timestamp !== 'string') {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
const revived = new Date(timestamp);
|
|
336
|
+
return Number.isNaN(revived.getTime())
|
|
337
|
+
? undefined
|
|
338
|
+
: { ...part, timestamp: revived };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case 'raw':
|
|
342
|
+
return 'rawValue' in part ? part : undefined;
|
|
343
|
+
|
|
344
|
+
case 'error':
|
|
345
|
+
return 'error' in part ? part : undefined;
|
|
346
|
+
|
|
347
|
+
default:
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isString(value: unknown): value is string {
|
|
353
|
+
return typeof value === 'string';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isOptional(
|
|
357
|
+
value: unknown,
|
|
358
|
+
check: (value: unknown) => boolean,
|
|
359
|
+
): boolean {
|
|
360
|
+
return value === undefined || check(value);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const knownWarningTypes = new Set([
|
|
364
|
+
'compatibility',
|
|
365
|
+
'deprecated',
|
|
366
|
+
'other',
|
|
367
|
+
'unsupported',
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
function isUnknownWarningType(value: unknown): boolean {
|
|
371
|
+
return (
|
|
372
|
+
isRecord(value) &&
|
|
373
|
+
isString(value.type) &&
|
|
374
|
+
!knownWarningTypes.has(value.type)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isWarning(value: unknown): boolean {
|
|
379
|
+
if (!isRecord(value)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
switch (value.type) {
|
|
383
|
+
case 'compatibility':
|
|
384
|
+
case 'unsupported':
|
|
385
|
+
return isString(value.feature) && isOptional(value.details, isString);
|
|
386
|
+
case 'deprecated':
|
|
387
|
+
return isString(value.setting) && isString(value.message);
|
|
388
|
+
case 'other':
|
|
389
|
+
return isString(value.message);
|
|
390
|
+
default:
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function isProviderOptions(value: unknown): boolean {
|
|
396
|
+
return isRecord(value) && Object.values(value).every(isRecord);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isAudioFormat(value: unknown): boolean {
|
|
400
|
+
return (
|
|
401
|
+
isRecord(value) &&
|
|
402
|
+
isString(value.type) &&
|
|
403
|
+
value.type.length > 0 &&
|
|
404
|
+
isOptional(
|
|
405
|
+
value.rate,
|
|
406
|
+
rate => typeof rate === 'number' && Number.isFinite(rate) && rate > 0,
|
|
407
|
+
)
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isUsage(value: unknown): boolean {
|
|
412
|
+
return (
|
|
413
|
+
isRecord(value) &&
|
|
414
|
+
['inputCharacters', 'inputTokens', 'outputTokens'].every(key =>
|
|
415
|
+
isOptional(value[key], count => typeof count === 'number'),
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
}
|