@henjicc/ai-sdk 0.6.1 → 0.6.2
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 +8 -0
- package/README.md +16 -3
- package/dist/capabilities/client.js +103 -86
- package/dist/capabilities/speech-recognition/bailian/realtime/module.js +42 -7
- package/dist/capabilities/speech-recognition/bailian/realtime/protocol.js +57 -15
- package/dist/capabilities/speech-recognition/volcengine/realtime/module.js +42 -10
- package/dist/capabilities/translation/bailian/module.js +41 -13
- package/dist/capabilities/translation/bailian/sse.d.ts +1 -1
- package/dist/capabilities/translation/bailian/sse.js +82 -54
- package/dist/capabilities/types.d.ts +4 -0
- package/dist/llm/chat.js +6 -1
- package/dist/llm/streaming.js +131 -91
- package/dist/protocols/sse-events.d.ts +4 -0
- package/dist/protocols/sse-events.js +31 -0
- package/docs/consumers.md +119 -107
- package/docs/llm-adaptation/README.md +26 -1
- package/docs/llm-adaptation//344/276/233/345/272/224/345/225/206//346/231/272/350/260/261GLM.md +3 -1
- package/docs/model-adaptation/Fun-ASR-Realtime/Fun-ASR-Realtime_/347/231/276/347/202/274.md +22 -2
- package/docs/model-adaptation/Qwen-MT-Flash/Qwen-MT-Flash_/347/231/276/347/202/274.md +24 -3
- package/docs/model-adaptation/Qwen-MT-Lite/Qwen-MT-Lite_/347/231/276/347/202/274.md +3 -2
- package/docs/model-adaptation/Qwen-MT-Plus/Qwen-MT-Plus_/347/231/276/347/202/274.md +3 -2
- package/docs/model-adaptation/Qwen3-ASR-Flash-Realtime/Qwen3-ASR-Flash-Realtime_/347/231/276/347/202/274.md +25 -2
- package/docs/model-adaptation/SeedASR-2.0-Realtime/SeedASR-2.0-Realtime_/347/201/253/345/261/261/345/274/225/346/223/216.md +7 -3
- package/examples/llm-chat/README.md +1 -1
- package/examples/llm-chat/index.ts +1 -1
- package/examples/llm-chat/package.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.2 - 2026-09-24
|
|
4
|
+
|
|
5
|
+
- 完善聊天与翻译 SSE 的事件边界和完成状态校验:保留合法空中间帧、用量与状态通知,提前结束、服务端错误和无效终态不再伪装成功;保留翻译增量/累积协议差异。
|
|
6
|
+
- 修复百炼实时 ASR 字段校验、Qwen 中间文字拼接和状态通知处理;保留 0.6.1 空中间帧兼容,诊断包含实际模型、协议及失败阶段。
|
|
7
|
+
- 收口共享实时会话的取消、超时、后台故障通知和资源释放;SDK 会话提供可选 result promise,旧自定义会话仍兼容。握手清理失败保留首个故障,火山服务错误默认脱敏。
|
|
8
|
+
- 阻止聊天及火山实时识别在取消后继续派发同一帧的后续结果;明确智谱推理网络错误与上下文截断,扩展通知不冒充完成证据。
|
|
9
|
+
- 增加脱敏事件序列回归并执行修复撤销验证,更新聊天示例的合法结束帧。未改变供应商配额、网络稳定性或模型识别质量。
|
|
10
|
+
|
|
3
11
|
## 0.6.1 - 2026-09-24
|
|
4
12
|
|
|
5
13
|
- 修复百炼 Fun Duplex 实时识别把无句首标记的空文本中间帧误判为无效响应的问题;覆盖 Qwen Audio 3.1 streaming 与 Fun-ASR 共享协议。
|
package/README.md
CHANGED
|
@@ -48,10 +48,10 @@ try {
|
|
|
48
48
|
|
|
49
49
|
百炼工厂必须传 `baseUrl` 为实际地域/工作空间的 API 根地址,不包含端点路径。其他供应商可以覆盖根地址与 `credentialId`;SDK 不自动切换地域或账号。不同模型的向量不能混用;切换模型通常需要重建向量索引。Rerank 分数仅在本次请求内比较。
|
|
50
50
|
|
|
51
|
-
SDK `0.6.
|
|
51
|
+
SDK `0.6.2` 的正式分发渠道为公共 npm,无需配置 registry 或访问令牌:
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
npm install @henjicc/ai-sdk@0.6.
|
|
54
|
+
npm install @henjicc/ai-sdk@0.6.2
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
然后提供 4 个宿主能力(`Transport` / `CredentialStore` / `MediaReader` / `Logger`),创建客户端:
|
|
@@ -493,7 +493,20 @@ await capabilities.dispose()
|
|
|
493
493
|
- `llm/bigmodel`:智谱同一 provider family 下的中国大陆/Global 端点 profile、独立凭据槽与 GLM-5.3-Flash 能力。
|
|
494
494
|
- `llm/modules`:外部包、插件与内置 LLM 共用的注册、执行、发现、取消和 namespace 卸载边界。
|
|
495
495
|
|
|
496
|
-
|
|
496
|
+
实时会话仍使用 `openSession → send → finish/close`。由 client 创建的会话额外提供 `result` Promise:
|
|
497
|
+
它不主动发送 finish;在最终结果到达或后台失败、取消、超时、主动关闭,并完成资源收尾后结算。
|
|
498
|
+
调用方可以观察 `session.result` 以获知停止发送音频期间的后台失败,不必等到下一次 `send/finish`。
|
|
499
|
+
`finish()` 幂等且返回同一结果;正常关闭但没有最终结果时 `result` 拒绝 `realtime_session_closed`,
|
|
500
|
+
取消是 `cancelled`,超时是 `timeout`,供应商失败保留原错误。未订阅该 Promise 不产生未处理拒绝,
|
|
501
|
+
SDK 仍按现有日志契约记录失败。
|
|
502
|
+
|
|
503
|
+
外部实时驱动可以提供可选 `result` Promise,致命后台/发送错误必须拒绝它,成功则返回有效最终结果。
|
|
504
|
+
输入校验等可恢复的单次 send 错误只拒绝该调用,不应结束 result。旧驱动无需新增字段即可继续使用;
|
|
505
|
+
如果旧驱动没有提供结果通道,SDK 无法自行发现它的后台失败。公共类型中的可选属性用于兼容旧会话实现。
|
|
506
|
+
完成、失败、取消与关闭竞争时只结算一次;清理失败不能覆盖首个失败,但成功路径清理失败不能报成功。
|
|
507
|
+
连接、监听器与定时器回收后,原 requestId 可用于新会话;不会自动重连或重放已可能计费的音频。
|
|
508
|
+
|
|
509
|
+
ASR module ID 固定为 `<providerId>.speech-recognition.<modelId>`,翻译固定为
|
|
497
510
|
`bailian.translation.<modelId>`;供应商 ID 使用 `bailian` / `volcengine` / `siliconflow` / `groq`,
|
|
498
511
|
没有 `funasr` 兼容供应商别名。火山文件标准版不会读取本地 bytes/media-ref;宿主必须先通过自己受控的
|
|
499
512
|
对象存储发布成供应商可访问 URL。SDK 不提供公共 URL 文本框或冒充上传成功的回退路径。
|
|
@@ -193,117 +193,134 @@ export function createCapabilityClient(config) {
|
|
|
193
193
|
? new AiRuntimeError('timeout', `Capability session timed out: ${requestId}`)
|
|
194
194
|
: cancelledError(requestId);
|
|
195
195
|
}
|
|
196
|
-
let ended = false;
|
|
197
196
|
let closePromise;
|
|
198
197
|
let finishPromise;
|
|
198
|
+
let outcome;
|
|
199
|
+
let settlement;
|
|
200
|
+
let cleanupError;
|
|
201
|
+
const pendingSends = new Set();
|
|
202
|
+
let resolveResult;
|
|
203
|
+
let rejectResult;
|
|
204
|
+
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
|
205
|
+
// Observing is optional; the original promise remains rejected for callers.
|
|
206
|
+
void result.catch(() => undefined);
|
|
199
207
|
const closeDriver = () => {
|
|
200
208
|
closePromise ??= Promise.resolve().then(async () => await driver.close?.());
|
|
201
209
|
return closePromise;
|
|
202
210
|
};
|
|
203
|
-
const
|
|
204
|
-
if (ended)
|
|
205
|
-
return;
|
|
206
|
-
ended = true;
|
|
207
|
-
options.signal?.removeEventListener('abort', forwardAbort);
|
|
208
|
-
if (timeout !== undefined)
|
|
209
|
-
clearTimeout(timeout);
|
|
210
|
-
active.delete(requestId);
|
|
211
|
-
markFinished();
|
|
212
|
-
span.end(error);
|
|
213
|
-
};
|
|
214
|
-
const closeAfterAbort = async () => {
|
|
215
|
-
if (ended)
|
|
216
|
-
return;
|
|
217
|
-
const error = timedOut
|
|
218
|
-
? new AiRuntimeError('timeout', `Capability session timed out: ${requestId}`)
|
|
219
|
-
: cancelledError(requestId);
|
|
220
|
-
try {
|
|
221
|
-
await closeDriver();
|
|
222
|
-
}
|
|
223
|
-
finally {
|
|
224
|
-
runtime.logger.error('能力实时会话失败', {
|
|
225
|
-
event: 'capability.session.failed', requestId, context: { capabilityId: id }, error,
|
|
226
|
-
});
|
|
227
|
-
end(error);
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
controller.signal.addEventListener('abort', () => { void closeAfterAbort(); }, { once: true });
|
|
231
|
-
const sessionInactiveError = () => timedOut
|
|
211
|
+
const abortError = () => timedOut
|
|
232
212
|
? new AiRuntimeError('timeout', `Capability session timed out: ${requestId}`)
|
|
233
213
|
: cancelledError(requestId);
|
|
214
|
+
const normalize = (error) => controller.signal.aborted
|
|
215
|
+
? abortError() : normalizeExecutionError(id, error);
|
|
216
|
+
const inactiveError = () => outcome && outcome.kind !== 'completed'
|
|
217
|
+
? outcome.error : new AiRuntimeError('realtime_session_inactive', `Capability session is inactive: ${requestId}`);
|
|
218
|
+
const settle = (next) => {
|
|
219
|
+
if (settlement)
|
|
220
|
+
return settlement;
|
|
221
|
+
outcome = next;
|
|
222
|
+
for (const reject of pendingSends)
|
|
223
|
+
reject(inactiveError());
|
|
224
|
+
pendingSends.clear();
|
|
225
|
+
settlement = (async () => {
|
|
226
|
+
let terminal = next;
|
|
227
|
+
try {
|
|
228
|
+
await closeDriver();
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
cleanupError = normalizeExecutionError(id, error);
|
|
232
|
+
// Keep the first failure/cancellation; failed cleanup cannot make
|
|
233
|
+
// a successful result or explicit close look successful.
|
|
234
|
+
if (terminal.kind !== 'failed')
|
|
235
|
+
terminal = { kind: 'failed', error: cleanupError };
|
|
236
|
+
}
|
|
237
|
+
outcome = terminal;
|
|
238
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
239
|
+
options.signal?.removeEventListener('abort', forwardAbort);
|
|
240
|
+
if (timeout !== undefined)
|
|
241
|
+
clearTimeout(timeout);
|
|
242
|
+
active.delete(requestId);
|
|
243
|
+
markFinished();
|
|
244
|
+
if (terminal.kind === 'failed') {
|
|
245
|
+
runtime.logger.error('能力实时会话失败', {
|
|
246
|
+
event: 'capability.session.failed', requestId,
|
|
247
|
+
context: { capabilityId: id, ...(cleanupError ? { cleanupErrorCode: cleanupError.code } : {}) },
|
|
248
|
+
error: terminal.error,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
runtime.logger.info(terminal.kind === 'completed' ? '能力实时会话完成' : '能力实时会话关闭', {
|
|
253
|
+
event: `capability.session.${terminal.kind}`, requestId, context: { capabilityId: id },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
span.end(terminal.kind === 'failed' ? terminal.error : undefined);
|
|
257
|
+
if (terminal.kind === 'completed')
|
|
258
|
+
resolveResult(terminal.output);
|
|
259
|
+
else
|
|
260
|
+
rejectResult(terminal.error);
|
|
261
|
+
})();
|
|
262
|
+
return settlement;
|
|
263
|
+
};
|
|
264
|
+
const onAbort = () => { void settle({ kind: 'failed', error: abortError() }); };
|
|
265
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
266
|
+
if (driver.result) {
|
|
267
|
+
void driver.result.then(output => settle({ kind: 'completed', output }), error => settle({ kind: 'failed', error: normalize(error) }));
|
|
268
|
+
}
|
|
234
269
|
return {
|
|
235
270
|
requestId,
|
|
236
271
|
descriptor: registered.module.descriptor,
|
|
272
|
+
result,
|
|
237
273
|
send: async (value) => {
|
|
238
|
-
if (
|
|
239
|
-
throw
|
|
240
|
-
|
|
274
|
+
if (outcome || finishPromise)
|
|
275
|
+
throw inactiveError();
|
|
276
|
+
if (controller.signal.aborted)
|
|
277
|
+
throw abortError();
|
|
278
|
+
let rejectSend;
|
|
279
|
+
const pending = new Promise((resolve, reject) => {
|
|
280
|
+
rejectSend = reject;
|
|
281
|
+
pendingSends.add(reject);
|
|
282
|
+
void Promise.resolve().then(async () => {
|
|
283
|
+
if (outcome)
|
|
284
|
+
throw inactiveError();
|
|
285
|
+
await driver.send(value);
|
|
286
|
+
}).then(resolve, reject);
|
|
287
|
+
});
|
|
288
|
+
try {
|
|
289
|
+
await pending;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
pendingSends.delete(rejectSend);
|
|
293
|
+
}
|
|
241
294
|
},
|
|
242
295
|
finish: () => {
|
|
243
296
|
if (finishPromise)
|
|
244
297
|
return finishPromise;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
output = await driver.finish();
|
|
251
|
-
if (controller.signal.aborted)
|
|
252
|
-
throw cancelledError(requestId);
|
|
253
|
-
}
|
|
254
|
-
catch (error) {
|
|
255
|
-
const normalized = controller.signal.aborted
|
|
256
|
-
? cancelledError(requestId)
|
|
257
|
-
: normalizeExecutionError(id, error);
|
|
298
|
+
finishPromise = result;
|
|
299
|
+
if (!outcome) {
|
|
300
|
+
void Promise.resolve().then(async () => {
|
|
301
|
+
if (outcome)
|
|
302
|
+
return;
|
|
258
303
|
try {
|
|
259
|
-
await
|
|
304
|
+
const output = await driver.finish();
|
|
305
|
+
if (controller.signal.aborted)
|
|
306
|
+
throw abortError();
|
|
307
|
+
await settle({ kind: 'completed', output });
|
|
260
308
|
}
|
|
261
|
-
catch {
|
|
262
|
-
|
|
309
|
+
catch (error) {
|
|
310
|
+
await settle({ kind: 'failed', error: normalize(error) });
|
|
263
311
|
}
|
|
264
|
-
runtime.logger.error('能力实时会话失败', {
|
|
265
|
-
event: 'capability.session.failed', requestId, context: { capabilityId: id }, error: normalized,
|
|
266
|
-
});
|
|
267
|
-
end(normalized);
|
|
268
|
-
throw normalized;
|
|
269
|
-
}
|
|
270
|
-
try {
|
|
271
|
-
await closeDriver();
|
|
272
|
-
}
|
|
273
|
-
catch (error) {
|
|
274
|
-
const normalized = normalizeExecutionError(id, error);
|
|
275
|
-
runtime.logger.error('能力实时会话失败', {
|
|
276
|
-
event: 'capability.session.failed', requestId, context: { capabilityId: id }, error: normalized,
|
|
277
|
-
});
|
|
278
|
-
end(normalized);
|
|
279
|
-
throw normalized;
|
|
280
|
-
}
|
|
281
|
-
runtime.logger.info('能力实时会话完成', {
|
|
282
|
-
event: 'capability.session.completed', requestId, context: { capabilityId: id },
|
|
283
312
|
});
|
|
284
|
-
|
|
285
|
-
return output;
|
|
286
|
-
})();
|
|
313
|
+
}
|
|
287
314
|
return finishPromise;
|
|
288
315
|
},
|
|
289
316
|
close: async () => {
|
|
290
|
-
if (
|
|
317
|
+
if (outcome) {
|
|
318
|
+
await settlement;
|
|
291
319
|
return;
|
|
292
|
-
try {
|
|
293
|
-
await closeDriver();
|
|
294
|
-
runtime.logger.info('能力实时会话关闭', {
|
|
295
|
-
event: 'capability.session.closed', requestId, context: { capabilityId: id },
|
|
296
|
-
});
|
|
297
|
-
end();
|
|
298
|
-
}
|
|
299
|
-
catch (error) {
|
|
300
|
-
const normalized = normalizeExecutionError(id, error);
|
|
301
|
-
runtime.logger.error('能力实时会话失败', {
|
|
302
|
-
event: 'capability.session.failed', requestId, context: { capabilityId: id }, error: normalized,
|
|
303
|
-
});
|
|
304
|
-
end(normalized);
|
|
305
|
-
throw normalized;
|
|
306
320
|
}
|
|
321
|
+
await settle({ kind: 'closed', error: new AiRuntimeError('realtime_session_closed', `Capability session was closed: ${requestId}`) });
|
|
322
|
+
if (cleanupError)
|
|
323
|
+
throw cleanupError;
|
|
307
324
|
},
|
|
308
325
|
};
|
|
309
326
|
}
|
|
@@ -84,6 +84,8 @@ export function createBailianRealtimeAsrModule(preset, options = {}) {
|
|
|
84
84
|
if (context.signal.aborted)
|
|
85
85
|
throw cancelledError(context.requestId);
|
|
86
86
|
const credential = await apiKey(context);
|
|
87
|
+
if (context.signal.aborted)
|
|
88
|
+
throw cancelledError(context.requestId);
|
|
87
89
|
const transport = context.runtime.realtime;
|
|
88
90
|
if (!transport)
|
|
89
91
|
throw new AiRuntimeError('realtime_transport_missing', 'Host did not provide realtime transport');
|
|
@@ -118,21 +120,32 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
118
120
|
let lastPartial = '';
|
|
119
121
|
let durationMs;
|
|
120
122
|
let terminalError;
|
|
123
|
+
let lastEventKind;
|
|
121
124
|
const segments = [];
|
|
122
125
|
const created = deferred();
|
|
123
126
|
const ready = deferred();
|
|
124
127
|
const finished = deferred();
|
|
128
|
+
const onAbort = () => fail(cancelledError(context.requestId));
|
|
125
129
|
const closeConnection = () => {
|
|
130
|
+
context.signal.removeEventListener('abort', onAbort);
|
|
126
131
|
closePromise ??= Promise.resolve().then(async () => await connection.close(1000, 'session complete'));
|
|
127
132
|
return closePromise;
|
|
128
133
|
};
|
|
129
134
|
const fail = (error) => {
|
|
130
135
|
if (phase === 'finished' || phase === 'closed' || phase === 'failed')
|
|
131
136
|
return;
|
|
137
|
+
const failedPhase = phase;
|
|
132
138
|
phase = 'failed';
|
|
133
|
-
const
|
|
139
|
+
const original = error instanceof AiRuntimeError
|
|
134
140
|
? error
|
|
135
|
-
: new AiRuntimeError('provider_realtime_error',
|
|
141
|
+
: new AiRuntimeError('provider_realtime_error', 'Realtime transport or event handler failed');
|
|
142
|
+
const message = original.message.replace(`[${original.code}] `, '');
|
|
143
|
+
const normalized = new AiRuntimeError(original.code, original.details?.modelId === preset.modelId ? message : `${preset.modelId} (${preset.protocol}): ${message}`, {
|
|
144
|
+
providerId: 'bailian', modelId: preset.modelId, protocol: preset.protocol,
|
|
145
|
+
stage: failedPhase, operation: original.details?.stage,
|
|
146
|
+
eventType: original.details?.eventType ?? lastEventKind,
|
|
147
|
+
providerCode: original.details?.providerCode,
|
|
148
|
+
});
|
|
136
149
|
terminalError = normalized;
|
|
137
150
|
created.reject(normalized);
|
|
138
151
|
ready.reject(normalized);
|
|
@@ -159,6 +172,7 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
159
172
|
await closeConnection();
|
|
160
173
|
};
|
|
161
174
|
const handle = async (event) => {
|
|
175
|
+
lastEventKind = event.kind;
|
|
162
176
|
if (event.kind === 'created') {
|
|
163
177
|
if (preset.protocol !== 'qwen-realtime' || phase !== 'opening') {
|
|
164
178
|
throw new AiRuntimeError('protocol_event_out_of_order', 'Unexpected Bailian realtime created event');
|
|
@@ -206,7 +220,10 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
206
220
|
return;
|
|
207
221
|
}
|
|
208
222
|
if (event.kind === 'error') {
|
|
209
|
-
|
|
223
|
+
// Provider messages can echo request content. Retain the diagnostic code,
|
|
224
|
+
// never the full upstream message in the default error/logging path.
|
|
225
|
+
const providerCode = event.code && /^[\w.-]{1,80}$/.test(event.code) ? event.code : undefined;
|
|
226
|
+
throw new AiRuntimeError('provider_task_failed', 'Server reported a realtime task failure', { providerCode });
|
|
210
227
|
}
|
|
211
228
|
if (event.kind === 'ignored')
|
|
212
229
|
return;
|
|
@@ -238,9 +255,10 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
238
255
|
}
|
|
239
256
|
};
|
|
240
257
|
void consume();
|
|
241
|
-
const onAbort = () => fail(cancelledError(context.requestId));
|
|
242
258
|
context.signal.addEventListener('abort', onAbort, { once: true });
|
|
243
259
|
try {
|
|
260
|
+
if (context.signal.aborted)
|
|
261
|
+
throw cancelledError(context.requestId);
|
|
244
262
|
if (preset.protocol === 'fun-duplex') {
|
|
245
263
|
await connection.send(buildFunStart(preset, input, taskId));
|
|
246
264
|
}
|
|
@@ -252,10 +270,19 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
252
270
|
}
|
|
253
271
|
catch (error) {
|
|
254
272
|
fail(error);
|
|
255
|
-
|
|
256
|
-
|
|
273
|
+
try {
|
|
274
|
+
await closeConnection();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
const primary = terminalError;
|
|
278
|
+
throw new AiRuntimeError(primary.code, primary.message.replace(`[${primary.code}] `, ''), {
|
|
279
|
+
...primary.details, cleanupFailed: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
throw terminalError ?? error;
|
|
257
283
|
}
|
|
258
284
|
return {
|
|
285
|
+
result: finished.promise,
|
|
259
286
|
send: async (chunk) => {
|
|
260
287
|
if (terminalError)
|
|
261
288
|
throw terminalError;
|
|
@@ -263,7 +290,14 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
263
290
|
throw new AiRuntimeError('realtime_session_inactive', 'Bailian realtime session is not active');
|
|
264
291
|
if (chunk.bytes.byteLength === 0)
|
|
265
292
|
return;
|
|
266
|
-
|
|
293
|
+
const data = preset.protocol === 'fun-duplex' ? chunk.bytes : buildQwenAudio(chunk.bytes);
|
|
294
|
+
try {
|
|
295
|
+
await connection.send(data);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
fail(error);
|
|
299
|
+
throw terminalError ?? error;
|
|
300
|
+
}
|
|
267
301
|
},
|
|
268
302
|
finish: () => {
|
|
269
303
|
if (finishPromise)
|
|
@@ -286,6 +320,7 @@ async function openDriver(preset, input, taskId, connection, context) {
|
|
|
286
320
|
}
|
|
287
321
|
return await finished.promise;
|
|
288
322
|
})();
|
|
323
|
+
void finishPromise.catch(error => fail(error));
|
|
289
324
|
return finishPromise;
|
|
290
325
|
},
|
|
291
326
|
close: async () => {
|
|
@@ -116,17 +116,33 @@ export function qwenUsesManualCommit(input) {
|
|
|
116
116
|
return inputOptions(input).turnDetection === 'manual';
|
|
117
117
|
}
|
|
118
118
|
export function parseRealtimeMessage(preset, payload) {
|
|
119
|
-
let
|
|
119
|
+
let eventType;
|
|
120
120
|
try {
|
|
121
|
-
parsed
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(payload);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new AiRuntimeError('invalid_response', 'Server returned invalid JSON');
|
|
127
|
+
}
|
|
128
|
+
const root = record(parsed);
|
|
129
|
+
if (!root)
|
|
130
|
+
throw new AiRuntimeError('invalid_response', 'Server returned a non-object event');
|
|
131
|
+
const discriminator = preset.protocol === 'fun-duplex' ? record(root.header)?.event : root.type;
|
|
132
|
+
if (typeof discriminator !== 'string' || !discriminator.trim()) {
|
|
133
|
+
throw new AiRuntimeError('invalid_response', 'Server event has no event type');
|
|
134
|
+
}
|
|
135
|
+
eventType = /^[a-zA-Z][\w.-]{0,127}$/.test(discriminator) ? discriminator : 'unrecognized-event';
|
|
136
|
+
return preset.protocol === 'fun-duplex' ? parseFunEvent(root) : parseQwenEvent(root);
|
|
122
137
|
}
|
|
123
|
-
catch {
|
|
124
|
-
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!(error instanceof AiRuntimeError))
|
|
140
|
+
throw error;
|
|
141
|
+
throw new AiRuntimeError(error.code, `${preset.modelId} (${preset.protocol}): ${error.message.replace(`[${error.code}] `, '')}`, {
|
|
142
|
+
providerId: 'bailian', modelId: preset.modelId, protocol: preset.protocol,
|
|
143
|
+
stage: 'parse', eventType,
|
|
144
|
+
});
|
|
125
145
|
}
|
|
126
|
-
const root = record(parsed);
|
|
127
|
-
if (!root)
|
|
128
|
-
throw new AiRuntimeError('invalid_response', 'Bailian realtime server returned a non-object event');
|
|
129
|
-
return preset.protocol === 'fun-duplex' ? parseFunEvent(root) : parseQwenEvent(root);
|
|
130
146
|
}
|
|
131
147
|
function parseFunEvent(root) {
|
|
132
148
|
const header = record(root.header);
|
|
@@ -147,6 +163,9 @@ function parseFunEvent(root) {
|
|
|
147
163
|
const sentence = record(record(record(root.payload)?.output)?.sentence);
|
|
148
164
|
if (sentence?.heartbeat === true)
|
|
149
165
|
return { kind: 'ignored', eventType: 'heartbeat' };
|
|
166
|
+
if (typeof sentence?.text !== 'string' || typeof sentence.sentence_end !== 'boolean') {
|
|
167
|
+
throw new AiRuntimeError('invalid_response', 'Result requires text and boolean sentence_end');
|
|
168
|
+
}
|
|
150
169
|
const segment = parseBailianSentence(sentence);
|
|
151
170
|
const transcript = segment?.text ?? string(sentence?.text);
|
|
152
171
|
if (!transcript) {
|
|
@@ -156,16 +175,16 @@ function parseFunEvent(root) {
|
|
|
156
175
|
return { kind: 'ignored', eventType: sentence.sentence_begin === true ? 'empty-sentence-begin' : 'empty-intermediate' };
|
|
157
176
|
}
|
|
158
177
|
if (sentence?.sentence_end === true) {
|
|
159
|
-
throw new AiRuntimeError('invalid_response', '
|
|
178
|
+
throw new AiRuntimeError('invalid_response', 'Final result has no text');
|
|
160
179
|
}
|
|
161
|
-
throw new AiRuntimeError('invalid_response', '
|
|
180
|
+
throw new AiRuntimeError('invalid_response', 'Result has no text or sentence state');
|
|
162
181
|
}
|
|
163
182
|
const duration = number((record(root.usage) ?? record(record(root.payload)?.usage))?.duration);
|
|
164
183
|
return sentence?.sentence_end === true
|
|
165
184
|
? { kind: 'final', text: transcript, segment, durationMs: duration === undefined ? undefined : duration * 1_000 }
|
|
166
185
|
: { kind: 'partial', text: transcript, segment };
|
|
167
186
|
}
|
|
168
|
-
return { kind: 'unknown', eventType: eventType
|
|
187
|
+
return { kind: 'unknown', eventType: /^[a-zA-Z][\w.-]{0,127}$/.test(eventType) ? eventType : 'unrecognized-event' };
|
|
169
188
|
}
|
|
170
189
|
function parseQwenEvent(root) {
|
|
171
190
|
const eventType = string(root.type) ?? '';
|
|
@@ -176,19 +195,42 @@ function parseQwenEvent(root) {
|
|
|
176
195
|
return { kind: 'ready', sessionId: string(record(root.session)?.id) ?? string(root.session_id) };
|
|
177
196
|
}
|
|
178
197
|
if (eventType === 'conversation.item.input_audio_transcription.text') {
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
if (typeof root.text !== 'string' || typeof root.stash !== 'string') {
|
|
199
|
+
throw new AiRuntimeError('invalid_response', 'Partial event requires string text and stash');
|
|
200
|
+
}
|
|
201
|
+
const transcript = root.text + root.stash;
|
|
202
|
+
if (!transcript.trim())
|
|
181
203
|
throw new AiRuntimeError('invalid_response', 'Bailian Qwen realtime partial event has no text');
|
|
182
204
|
return { kind: 'partial', text: transcript };
|
|
183
205
|
}
|
|
184
206
|
if (eventType === 'conversation.item.input_audio_transcription.completed') {
|
|
185
|
-
const transcript = string(root.transcript)
|
|
207
|
+
const transcript = string(root.transcript);
|
|
186
208
|
if (!transcript)
|
|
187
209
|
throw new AiRuntimeError('invalid_response', 'Bailian Qwen realtime final event has no transcript');
|
|
188
210
|
return { kind: 'final', text: transcript };
|
|
189
211
|
}
|
|
190
212
|
if (eventType === 'session.finished')
|
|
191
213
|
return { kind: 'finished' };
|
|
214
|
+
if (eventType === 'input_audio_buffer.speech_started' || eventType === 'input_audio_buffer.speech_stopped') {
|
|
215
|
+
const timestamp = root[eventType.endsWith('speech_started') ? 'audio_start_ms' : 'audio_end_ms'];
|
|
216
|
+
if (!string(root.item_id) || typeof timestamp !== 'number' || !Number.isInteger(timestamp) || timestamp < 0) {
|
|
217
|
+
throw new AiRuntimeError('invalid_response', 'Invalid speech activity notification');
|
|
218
|
+
}
|
|
219
|
+
return { kind: 'ignored', eventType };
|
|
220
|
+
}
|
|
221
|
+
if (eventType === 'input_audio_buffer.committed') {
|
|
222
|
+
if (!string(root.item_id))
|
|
223
|
+
throw new AiRuntimeError('invalid_response', 'Commit notification has no item_id');
|
|
224
|
+
return { kind: 'ignored', eventType };
|
|
225
|
+
}
|
|
226
|
+
if (eventType === 'conversation.item.created') {
|
|
227
|
+
const item = record(root.item);
|
|
228
|
+
if (!string(item?.id) || !Array.isArray(item?.content)
|
|
229
|
+
|| !item.content.some(value => record(value)?.type === 'input_audio' && record(value)?.transcript === null)) {
|
|
230
|
+
throw new AiRuntimeError('invalid_response', 'Invalid audio item notification');
|
|
231
|
+
}
|
|
232
|
+
return { kind: 'ignored', eventType };
|
|
233
|
+
}
|
|
192
234
|
if (eventType === 'error' || eventType === 'conversation.item.input_audio_transcription.failed') {
|
|
193
235
|
const error = record(root.error);
|
|
194
236
|
return {
|
|
@@ -196,5 +238,5 @@ function parseQwenEvent(root) {
|
|
|
196
238
|
message: string(error?.message) ?? string(root.message) ?? 'Bailian Qwen realtime task failed',
|
|
197
239
|
};
|
|
198
240
|
}
|
|
199
|
-
return { kind: 'unknown', eventType: eventType
|
|
241
|
+
return { kind: 'unknown', eventType: /^[a-zA-Z][\w.-]{0,127}$/.test(eventType) ? eventType : 'unrecognized-event' };
|
|
200
242
|
}
|