diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 5d196bb9ead..dc1f322bae0 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -91,7 +91,7 @@ Settings are organized into categories. Most settings should be placed within th | `general.voice.enabled` | boolean | Enable voice dictation in the prompt input. Also toggleable with the `/voice` command. Requires a transcription model (`voiceModel`) to be configured. | `false` | | `general.voice.mode` | enum | How push-to-talk behaves: `"hold"` to talk while the key is held, or `"tap"` to start and tap (or pause) to stop and submit. | `"hold"` | | `general.voice.language` | string | Preferred spoken language for voice transcription (e.g. `"english"`, `"chinese"`). Leave empty to auto-detect. | `""` | -| `general.voice.keytermsFile` | string | Path to a custom keyterms file (one term per line, `#` for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to `.qwen/voice-keyterms.txt` when present. Read only in trusted workspaces. Only applies to Qwen ASR models (`qwen3-asr-*`). | `""` | +| `general.voice.keytermsFile` | string | Path to a custom keyterms file (one term per line, `#` for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to `.qwen/voice-keyterms.txt` when present. Read only in trusted workspaces. Only applies to Qwen ASR models (`qwen3-asr-*` and the `qwen-audio-*` ASR family). | `""` | | `general.voice.refineTranscript` | boolean | Clean up voice transcripts with the fast model before inserting them — removes filler words and fixes recognition errors while preserving meaning. Falls back to the raw transcript on failure, and is skipped when no fast model is configured. | `true` | | `general.cleanupPeriodDays` | number | Days to retain `~/.qwen/file-history/` session backups used by `/rewind`. Backups older than this are removed by a background pass that runs at most once per day. `0` = minimum retention (~1 hour): keeps sessions touched in the last hour plus the currently active one. Changes take effect after restart. | `30` | | `general.language` | enum | Language for the user interface. Use `"auto"` to detect from system settings, or a language code (e.g. `"zh-CN"`, `"fr"`). Custom codes can be added by placing JS locale files in `~/.qwen/locales/`. See [i18n](../features/language). Requires restart. | `"auto"` | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fb84f79644f..2c527bd0444 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -506,7 +506,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: '', description: - 'Path to a custom keyterms file (one term per line, "#" for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to ".qwen/voice-keyterms.txt" when present. The file contents are sent to the ASR provider and it is read only in trusted workspaces. Only applies to Qwen ASR models (qwen3-asr-*).', + 'Path to a custom keyterms file (one term per line, "#" for comments) that biases voice transcription toward domain-specific terms. Relative paths resolve from the workspace root; defaults to ".qwen/voice-keyterms.txt" when present. The file contents are sent to the ASR provider and it is read only in trusted workspaces. Only applies to Qwen ASR models (qwen3-asr-* and the qwen-audio-* ASR family).', showInDialog: false, }, refineTranscript: { diff --git a/packages/cli/src/services/voice-model.ts b/packages/cli/src/services/voice-model.ts index 087dfaabac0..73fa013f6e4 100644 --- a/packages/cli/src/services/voice-model.ts +++ b/packages/cli/src/services/voice-model.ts @@ -13,7 +13,15 @@ export type VoiceTransport = | 'dashscope-task-realtime' | 'unsupported'; -/** Map a model id to the ASR transport it uses, or 'unsupported'. */ +/** + * Map a model id to the ASR transport it uses, or 'unsupported'. + * + * The `qwen-audio-` family (Model Studio Token Plan, #10932) follows + * the same split as `qwen3-asr-flash`: `*-realtime` and `*-asr-flash-streaming` + * ids speak the OpenAI realtime WebSocket dialect, while bare and date-suffixed + * `*-asr-flash` ids use the batch chat/completions shape. Other ids in the + * family (filetrans, tts) are not dictation transports and stay unsupported. + */ export function resolveVoiceTransport(model: string): VoiceTransport { const id = model.toLowerCase(); if (/^qwen3-asr-flash-realtime(?:-|$)/.test(id)) { @@ -22,6 +30,16 @@ export function resolveVoiceTransport(model: string): VoiceTransport { if (/^qwen3-asr-flash(?:-\d{4}-\d{2}-\d{2})?$/.test(id)) { return 'qwen-asr-chat'; } + if ( + /^qwen-audio-[\d.]+-(?:asr-flash-(?:realtime|streaming)|realtime)(?:-|$)/.test( + id, + ) + ) { + return 'qwen-asr-realtime'; + } + if (/^qwen-audio-[\d.]+-asr-flash(?:-\d{4}-\d{2}-\d{2})?$/.test(id)) { + return 'qwen-asr-chat'; + } if (/^(fun-asr|paraformer).*realtime(?:-|$)/.test(id)) { return 'dashscope-task-realtime'; } diff --git a/packages/cli/src/ui/voice/voice-model.test.ts b/packages/cli/src/ui/voice/voice-model.test.ts index 5d073e74353..32c8ce90161 100644 --- a/packages/cli/src/ui/voice/voice-model.test.ts +++ b/packages/cli/src/ui/voice/voice-model.test.ts @@ -9,6 +9,7 @@ import { AuthType, type AvailableModel } from '@qwen-code/qwen-code-core'; import { isSelectableVoiceModel, isTranscribableVoiceModel, + resolveVoiceTransport, } from './voice-model.js'; function model(overrides: Partial): AvailableModel { @@ -51,4 +52,68 @@ describe('voice model guards', () => { isSelectableVoiceModel(model({ id: 'qwen3-asr-flash-filetrans' })), ).toBe(false); }); + + it('isSelectableVoiceModel accepts the qwen-audio Token Plan ASR family', () => { + expect( + isSelectableVoiceModel(model({ id: 'qwen-audio-3.0-asr-flash' })), + ).toBe(true); + expect( + isSelectableVoiceModel(model({ id: 'qwen-audio-3.0-realtime-plus' })), + ).toBe(true); + }); +}); + +describe('resolveVoiceTransport', () => { + it('routes the qwen-audio Token Plan ASR family', () => { + // Bare and date-suffixed asr ids use the batch chat shape (#10932). + expect(resolveVoiceTransport('qwen-audio-3.0-asr-flash')).toBe( + 'qwen-asr-chat', + ); + expect(resolveVoiceTransport('qwen-audio-3.0-asr-flash-2026-09-01')).toBe( + 'qwen-asr-chat', + ); + // Streaming variants speak the OpenAI realtime WebSocket dialect. + expect(resolveVoiceTransport('qwen-audio-3.0-asr-flash-streaming')).toBe( + 'qwen-asr-realtime', + ); + expect(resolveVoiceTransport('qwen-audio-3.0-asr-flash-realtime')).toBe( + 'qwen-asr-realtime', + ); + expect(resolveVoiceTransport('qwen-audio-3.0-realtime-plus')).toBe( + 'qwen-asr-realtime', + ); + expect(resolveVoiceTransport('Qwen-Audio-3.0-ASR-Flash')).toBe( + 'qwen-asr-chat', + ); + }); + + it('keeps routing the legacy qwen3-asr and dashscope ids', () => { + expect(resolveVoiceTransport('qwen3-asr-flash')).toBe('qwen-asr-chat'); + expect(resolveVoiceTransport('qwen3-asr-flash-2025-08-20')).toBe( + 'qwen-asr-chat', + ); + expect(resolveVoiceTransport('qwen3-asr-flash-realtime')).toBe( + 'qwen-asr-realtime', + ); + expect(resolveVoiceTransport('fun-asr-realtime')).toBe( + 'dashscope-task-realtime', + ); + expect(resolveVoiceTransport('paraformer-realtime-v2')).toBe( + 'dashscope-task-realtime', + ); + }); + + it('rejects non-ASR ids, including filetrans and tts in the family', () => { + expect(resolveVoiceTransport('gpt-4o')).toBe('unsupported'); + expect(resolveVoiceTransport('custom:asr')).toBe('unsupported'); + expect(resolveVoiceTransport('qwen3-asr-flash-filetrans')).toBe( + 'unsupported', + ); + expect(resolveVoiceTransport('qwen-audio-3.0-asr-flash-filetrans')).toBe( + 'unsupported', + ); + expect(resolveVoiceTransport('qwen-audio-3.0-tts-plus')).toBe( + 'unsupported', + ); + }); }); diff --git a/packages/web-shell/client/voice/voiceModels.test.ts b/packages/web-shell/client/voice/voiceModels.test.ts new file mode 100644 index 00000000000..13c58001015 --- /dev/null +++ b/packages/web-shell/client/voice/voiceModels.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { extractVoiceModels, isVoiceModelId } from './voiceModels'; + +describe('isVoiceModelId', () => { + it('accepts the qwen-audio Token Plan ASR family (#10932)', () => { + expect(isVoiceModelId('qwen-audio-3.0-asr-flash')).toBe(true); + expect(isVoiceModelId('qwen-audio-3.0-asr-flash-2026-09-01')).toBe(true); + expect(isVoiceModelId('qwen-audio-3.0-asr-flash-streaming')).toBe(true); + expect(isVoiceModelId('qwen-audio-3.0-asr-flash-realtime')).toBe(true); + expect(isVoiceModelId('qwen-audio-3.0-realtime-plus')).toBe(true); + expect(isVoiceModelId('Qwen-Audio-3.0-ASR-Flash')).toBe(true); + }); + + it('keeps accepting the legacy ASR ids', () => { + expect(isVoiceModelId('qwen3-asr-flash')).toBe(true); + expect(isVoiceModelId('qwen3-asr-flash-2025-08-20')).toBe(true); + expect(isVoiceModelId('qwen3-asr-flash-realtime')).toBe(true); + expect(isVoiceModelId('fun-asr-realtime')).toBe(true); + expect(isVoiceModelId('paraformer-realtime-v2')).toBe(true); + }); + + it('rejects non-ASR ids, including filetrans and tts in the family', () => { + expect(isVoiceModelId('gpt-4o')).toBe(false); + expect(isVoiceModelId('qwen3-asr-flash-filetrans')).toBe(false); + expect(isVoiceModelId('qwen-audio-3.0-asr-flash-filetrans')).toBe(false); + expect(isVoiceModelId('qwen-audio-3.0-tts-plus')).toBe(false); + }); +}); + +describe('extractVoiceModels', () => { + it('lists qwen-audio ASR ids from a providers status and dedupes', () => { + const status = { + providers: [ + { + authType: 'USE_OPENAI', + models: [ + { + baseModelId: 'qwen-audio-3.0-asr-flash', + name: 'Qwen Audio ASR', + baseUrl: 'https://token-plan.example/compatible-mode/v1', + contextLimit: 8000, + }, + { + // Registered with an auth suffix; the base id is the voice id. + baseModelId: 'qwen-audio-3.0-asr-flash', + name: 'Qwen Audio ASR (duplicate provider)', + }, + { baseModelId: 'qwen-max', name: 'Qwen Max' }, + { baseModelId: 'qwen-audio-3.0-asr-flash-filetrans' }, + { isRuntime: true, baseModelId: 'qwen3-asr-flash-realtime' }, + ], + }, + ], + }; + const options = extractVoiceModels(status); + expect(options).toHaveLength(1); + expect(options[0]).toEqual({ + id: 'qwen-audio-3.0-asr-flash', + label: 'Qwen Audio ASR', + authType: 'USE_OPENAI', + baseUrl: 'https://token-plan.example/compatible-mode/v1', + contextWindow: 8000, + modalities: { audio: true }, + }); + }); + + it('returns an empty list for an undefined status', () => { + expect(extractVoiceModels(undefined)).toEqual([]); + }); +}); diff --git a/packages/web-shell/client/voice/voiceModels.ts b/packages/web-shell/client/voice/voiceModels.ts index 7d84b2cd289..e0d84b555ca 100644 --- a/packages/web-shell/client/voice/voiceModels.ts +++ b/packages/web-shell/client/voice/voiceModels.ts @@ -25,6 +25,10 @@ export function isVoiceModelId(id: string): boolean { return ( /^qwen3-asr-flash-realtime(?:-|$)/.test(s) || /^qwen3-asr-flash(?:-\d{4}-\d{2}-\d{2})?$/.test(s) || + /^qwen-audio-[\d.]+-(?:asr-flash-(?:realtime|streaming)|realtime)(?:-|$)/.test( + s, + ) || + /^qwen-audio-[\d.]+-asr-flash(?:-\d{4}-\d{2}-\d{2})?$/.test(s) || /^(fun-asr|paraformer).*realtime(?:-|$)/.test(s) ); }