mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
[codex] fix Qwen realtime compatibility (#977)
* fix: improve Qwen realtime compatibility * fix: preserve Qwen endpoint query parameters
This commit is contained in:
@@ -53,6 +53,7 @@ class FakeClient {
|
||||
}
|
||||
|
||||
let lastUpstream: FakeUpstream | null = null
|
||||
const origQwenRealtimeWsUrl = process.env.QWEN_REALTIME_WS_URL
|
||||
const FakeWebSocket = function FakeWebSocket(url: string, opts?: unknown) {
|
||||
const u = new FakeUpstream(url, opts)
|
||||
lastUpstream = u
|
||||
@@ -65,13 +66,47 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
lastUpstream = null
|
||||
if (origQwenRealtimeWsUrl === undefined) delete process.env.QWEN_REALTIME_WS_URL
|
||||
else process.env.QWEN_REALTIME_WS_URL = origQwenRealtimeWsUrl
|
||||
})
|
||||
|
||||
function newClient() {
|
||||
return new FakeClient({ apiKey: 'k', model: 'qwen3-omni-flash-realtime', language: 'en', voiceName: 'Cherry' }) as unknown as ServerWebSocket<unknown> & FakeClient
|
||||
return new FakeClient({ apiKey: 'k', model: 'qwen3.5-omni-flash-realtime', language: 'en', voiceName: 'Cherry' }) as unknown as ServerWebSocket<unknown> & FakeClient
|
||||
}
|
||||
|
||||
describe('createQwenProxyWebSocketHandler ack-gate', () => {
|
||||
test('preserves the international DashScope realtime endpoint by default', () => {
|
||||
delete process.env.QWEN_REALTIME_WS_URL
|
||||
const handler = createQwenProxyWebSocketHandler(FakeWebSocket)
|
||||
const client = newClient()
|
||||
|
||||
handler.open(client)
|
||||
|
||||
expect(lastUpstream?.url).toBe('wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-flash-realtime')
|
||||
})
|
||||
|
||||
test('allows overriding the DashScope realtime endpoint with QWEN_REALTIME_WS_URL', () => {
|
||||
process.env.QWEN_REALTIME_WS_URL = 'wss://example.test/realtime'
|
||||
const handler = createQwenProxyWebSocketHandler(FakeWebSocket)
|
||||
const client = newClient()
|
||||
|
||||
handler.open(client)
|
||||
|
||||
expect(lastUpstream?.url).toBe('wss://example.test/realtime?model=qwen3.5-omni-flash-realtime')
|
||||
})
|
||||
|
||||
test('preserves existing query parameters in QWEN_REALTIME_WS_URL', () => {
|
||||
process.env.QWEN_REALTIME_WS_URL = 'wss://example.test/realtime?workspace=abc&model=old'
|
||||
const handler = createQwenProxyWebSocketHandler(FakeWebSocket)
|
||||
const client = newClient()
|
||||
|
||||
handler.open(client)
|
||||
|
||||
expect(lastUpstream?.url).toBe(
|
||||
'wss://example.test/realtime?workspace=abc&model=qwen3.5-omni-flash-realtime'
|
||||
)
|
||||
})
|
||||
|
||||
test('queues client frames until upstream acks hub-owned session.update with session.updated', () => {
|
||||
const handler = createQwenProxyWebSocketHandler(FakeWebSocket)
|
||||
const client = newClient()
|
||||
@@ -85,6 +120,7 @@ describe('createQwenProxyWebSocketHandler ack-gate', () => {
|
||||
const hubSetup = JSON.parse(upstream.sent[0] as string) as { type: string; session: { instructions: string } }
|
||||
expect(hubSetup.type).toBe('session.update')
|
||||
expect(typeof hubSetup.session.instructions).toBe('string')
|
||||
expect(hubSetup).not.toHaveProperty('session.tool_choice')
|
||||
|
||||
handler.message(client, JSON.stringify({ type: 'response.create' }))
|
||||
handler.message(client, JSON.stringify({ type: 'conversation.item.create', item: { type: 'message' } }))
|
||||
|
||||
@@ -53,9 +53,11 @@ export function createQwenProxyWebSocketHandler(
|
||||
return {
|
||||
open(clientWs) {
|
||||
const data = clientWs.data as { apiKey: string; model: string; language?: string; voiceName?: string; systemInstruction?: string }
|
||||
const upstreamUrl = `${process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE}?model=${encodeURIComponent(data.model)}`
|
||||
const upstreamBase = process.env.QWEN_REALTIME_WS_URL || QWEN_WS_BASE
|
||||
const upstreamUrl = new URL(upstreamBase)
|
||||
upstreamUrl.searchParams.set('model', data.model)
|
||||
|
||||
const upstream = new WebSocketImpl(upstreamUrl, {
|
||||
const upstream = new WebSocketImpl(upstreamUrl.toString(), {
|
||||
headers: { 'Authorization': `Bearer ${data.apiKey}` }
|
||||
})
|
||||
|
||||
@@ -106,12 +108,17 @@ export function createQwenProxyWebSocketHandler(
|
||||
}
|
||||
} catch { /* client gone */ }
|
||||
}
|
||||
upstream.onerror = () => {
|
||||
upstream.onerror = (event) => {
|
||||
pendingSetupMap.delete(clientWs)
|
||||
setupAckedMap.delete(clientWs)
|
||||
pendingClientFrames.delete(clientWs)
|
||||
pendingClientBytes.delete(clientWs)
|
||||
upstreamMap.delete(clientWs)
|
||||
console.error('[Voice][QwenProxy] Upstream WebSocket error', {
|
||||
upstreamBase,
|
||||
model: data.model,
|
||||
error: event instanceof Error ? event.message : String(event)
|
||||
})
|
||||
try { clientWs.close(1011, 'Upstream error') } catch { /* */ }
|
||||
}
|
||||
upstream.onclose = (event) => {
|
||||
@@ -119,6 +126,14 @@ export function createQwenProxyWebSocketHandler(
|
||||
setupAckedMap.delete(clientWs)
|
||||
pendingClientFrames.delete(clientWs)
|
||||
pendingClientBytes.delete(clientWs)
|
||||
if (event.code !== 1000) {
|
||||
console.warn('[Voice][QwenProxy] Upstream WebSocket closed', {
|
||||
upstreamBase,
|
||||
model: data.model,
|
||||
code: event.code,
|
||||
reason: event.reason
|
||||
})
|
||||
}
|
||||
try { clientWs.close(toClientCloseCode(event.code), event.reason || 'Upstream closed') } catch { /* */ }
|
||||
upstreamMap.delete(clientWs)
|
||||
}
|
||||
|
||||
+4
-2
@@ -399,8 +399,10 @@ export function buildQwenSessionUpdateMessage(
|
||||
silence_duration_ms: 800,
|
||||
prefix_padding_ms: 300
|
||||
},
|
||||
tools,
|
||||
tool_choice: 'auto'
|
||||
// Qwen-Omni-Realtime decides whether to call tools automatically.
|
||||
// The official realtime API does not support OpenAI-style tool_choice /
|
||||
// parallel_tool_calls parameters on session.update.
|
||||
tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user