From 1df50588f6e445a4915d5bd3b3748990012f26e7 Mon Sep 17 00:00:00 2001 From: LingXuanYin <3546599908@qq.com> Date: Tue, 27 Oct 2026 18:13:35 +0800 Subject: [PATCH 1/3] feat!: collapse Qwen TTS to qwen-audio-3.1-tts-flash qwen-tts was retired upstream on 2026-10-10, and the speech service already runs every Qwen speech id as qwen-audio-3.1-tts-flash. The SDK now declares only that id: qwen-tts and qwen-audio-3.0-tts-plus are removed, qwen-audio-3.0-tts-flash becomes qwen-audio-3.1-tts-flash, and every request needs at least 15 Unicode code points. Version 0.2.0. Rebased onto main after higgs-tts was removed there (0.1.42): the README's model-choice rows and the tests that used qwen-tts as sample data now use qwen-audio-3.1-tts-flash, the short-text note no longer points at higgs-tts, and the README says which ids this release removes. --- README.md | 26 +++--- examples/text-to-speech.ts | 2 +- models/qwen-audio-3.0-tts-plus.yaml | 44 ---------- ...ash.yaml => qwen-audio-3.1-tts-flash.yaml} | 15 ++-- models/qwen-tts.yaml | 44 ---------- package.json | 2 +- src/adapters/audio-speech.ts | 7 +- src/builtins.ts | 53 +++--------- test/adapters/audio-speech.test.ts | 85 ++++++++++--------- test/config.test.ts | 28 ++---- test/live/audio-speech-live.test.ts | 22 +---- 11 files changed, 91 insertions(+), 237 deletions(-) delete mode 100644 models/qwen-audio-3.0-tts-plus.yaml rename models/{qwen-audio-3.0-tts-flash.yaml => qwen-audio-3.1-tts-flash.yaml} (66%) delete mode 100644 models/qwen-tts.yaml diff --git a/README.md b/README.md index af30bb8..ce98f22 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Agents and external tools should inspect a model declaration before constructing import { createGenerationClient } from "@neta-art/generation"; const discoveryClient = createGenerationClient(); -const declaration = discoveryClient.getModel("qwen-tts"); +const declaration = discoveryClient.getModel("qwen-audio-3.1-tts-flash"); if (!declaration) throw new Error("Model is unavailable"); console.log(discoveryClient.stringifyModelConfig(declaration.model, { format: "json" })); @@ -220,7 +220,7 @@ The same declarations can be exported as YAML through the existing CLI: ```bash neta-generation models list -neta-generation models export qwen-tts --out ./qwen-tts.yaml +neta-generation models export qwen-audio-3.1-tts-flash --out ./qwen-audio-3.1-tts-flash.yaml neta-generation models export-all --out ./models ``` @@ -308,9 +308,7 @@ const client = createGenerationClient({ - `gpt-image-2` - `z-image-turbo` - `qwen-image-edit` -- `qwen-tts` -- `qwen-audio-3.0-tts-plus` -- `qwen-audio-3.0-tts-flash` +- `qwen-audio-3.1-tts-flash` - `gemini-3.1-flash-image-preview` - `kling-text-to-video` - `kling-image-to-video` @@ -403,30 +401,32 @@ await client.generate({ ## Text to speech +`qwen-tts`, `qwen-audio-3.0-tts-plus` and `qwen-audio-3.0-tts-flash` are removed in this release. Migrate to `qwen-audio-3.1-tts-flash`; its input must have at least 15 Unicode code points. + Each TTS request accepts exactly one non-empty text block and returns one URL audio block. Choose the model from the requested voice mode and priority before constructing the request: | Requirement | Model choice | | --- | --- | -| Create a voice from a text-only description, without reference audio | Use an explicitly requested Qwen variant; otherwise use `qwen-tts` as the deterministic default | -| Clone a voice from one reference recording | Use an explicitly requested Qwen variant, or `qwen-tts`; pass one HTTP(S) reference URL and no `voice_prompt` | -| Use a default voice, including a delegated choice expressed only as any, random, suitable, or natural | `qwen-tts` with a short generic `voice_prompt`, such as a natural, neutral narrator | +| Create a voice from a text-only description, without reference audio | `qwen-audio-3.1-tts-flash` with a `voice_prompt` | +| Clone a voice from one reference recording | `qwen-audio-3.1-tts-flash`; pass one HTTP(S) reference URL and no `voice_prompt` | +| Use a default voice, including a delegated choice expressed only as any, random, suitable, or natural | `qwen-audio-3.1-tts-flash` with a short generic `voice_prompt`, such as a natural, neutral narrator | -- Qwen: `voice_prompt` design OR one-reference clone; `qwen-tts` is the unspecified-design default and accepts any text length; Plus / Flash require at least 15 Unicode code points. +- Qwen: `voice_prompt` design OR one-reference clone via `qwen-audio-3.1-tts-flash`; requires at least 15 Unicode code points. - Conflict: reference + redesign requires user choice before generation. - Dependency: clone prior generated audio. -- Ranking: no declared Qwen quality, latency, or cost order. +- Short text: input under 15 Unicode code points is rejected; ask the user to lengthen it. ```ts await client.generate({ - model: "qwen-tts", - content: [{ type: "text", text: "欢迎使用语音合成功能。" }], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: "欢迎使用语音合成功能,这是一段示例文本。" }], meta: { voice_prompt: "一位沉稳自然的中文播音员,吐字清晰,语速适中", }, }); await client.generate({ - model: "qwen-audio-3.0-tts-flash", + model: "qwen-audio-3.1-tts-flash", content: [ { type: "text", text: "这是一段长度足够并且表达清晰自然的语音合成文本。" }, { type: "audio", source: { type: "url", url: "https://example.com/reference.mp3" } }, diff --git a/examples/text-to-speech.ts b/examples/text-to-speech.ts index 009b59f..db30b94 100644 --- a/examples/text-to-speech.ts +++ b/examples/text-to-speech.ts @@ -5,7 +5,7 @@ if (!apiKey) throw new Error("Set NETA_ROUTER_API_KEY or NETA_API_KEY"); const client = createGenerationClient({ apiKey }); const output = await client.generate({ - model: "qwen-tts", + model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text: "欢迎使用语音合成功能,这是一段示例文本。" }], meta: { voice_prompt: "一位沉稳自然的中文播音员,吐字清晰,语速适中", diff --git a/models/qwen-audio-3.0-tts-plus.yaml b/models/qwen-audio-3.0-tts-plus.yaml deleted file mode 100644 index d6bf7fa..0000000 --- a/models/qwen-audio-3.0-tts-plus.yaml +++ /dev/null @@ -1,44 +0,0 @@ -schema: neta.generation.model.v1 -model: qwen-audio-3.0-tts-plus -title: Qwen Audio 3.0 TTS Plus -description: 'Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.' -adapter: - type: openai.audioSpeech -content: - input: - - type: text - required: true - min: 1 - max: 1 - description: Exactly one non-empty text block to speak, with at least 15 Unicode code points. - - type: audio - required: false - max: 1 - sources: - - url - description: 'Clone: one URL; no voice_prompt. Dependency: use prior generated audio.' -meta: - fields: - voice_prompt: - type: string - optional: true - description: 'Design: custom voice text; no reference audio.' -examples: - - title: Voice design - request: - model: qwen-audio-3.0-tts-plus - content: - - type: text - text: 这是一段长度足够并且表达清晰自然的语音合成测试文本。 - meta: - voice_prompt: 一位沉稳干练的男性播音员声音,吐字清晰有力 - - title: Voice clone - request: - model: qwen-audio-3.0-tts-plus - content: - - type: text - text: 这是一段长度足够并且表达清晰自然的语音合成测试文本。 - - type: audio - source: - type: url - url: https://example.com/reference.mp3 diff --git a/models/qwen-audio-3.0-tts-flash.yaml b/models/qwen-audio-3.1-tts-flash.yaml similarity index 66% rename from models/qwen-audio-3.0-tts-flash.yaml rename to models/qwen-audio-3.1-tts-flash.yaml index 3d111b9..10233cb 100644 --- a/models/qwen-audio-3.0-tts-flash.yaml +++ b/models/qwen-audio-3.1-tts-flash.yaml @@ -1,7 +1,8 @@ schema: neta.generation.model.v1 -model: qwen-audio-3.0-tts-flash -title: Qwen Audio 3.0 TTS Flash -description: 'Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.' +model: qwen-audio-3.1-tts-flash +title: Qwen Audio 3.1 TTS Flash +description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; + never combine/reinterpret. Dependency: clone prior generated audio." adapter: type: openai.audioSpeech content: @@ -16,17 +17,17 @@ content: max: 1 sources: - url - description: 'Clone: one URL; no voice_prompt. Dependency: use prior generated audio.' + description: "Clone: one URL; no voice_prompt. Dependency: use prior generated audio." meta: fields: voice_prompt: type: string optional: true - description: 'Design: custom voice text; no reference audio.' + description: "Design: custom voice text; no reference audio." examples: - title: Voice design request: - model: qwen-audio-3.0-tts-flash + model: qwen-audio-3.1-tts-flash content: - type: text text: 这是一段长度足够并且表达清晰自然的语音合成测试文本。 @@ -34,7 +35,7 @@ examples: voice_prompt: 一位沉稳干练的男性播音员声音,吐字清晰有力 - title: Voice clone request: - model: qwen-audio-3.0-tts-flash + model: qwen-audio-3.1-tts-flash content: - type: text text: 这是一段长度足够并且表达清晰自然的语音合成测试文本。 diff --git a/models/qwen-tts.yaml b/models/qwen-tts.yaml deleted file mode 100644 index 1136fc7..0000000 --- a/models/qwen-tts.yaml +++ /dev/null @@ -1,44 +0,0 @@ -schema: neta.generation.model.v1 -model: qwen-tts -title: Qwen TTS -description: 'Modes: voice_prompt design OR one-reference clone. Default: unspecified Qwen design. Text: any length. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.' -adapter: - type: openai.audioSpeech -content: - input: - - type: text - required: true - min: 1 - max: 1 - description: Exactly one non-empty text block to speak. - - type: audio - required: false - max: 1 - sources: - - url - description: 'Clone: one URL; no voice_prompt. Dependency: use prior generated audio.' -meta: - fields: - voice_prompt: - type: string - optional: true - description: 'Design: custom voice text; no reference audio.' -examples: - - title: Voice design - request: - model: qwen-tts - content: - - type: text - text: 这是一次清晰自然的语音合成测试。 - meta: - voice_prompt: 一位沉稳干练的男性播音员声音,吐字清晰有力 - - title: Voice clone - request: - model: qwen-tts - content: - - type: text - text: 这是一次清晰自然的语音合成测试。 - - type: audio - source: - type: url - url: https://example.com/reference.mp3 diff --git a/package.json b/package.json index b9cb905..d493488 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neta-art/generation", - "version": "0.1.42", + "version": "0.2.0", "description": "A lightweight multimodal generation SDK with built-in model presets and adapter-based provider calls.", "keywords": [ "ai", diff --git a/src/adapters/audio-speech.ts b/src/adapters/audio-speech.ts index 076cdb4..99d65b9 100644 --- a/src/adapters/audio-speech.ts +++ b/src/adapters/audio-speech.ts @@ -8,8 +8,7 @@ import type { } from "../types.js"; const REQUEST_TIMEOUT_MS = 210_000; -const QWEN_MODELS = new Set(["qwen-tts", "qwen-audio-3.0-tts-plus", "qwen-audio-3.0-tts-flash"]); -const QWEN_AUDIO_3_MODELS = new Set(["qwen-audio-3.0-tts-plus", "qwen-audio-3.0-tts-flash"]); +const QWEN_MODEL = "qwen-audio-3.1-tts-flash"; type TextBlock = Extract; type AudioBlock = Extract; @@ -91,14 +90,14 @@ function validateQwen(input: ResolvedGenerationRequest, text: TextBlock, audio: ); } - if (QWEN_AUDIO_3_MODELS.has(input.declaration.model) && Array.from(text.text.trim()).length < 15) { + if (Array.from(text.text.trim()).length < 15) { throw new GenerationValidationError(`${input.declaration.model} requires input of at least 15 Unicode code points`); } } function validateAudioSpeechRequest(input: ResolvedGenerationRequest): void { const { text, audio } = validateCommonContent(input); - if (QWEN_MODELS.has(input.declaration.model)) { + if (input.declaration.model === QWEN_MODEL) { validateQwen(input, text, audio); return; } diff --git a/src/builtins.ts b/src/builtins.ts index 29289a3..ea719a4 100644 --- a/src/builtins.ts +++ b/src/builtins.ts @@ -708,20 +708,13 @@ function geminiImageModel( }; } -function qwenTtsModel( - model: string, - title: string, - description: string, - options: { minimumTextCodePoints?: number } = {}, -): GenerationModelDeclaration { - const text = options.minimumTextCodePoints - ? "这是一段长度足够并且表达清晰自然的语音合成测试文本。" - : "这是一次清晰自然的语音合成测试。"; - return { +const audioSpeechModels = [ + { schema: MODEL_SCHEMA, - model, - title, - description, + model: "qwen-audio-3.1-tts-flash", + title: "Qwen Audio 3.1 TTS Flash", + description: + "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", adapter: { type: "openai.audioSpeech" }, content: { input: [ @@ -730,9 +723,7 @@ function qwenTtsModel( required: true, min: 1, max: 1, - description: options.minimumTextCodePoints - ? `Exactly one non-empty text block to speak, with at least ${options.minimumTextCodePoints} Unicode code points.` - : "Exactly one non-empty text block to speak.", + description: "Exactly one non-empty text block to speak, with at least 15 Unicode code points.", }, { type: "audio", @@ -756,43 +747,23 @@ function qwenTtsModel( { title: "Voice design", request: { - model, - content: [{ type: "text", text }], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: "这是一段长度足够并且表达清晰自然的语音合成测试文本。" }], meta: { voice_prompt: "一位沉稳干练的男性播音员声音,吐字清晰有力" }, }, }, { title: "Voice clone", request: { - model, + model: "qwen-audio-3.1-tts-flash", content: [ - { type: "text", text }, + { type: "text", text: "这是一段长度足够并且表达清晰自然的语音合成测试文本。" }, { type: "audio", source: { type: "url", url: "https://example.com/reference.mp3" } }, ], }, }, ], - }; -} - -const audioSpeechModels = [ - qwenTtsModel( - "qwen-tts", - "Qwen TTS", - "Modes: voice_prompt design OR one-reference clone. Default: unspecified Qwen design. Text: any length. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", - ), - qwenTtsModel( - "qwen-audio-3.0-tts-plus", - "Qwen Audio 3.0 TTS Plus", - "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", - { minimumTextCodePoints: 15 }, - ), - qwenTtsModel( - "qwen-audio-3.0-tts-flash", - "Qwen Audio 3.0 TTS Flash", - "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", - { minimumTextCodePoints: 15 }, - ), + }, ] satisfies GenerationModelDeclaration[]; const meshyTaskParameters = { diff --git a/test/adapters/audio-speech.test.ts b/test/adapters/audio-speech.test.ts index 2de3cdf..8d5d93d 100644 --- a/test/adapters/audio-speech.test.ts +++ b/test/adapters/audio-speech.test.ts @@ -13,6 +13,7 @@ import { const REFERENCE_URL = "https://example.com/reference.mp3"; const SECOND_REFERENCE_URL = "https://example.com/reference-2.mp3"; +const QWEN_TEXT = "这是用于语音合成测试的一整句中文示例文本。"; function routerSuccess( body: Record = {}, @@ -43,8 +44,8 @@ function audio(url = REFERENCE_URL, meta?: Record): GenerationC function qwenDesignRequest(overrides: Partial = {}): GenerateRequest { return { - model: "qwen-tts", - content: [{ type: "text", text: "这是需要朗读的文本。" }], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: QWEN_TEXT }], meta: { voice_prompt: "沉稳清晰的男性播音员声音" }, ...overrides, }; @@ -71,11 +72,11 @@ describe("openai.audioSpeech adapter requests", () => { const { client, calls } = recordingClient(() => routerSuccess({}, { headers: { "x-request-id": "request-primary", "x-oneapi-request-id": "request-fallback" } }), ); - const input = " 原样保留的朗读文本。\n"; + const input = " 原样保留的朗读文本内容,不应被修改。\n"; const voicePrompt = " 沉稳清晰的声音。\n"; const output = await client.generate({ - model: "qwen-tts", + model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text: input }], meta: { voice_prompt: voicePrompt }, }); @@ -87,7 +88,7 @@ describe("openai.audioSpeech adapter requests", () => { new Headers({ Authorization: "Bearer secret-key", "Content-Type": "application/json" }), ); expect(requestBody(calls[0])).toEqual({ - model: "qwen-tts", + model: "qwen-audio-3.1-tts-flash", input, metadata: { voice_prompt: voicePrompt }, }); @@ -103,13 +104,13 @@ describe("openai.audioSpeech adapter requests", () => { it("maps Qwen reference audio and trims only its URL", async () => { const { client, calls } = recordingClient(); await client.generate({ - model: "qwen-tts", - content: [{ type: "text", text: "短句" }, audio(` ${REFERENCE_URL}\n`)], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: QWEN_TEXT }, audio(` ${REFERENCE_URL}\n`)], }); expect(requestBody(calls[0])).toEqual({ - model: "qwen-tts", - input: "短句", + model: "qwen-audio-3.1-tts-flash", + input: QWEN_TEXT, ref_audio: REFERENCE_URL, }); }); @@ -123,8 +124,8 @@ describe("openai.audioSpeech adapter requests", () => { ); expect(requestBody(calls[0])).toEqual({ - model: "qwen-tts", - input: "这是需要朗读的文本。", + model: "qwen-audio-3.1-tts-flash", + input: QWEN_TEXT, metadata: { voice_prompt: "清晰女声" }, }); }); @@ -159,7 +160,9 @@ describe("openai.audioSpeech adapter validation", () => { ])("rejects $name before fetch", async ({ content }) => { const fetchMock = vi.fn(async () => routerSuccess()); const client = createGenerationClient({ apiKey: "key", fetch: fetchMock as typeof fetch }); - await expect(client.generate({ model: "qwen-tts", content })).rejects.toBeInstanceOf(GenerationValidationError); + await expect(client.generate({ model: "qwen-audio-3.1-tts-flash", content })).rejects.toBeInstanceOf( + GenerationValidationError, + ); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -167,8 +170,8 @@ describe("openai.audioSpeech adapter validation", () => { const client = createGenerationClient({ apiKey: "key" }); expect(() => client.validate({ - model: "qwen-tts", - content: [{ type: "text", text: "有效语音文本" }], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: QWEN_TEXT }], meta: { preview_text: "已经失效的文本来源" }, }), ).toThrow("requires one reference audio or meta.voice_prompt"); @@ -182,7 +185,7 @@ describe("openai.audioSpeech adapter validation", () => { { name: "both voice sources", request: qwenDesignRequest({ - content: [{ type: "text", text: "文本" }, audio()], + content: [{ type: "text", text: QWEN_TEXT }, audio()], }), }, { @@ -192,7 +195,7 @@ describe("openai.audioSpeech adapter validation", () => { { name: "audio weight", request: qwenDesignRequest({ - content: [{ type: "text", text: "文本" }, audio(REFERENCE_URL, { weight: 1 })], + content: [{ type: "text", text: QWEN_TEXT }, audio(REFERENCE_URL, { weight: 1 })], meta: {}, }), }, @@ -202,17 +205,17 @@ describe("openai.audioSpeech adapter validation", () => { }); it("enforces Qwen reference limits inside the adapter hook when a declaration is overridden", () => { - const declaration = getBuiltinGenerationModel("qwen-tts"); - if (!declaration) throw new Error("qwen-tts declaration is unavailable"); + const declaration = getBuiltinGenerationModel("qwen-audio-3.1-tts-flash"); + if (!declaration) throw new Error("qwen-audio-3.1-tts-flash declaration is unavailable"); const audioSpec = declaration.content.input.find((spec) => spec.type === "audio"); - if (!audioSpec) throw new Error("qwen-tts audio spec is unavailable"); + if (!audioSpec) throw new Error("qwen-audio-3.1-tts-flash audio spec is unavailable"); audioSpec.max = 2; const client = createGenerationClient({ models: [declaration], includeBuiltinModels: false, apiKey: "key" }); expect(() => client.validate({ - model: "qwen-tts", - content: [{ type: "text", text: "文本" }, audio(), audio(SECOND_REFERENCE_URL)], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: QWEN_TEXT }, audio(), audio(SECOND_REFERENCE_URL)], }), ).toThrow("supports at most one reference audio"); }); @@ -237,23 +240,23 @@ describe("openai.audioSpeech adapter validation", () => { }); it.each([ - { model: "qwen-audio-3.0-tts-plus", text: "a".repeat(14) }, - { model: "qwen-audio-3.0-tts-flash", text: "😀".repeat(14) }, - ])("rejects $model input below 15 Unicode code points", ({ model, text }) => { + { text: "a".repeat(14) }, + { text: "😀".repeat(14) }, + ])("rejects qwen-audio-3.1-tts-flash input below 15 Unicode code points ($text)", ({ text }) => { const client = createGenerationClient({ apiKey: "key" }); - expect(() => client.validate({ model, content: [{ type: "text", text }, audio()] })).toThrow( - "requires input of at least 15 Unicode code points", - ); + expect(() => + client.validate({ model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text }, audio()] }), + ).toThrow("requires input of at least 15 Unicode code points"); }); it.each([ - { model: "qwen-audio-3.0-tts-plus", text: "a".repeat(15) }, - { model: "qwen-audio-3.0-tts-flash", text: ` ${"😀".repeat(15)} ` }, - { model: "qwen-tts", text: "短" }, - { model: "qwen-tts", text: "a".repeat(40) }, - ])("accepts the $model input boundary", ({ model, text }) => { + { text: "a".repeat(15) }, + { text: ` ${"😀".repeat(15)} ` }, + ])("accepts the qwen-audio-3.1-tts-flash input boundary ($text)", ({ text }) => { const client = createGenerationClient({ apiKey: "key" }); - expect(() => client.validate({ model, content: [{ type: "text", text }, audio()] })).not.toThrow(); + expect(() => + client.validate({ model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text }, audio()] }), + ).not.toThrow(); }); it.each<{ label: string; request: GenerateRequest }>([ @@ -279,22 +282,22 @@ describe("openai.audioSpeech adapter validation", () => { "s3://bucket/file.mp3", ])("rejects unsupported reference URL %s", (url) => { const client = createGenerationClient({ apiKey: "key" }); - expect(() => client.validate({ model: "qwen-tts", content: [{ type: "text", text: "文本" }, audio(url)] })).toThrow( - GenerationValidationError, - ); + expect(() => + client.validate({ model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text: QWEN_TEXT }, audio(url)] }), + ).toThrow(GenerationValidationError); }); it("rejects base64 reference audio through model validation", () => { const client = createGenerationClient({ apiKey: "key" }); expect(() => client.validate({ - model: "qwen-tts", + model: "qwen-audio-3.1-tts-flash", content: [ - { type: "text", text: "文本" }, + { type: "text", text: QWEN_TEXT }, { type: "audio", source: { type: "base64", mediaType: "audio/mpeg", data: "abc" } }, ], }), - ).toThrow("audio source is not supported by qwen-tts: base64"); + ).toThrow("audio source is not supported by qwen-audio-3.1-tts-flash: base64"); }); }); @@ -484,8 +487,8 @@ describe("openai.audioSpeech adapter errors and diagnostics", () => { }); await client.generate({ - model: "qwen-tts", - content: [{ type: "text", text: "文本" }, audio(reference)], + model: "qwen-audio-3.1-tts-flash", + content: [{ type: "text", text: QWEN_TEXT }, audio(reference)], }); const serialized = JSON.stringify(events); diff --git a/test/config.test.ts b/test/config.test.ts index 42c0a3b..285e8d1 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -90,9 +90,7 @@ describe("config", () => { expect(byCategory["3d"].sort()).toEqual([...expected["3d"]]); expect(byCategory.decision.sort()).toEqual([...expected.decision]); for (const model of [ - "qwen-tts", - "qwen-audio-3.0-tts-plus", - "qwen-audio-3.0-tts-flash", + "qwen-audio-3.1-tts-flash", "suno_cover_chirp_v5", "suno_image_to_song_chirp_v5", "suno_infill_chirp_v5", @@ -284,7 +282,7 @@ describe("config", () => { it("publishes agent-discoverable audio speech declarations", () => { const client = createGenerationClient(); - const qwenModels = ["qwen-tts", "qwen-audio-3.0-tts-plus", "qwen-audio-3.0-tts-flash"]; + const qwenModels = ["qwen-audio-3.1-tts-flash"]; for (const model of qwenModels) { const declaration = client.getModel(model); @@ -304,23 +302,7 @@ describe("config", () => { expect(JSON.parse(client.stringifyModelConfig(model, { format: "json" }))).toEqual(declaration); } - const qwen = client.getModel("qwen-tts"); - expect(qwen?.description).toBe( - "Modes: voice_prompt design OR one-reference clone. Default: unspecified Qwen design. Text: any length. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", - ); - expect(qwen?.content.input.find((input) => input.type === "text")?.description).not.toContain( - "Unicode code points", - ); - - const plus = client.getModel("qwen-audio-3.0-tts-plus"); - expect(plus?.description).toBe( - "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", - ); - expect(plus?.content.input.find((input) => input.type === "text")?.description).toContain( - "at least 15 Unicode code points", - ); - - const flash = client.getModel("qwen-audio-3.0-tts-flash"); + const flash = client.getModel("qwen-audio-3.1-tts-flash"); expect(flash?.description).toBe( "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", ); @@ -341,7 +323,7 @@ describe("config", () => { expect(readme).toContain("Qwen: `voice_prompt` design OR one-reference clone"); expect(readme).toContain("Conflict: reference + redesign requires user choice before generation"); expect(readme).toContain("Dependency: clone prior generated audio"); - expect(readme).toContain("Ranking: no declared Qwen quality, latency, or cost order"); + expect(readme).toContain("Short text: input under 15 Unicode code points is rejected"); expect(readme).not.toMatch(/quality prioritized over latency|latency prioritized over maximum quality/); }); @@ -578,7 +560,7 @@ describe("config", () => { it("does not publish the retired Qwen preview field", async () => { const client = createGenerationClient({ apiKey: "test" }); - for (const model of ["qwen-tts", "qwen-audio-3.0-tts-plus", "qwen-audio-3.0-tts-flash"]) { + for (const model of ["qwen-audio-3.1-tts-flash"]) { expect(client.stringifyModelConfig(model)).not.toContain("preview_text"); } expect(await readFile(join(process.cwd(), "README.md"), "utf8")).not.toContain("preview_text"); diff --git a/test/live/audio-speech-live.test.ts b/test/live/audio-speech-live.test.ts index d32e073..aedfb6c 100644 --- a/test/live/audio-speech-live.test.ts +++ b/test/live/audio-speech-live.test.ts @@ -47,30 +47,16 @@ liveDescribe("audio speech live router smoke", () => { { name: "qwen voice design", request: { - model: "qwen-tts", - content: [text(`这是基础模型设计音色端到端测试,运行编号${runId}。`)], + model: "qwen-audio-3.1-tts-flash", + content: [text(`这是设计音色端到端测试,运行编号${runId}。`)], meta: { voice_prompt: "一位沉稳干练的男性播音员声音,吐字清晰有力" }, }, }, { name: "qwen voice clone", request: { - model: "qwen-tts", - content: [text(`这是基础模型克隆音色端到端测试,运行编号${runId}。`), audio(REFERENCE_A)], - }, - }, - { - name: "qwen audio 3 plus", - request: { - model: "qwen-audio-3.0-tts-plus", - content: [text(`这是增强版本语音合成端到端测试文本,运行编号${runId}。`), audio(REFERENCE_A)], - }, - }, - { - name: "qwen audio 3 flash", - request: { - model: "qwen-audio-3.0-tts-flash", - content: [text(`这是快速版本语音合成端到端测试文本,运行编号${runId}。`), audio(REFERENCE_A)], + model: "qwen-audio-3.1-tts-flash", + content: [text(`这是克隆音色端到端测试,运行编号${runId}。`), audio(REFERENCE_A)], }, }, ]; From 72789c3009d92625000630937d39bb89dd8edb51 Mon Sep 17 00:00:00 2001 From: LingXuanYin <3546599908@qq.com> Date: Tue, 27 Oct 2026 21:02:16 +0800 Subject: [PATCH 2/3] feat: declare qwen-audio-3.1-tts-plus beside qwen-audio-3.1-tts-flash The product adds Alibaba's quality tier of Qwen-Audio-TTS. qwen-audio-3.1-tts-plus has the same request shape as 3.1-flash (voice_prompt design or one-reference clone, at least 15 Unicode code points), so both declarations now come from one helper and the adapter applies the Qwen checks to both ids. The README keeps Flash as the default and uses Plus only when the user or an external policy selects it. Alibaba describes Flash as low latency and Plus as quality, but the SDK has not measured either, and since 0.1.x the README has not asked agents to infer a ranking from model names. Each declaration's description says its tier, and both model YAMLs are regenerated from the built-ins. --- README.md | 6 ++-- models/qwen-audio-3.1-tts-flash.yaml | 4 +-- models/qwen-audio-3.1-tts-plus.yaml | 45 ++++++++++++++++++++++++++++ src/adapters/audio-speech.ts | 4 +-- src/builtins.ts | 32 +++++++++++++------- test/adapters/audio-speech.test.ts | 39 ++++++++++++++++++++++++ test/config.test.ts | 25 ++++++++++------ test/live/audio-speech-live.test.ts | 8 +++++ 8 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 models/qwen-audio-3.1-tts-plus.yaml diff --git a/README.md b/README.md index ce98f22..adfef6f 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ const client = createGenerationClient({ - `z-image-turbo` - `qwen-image-edit` - `qwen-audio-3.1-tts-flash` +- `qwen-audio-3.1-tts-plus` - `gemini-3.1-flash-image-preview` - `kling-text-to-video` - `kling-image-to-video` @@ -401,7 +402,7 @@ await client.generate({ ## Text to speech -`qwen-tts`, `qwen-audio-3.0-tts-plus` and `qwen-audio-3.0-tts-flash` are removed in this release. Migrate to `qwen-audio-3.1-tts-flash`; its input must have at least 15 Unicode code points. +`qwen-tts`, `qwen-audio-3.0-tts-plus` and `qwen-audio-3.0-tts-flash` are removed in this release. Migrate to `qwen-audio-3.1-tts-flash`, or to `qwen-audio-3.1-tts-plus`, which this release adds; both need input of at least 15 Unicode code points. Each TTS request accepts exactly one non-empty text block and returns one URL audio block. Choose the model from the requested voice mode and priority before constructing the request: @@ -411,7 +412,8 @@ Each TTS request accepts exactly one non-empty text block and returns one URL au | Clone a voice from one reference recording | `qwen-audio-3.1-tts-flash`; pass one HTTP(S) reference URL and no `voice_prompt` | | Use a default voice, including a delegated choice expressed only as any, random, suitable, or natural | `qwen-audio-3.1-tts-flash` with a short generic `voice_prompt`, such as a natural, neutral narrator | -- Qwen: `voice_prompt` design OR one-reference clone via `qwen-audio-3.1-tts-flash`; requires at least 15 Unicode code points. +- Qwen: `voice_prompt` design OR one-reference clone via `qwen-audio-3.1-tts-flash` (default) or `qwen-audio-3.1-tts-plus`; both require at least 15 Unicode code points. +- Tier: use `qwen-audio-3.1-tts-plus` only when the user or an external policy selects it; otherwise use Flash. Alibaba describes Flash as its low-latency tier and Plus as its quality tier for audiobooks and dubbing; the SDK has not measured either. - Conflict: reference + redesign requires user choice before generation. - Dependency: clone prior generated audio. - Short text: input under 15 Unicode code points is rejected; ask the user to lengthen it. diff --git a/models/qwen-audio-3.1-tts-flash.yaml b/models/qwen-audio-3.1-tts-flash.yaml index 10233cb..2f53bba 100644 --- a/models/qwen-audio-3.1-tts-flash.yaml +++ b/models/qwen-audio-3.1-tts-flash.yaml @@ -1,8 +1,8 @@ schema: neta.generation.model.v1 model: qwen-audio-3.1-tts-flash title: Qwen Audio 3.1 TTS Flash -description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; - never combine/reinterpret. Dependency: clone prior generated audio." +description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: default Qwen TTS. + Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio." adapter: type: openai.audioSpeech content: diff --git a/models/qwen-audio-3.1-tts-plus.yaml b/models/qwen-audio-3.1-tts-plus.yaml new file mode 100644 index 0000000..4146d93 --- /dev/null +++ b/models/qwen-audio-3.1-tts-plus.yaml @@ -0,0 +1,45 @@ +schema: neta.generation.model.v1 +model: qwen-audio-3.1-tts-plus +title: Qwen Audio 3.1 TTS Plus +description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: only when + user/policy selects Plus. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio." +adapter: + type: openai.audioSpeech +content: + input: + - type: text + required: true + min: 1 + max: 1 + description: Exactly one non-empty text block to speak, with at least 15 Unicode code points. + - type: audio + required: false + max: 1 + sources: + - url + description: "Clone: one URL; no voice_prompt. Dependency: use prior generated audio." +meta: + fields: + voice_prompt: + type: string + optional: true + description: "Design: custom voice text; no reference audio." +examples: + - title: Voice design + request: + model: qwen-audio-3.1-tts-plus + content: + - type: text + text: 这是一段长度足够并且表达清晰自然的语音合成测试文本。 + meta: + voice_prompt: 一位沉稳干练的男性播音员声音,吐字清晰有力 + - title: Voice clone + request: + model: qwen-audio-3.1-tts-plus + content: + - type: text + text: 这是一段长度足够并且表达清晰自然的语音合成测试文本。 + - type: audio + source: + type: url + url: https://example.com/reference.mp3 diff --git a/src/adapters/audio-speech.ts b/src/adapters/audio-speech.ts index 99d65b9..afc438e 100644 --- a/src/adapters/audio-speech.ts +++ b/src/adapters/audio-speech.ts @@ -8,7 +8,7 @@ import type { } from "../types.js"; const REQUEST_TIMEOUT_MS = 210_000; -const QWEN_MODEL = "qwen-audio-3.1-tts-flash"; +const QWEN_MODELS: ReadonlySet = new Set(["qwen-audio-3.1-tts-flash", "qwen-audio-3.1-tts-plus"]); type TextBlock = Extract; type AudioBlock = Extract; @@ -97,7 +97,7 @@ function validateQwen(input: ResolvedGenerationRequest, text: TextBlock, audio: function validateAudioSpeechRequest(input: ResolvedGenerationRequest): void { const { text, audio } = validateCommonContent(input); - if (input.declaration.model === QWEN_MODEL) { + if (QWEN_MODELS.has(input.declaration.model)) { validateQwen(input, text, audio); return; } diff --git a/src/builtins.ts b/src/builtins.ts index ea719a4..6218b90 100644 --- a/src/builtins.ts +++ b/src/builtins.ts @@ -708,13 +708,14 @@ function geminiImageModel( }; } -const audioSpeechModels = [ - { +const QWEN_TTS_TEXT = "这是一段长度足够并且表达清晰自然的语音合成测试文本。"; + +function qwenAudio31TtsModel(model: string, title: string, tier: string): GenerationModelDeclaration { + return { schema: MODEL_SCHEMA, - model: "qwen-audio-3.1-tts-flash", - title: "Qwen Audio 3.1 TTS Flash", - description: - "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", + model, + title, + description: `Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. ${tier} Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.`, adapter: { type: "openai.audioSpeech" }, content: { input: [ @@ -747,23 +748,32 @@ const audioSpeechModels = [ { title: "Voice design", request: { - model: "qwen-audio-3.1-tts-flash", - content: [{ type: "text", text: "这是一段长度足够并且表达清晰自然的语音合成测试文本。" }], + model, + content: [{ type: "text", text: QWEN_TTS_TEXT }], meta: { voice_prompt: "一位沉稳干练的男性播音员声音,吐字清晰有力" }, }, }, { title: "Voice clone", request: { - model: "qwen-audio-3.1-tts-flash", + model, content: [ - { type: "text", text: "这是一段长度足够并且表达清晰自然的语音合成测试文本。" }, + { type: "text", text: QWEN_TTS_TEXT }, { type: "audio", source: { type: "url", url: "https://example.com/reference.mp3" } }, ], }, }, ], - }, + }; +} + +const audioSpeechModels = [ + qwenAudio31TtsModel("qwen-audio-3.1-tts-flash", "Qwen Audio 3.1 TTS Flash", "Tier: default Qwen TTS."), + qwenAudio31TtsModel( + "qwen-audio-3.1-tts-plus", + "Qwen Audio 3.1 TTS Plus", + "Tier: only when user/policy selects Plus.", + ), ] satisfies GenerationModelDeclaration[]; const meshyTaskParameters = { diff --git a/test/adapters/audio-speech.test.ts b/test/adapters/audio-speech.test.ts index 8d5d93d..bedc9a3 100644 --- a/test/adapters/audio-speech.test.ts +++ b/test/adapters/audio-speech.test.ts @@ -101,6 +101,25 @@ describe("openai.audioSpeech adapter requests", () => { ]); }); + it("sends qwen-audio-3.1-tts-plus as given while preserving Qwen input and voice prompt", async () => { + const { client, calls } = recordingClient(); + const input = " 原样保留的朗读文本内容,不应被修改。\n"; + const voicePrompt = " 沉稳清晰的声音。\n"; + + await client.generate({ + model: "qwen-audio-3.1-tts-plus", + content: [{ type: "text", text: input }], + meta: { voice_prompt: voicePrompt }, + }); + + expect(calls).toHaveLength(1); + expect(requestBody(calls[0])).toEqual({ + model: "qwen-audio-3.1-tts-plus", + input, + metadata: { voice_prompt: voicePrompt }, + }); + }); + it("maps Qwen reference audio and trims only its URL", async () => { const { client, calls } = recordingClient(); await client.generate({ @@ -220,6 +239,16 @@ describe("openai.audioSpeech adapter validation", () => { ).toThrow("supports at most one reference audio"); }); + it("accepts the qwen-audio-3.1-tts-plus declaration and a valid request", () => { + const declaration = getBuiltinGenerationModel("qwen-audio-3.1-tts-plus"); + if (!declaration) throw new Error("qwen-audio-3.1-tts-plus declaration is unavailable"); + const client = createGenerationClient({ models: [declaration], includeBuiltinModels: false, apiKey: "key" }); + + expect(() => + client.validate({ model: "qwen-audio-3.1-tts-plus", content: [{ type: "text", text: QWEN_TEXT }, audio()] }), + ).not.toThrow(); + }); + it("rejects a higgs-tts declaration at the audio speech adapter", () => { const declaration = { schema: "neta.generation.model.v1", @@ -249,6 +278,16 @@ describe("openai.audioSpeech adapter validation", () => { ).toThrow("requires input of at least 15 Unicode code points"); }); + it.each([ + { text: "a".repeat(14) }, + { text: "😀".repeat(14) }, + ])("rejects qwen-audio-3.1-tts-plus input below 15 Unicode code points ($text)", ({ text }) => { + const client = createGenerationClient({ apiKey: "key" }); + expect(() => + client.validate({ model: "qwen-audio-3.1-tts-plus", content: [{ type: "text", text }, audio()] }), + ).toThrow("requires input of at least 15 Unicode code points"); + }); + it.each([ { text: "a".repeat(15) }, { text: ` ${"😀".repeat(15)} ` }, diff --git a/test/config.test.ts b/test/config.test.ts index 285e8d1..4fb1477 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -91,6 +91,7 @@ describe("config", () => { expect(byCategory.decision.sort()).toEqual([...expected.decision]); for (const model of [ "qwen-audio-3.1-tts-flash", + "qwen-audio-3.1-tts-plus", "suno_cover_chirp_v5", "suno_image_to_song_chirp_v5", "suno_infill_chirp_v5", @@ -282,7 +283,7 @@ describe("config", () => { it("publishes agent-discoverable audio speech declarations", () => { const client = createGenerationClient(); - const qwenModels = ["qwen-audio-3.1-tts-flash"]; + const qwenModels = ["qwen-audio-3.1-tts-flash", "qwen-audio-3.1-tts-plus"]; for (const model of qwenModels) { const declaration = client.getModel(model); @@ -302,13 +303,18 @@ describe("config", () => { expect(JSON.parse(client.stringifyModelConfig(model, { format: "json" }))).toEqual(declaration); } - const flash = client.getModel("qwen-audio-3.1-tts-flash"); - expect(flash?.description).toBe( - "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", - ); - expect(flash?.content.input.find((input) => input.type === "text")?.description).toContain( - "at least 15 Unicode code points", - ); + for (const [model, tier] of [ + ["qwen-audio-3.1-tts-flash", "Tier: default Qwen TTS."], + ["qwen-audio-3.1-tts-plus", "Tier: only when user/policy selects Plus."], + ] as const) { + const declaration = client.getModel(model); + expect(declaration?.description).toBe( + `Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. ${tier} Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.`, + ); + expect(declaration?.content.input.find((input) => input.type === "text")?.description).toContain( + "at least 15 Unicode code points", + ); + } expect(client.getModel("higgs-tts")).toBeNull(); }); @@ -324,6 +330,7 @@ describe("config", () => { expect(readme).toContain("Conflict: reference + redesign requires user choice before generation"); expect(readme).toContain("Dependency: clone prior generated audio"); expect(readme).toContain("Short text: input under 15 Unicode code points is rejected"); + expect(readme).toContain("use `qwen-audio-3.1-tts-plus` only when the user or an external policy selects it"); expect(readme).not.toMatch(/quality prioritized over latency|latency prioritized over maximum quality/); }); @@ -560,7 +567,7 @@ describe("config", () => { it("does not publish the retired Qwen preview field", async () => { const client = createGenerationClient({ apiKey: "test" }); - for (const model of ["qwen-audio-3.1-tts-flash"]) { + for (const model of ["qwen-audio-3.1-tts-flash", "qwen-audio-3.1-tts-plus"]) { expect(client.stringifyModelConfig(model)).not.toContain("preview_text"); } expect(await readFile(join(process.cwd(), "README.md"), "utf8")).not.toContain("preview_text"); diff --git a/test/live/audio-speech-live.test.ts b/test/live/audio-speech-live.test.ts index aedfb6c..f0f22ef 100644 --- a/test/live/audio-speech-live.test.ts +++ b/test/live/audio-speech-live.test.ts @@ -52,6 +52,14 @@ liveDescribe("audio speech live router smoke", () => { meta: { voice_prompt: "一位沉稳干练的男性播音员声音,吐字清晰有力" }, }, }, + { + name: "qwen plus voice design", + request: { + model: "qwen-audio-3.1-tts-plus", + content: [text(`这是高质量音色设计端到端测试,运行编号${runId}。`)], + meta: { voice_prompt: "一位沉稳干练的男性播音员声音,吐字清晰有力" }, + }, + }, { name: "qwen voice clone", request: { From d2c91283016513711d8aa8c151da3e79eba8349e Mon Sep 17 00:00:00 2001 From: LingXuanYin <3546599908@qq.com> Date: Tue, 27 Oct 2026 21:37:25 +0800 Subject: [PATCH 3/3] fix: state each Qwen 3.1 tier in its declaration and share the tests Review follow-up: the descriptions now say what each tier is (Flash: default, Alibaba's low-latency tier; Plus: Alibaba's quality tier, only when the user or an external policy selects it), the README says the table names the default tier and Plus may replace it in any row, the Qwen request and input-length tests run over both ids, and the description test asserts literal strings. --- README.md | 4 +- models/qwen-audio-3.1-tts-flash.yaml | 4 +- models/qwen-audio-3.1-tts-plus.yaml | 5 +- src/builtins.ts | 6 +-- test/adapters/audio-speech.test.ts | 68 ++++++++++------------------ test/config.test.ts | 21 +++++---- 6 files changed, 44 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index adfef6f..a53e98b 100644 --- a/README.md +++ b/README.md @@ -406,14 +406,14 @@ await client.generate({ Each TTS request accepts exactly one non-empty text block and returns one URL audio block. Choose the model from the requested voice mode and priority before constructing the request: -| Requirement | Model choice | +| Requirement | Model choice (default tier) | | --- | --- | | Create a voice from a text-only description, without reference audio | `qwen-audio-3.1-tts-flash` with a `voice_prompt` | | Clone a voice from one reference recording | `qwen-audio-3.1-tts-flash`; pass one HTTP(S) reference URL and no `voice_prompt` | | Use a default voice, including a delegated choice expressed only as any, random, suitable, or natural | `qwen-audio-3.1-tts-flash` with a short generic `voice_prompt`, such as a natural, neutral narrator | - Qwen: `voice_prompt` design OR one-reference clone via `qwen-audio-3.1-tts-flash` (default) or `qwen-audio-3.1-tts-plus`; both require at least 15 Unicode code points. -- Tier: use `qwen-audio-3.1-tts-plus` only when the user or an external policy selects it; otherwise use Flash. Alibaba describes Flash as its low-latency tier and Plus as its quality tier for audiobooks and dubbing; the SDK has not measured either. +- Tier: the table names `qwen-audio-3.1-tts-flash`, the default. In any row, use `qwen-audio-3.1-tts-plus` instead only when the user or an external policy selects it. Alibaba describes Flash as its low-latency tier and Plus as its quality tier for audiobooks and dubbing; the SDK has not measured either. - Conflict: reference + redesign requires user choice before generation. - Dependency: clone prior generated audio. - Short text: input under 15 Unicode code points is rejected; ask the user to lengthen it. diff --git a/models/qwen-audio-3.1-tts-flash.yaml b/models/qwen-audio-3.1-tts-flash.yaml index 2f53bba..9fb2859 100644 --- a/models/qwen-audio-3.1-tts-flash.yaml +++ b/models/qwen-audio-3.1-tts-flash.yaml @@ -1,8 +1,8 @@ schema: neta.generation.model.v1 model: qwen-audio-3.1-tts-flash title: Qwen Audio 3.1 TTS Flash -description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: default Qwen TTS. - Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio." +description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: default; + Alibaba's low-latency tier. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio." adapter: type: openai.audioSpeech content: diff --git a/models/qwen-audio-3.1-tts-plus.yaml b/models/qwen-audio-3.1-tts-plus.yaml index 4146d93..281293b 100644 --- a/models/qwen-audio-3.1-tts-plus.yaml +++ b/models/qwen-audio-3.1-tts-plus.yaml @@ -1,8 +1,9 @@ schema: neta.generation.model.v1 model: qwen-audio-3.1-tts-plus title: Qwen Audio 3.1 TTS Plus -description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: only when - user/policy selects Plus. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio." +description: "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: Alibaba's quality + tier (audiobooks, dubbing); only when user or external policy selects it. Conflict: ask user; never + combine/reinterpret. Dependency: clone prior generated audio." adapter: type: openai.audioSpeech content: diff --git a/src/builtins.ts b/src/builtins.ts index 6218b90..90cf796 100644 --- a/src/builtins.ts +++ b/src/builtins.ts @@ -715,7 +715,7 @@ function qwenAudio31TtsModel(model: string, title: string, tier: string): Genera schema: MODEL_SCHEMA, model, title, - description: `Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. ${tier} Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.`, + description: `Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: ${tier}. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.`, adapter: { type: "openai.audioSpeech" }, content: { input: [ @@ -768,11 +768,11 @@ function qwenAudio31TtsModel(model: string, title: string, tier: string): Genera } const audioSpeechModels = [ - qwenAudio31TtsModel("qwen-audio-3.1-tts-flash", "Qwen Audio 3.1 TTS Flash", "Tier: default Qwen TTS."), + qwenAudio31TtsModel("qwen-audio-3.1-tts-flash", "Qwen Audio 3.1 TTS Flash", "default; Alibaba's low-latency tier"), qwenAudio31TtsModel( "qwen-audio-3.1-tts-plus", "Qwen Audio 3.1 TTS Plus", - "Tier: only when user/policy selects Plus.", + "Alibaba's quality tier (audiobooks, dubbing); only when user or external policy selects it", ), ] satisfies GenerationModelDeclaration[]; diff --git a/test/adapters/audio-speech.test.ts b/test/adapters/audio-speech.test.ts index bedc9a3..3660cb4 100644 --- a/test/adapters/audio-speech.test.ts +++ b/test/adapters/audio-speech.test.ts @@ -68,7 +68,10 @@ function requestBody(call: { init: RequestInit } | undefined): Record { - it("sends the fixed wire contract and preserves Qwen input and voice prompt", async () => { + it.each([ + "qwen-audio-3.1-tts-flash", + "qwen-audio-3.1-tts-plus", + ])("sends the fixed wire contract for %s and preserves Qwen input and voice prompt", async (model) => { const { client, calls } = recordingClient(() => routerSuccess({}, { headers: { "x-request-id": "request-primary", "x-oneapi-request-id": "request-fallback" } }), ); @@ -76,7 +79,7 @@ describe("openai.audioSpeech adapter requests", () => { const voicePrompt = " 沉稳清晰的声音。\n"; const output = await client.generate({ - model: "qwen-audio-3.1-tts-flash", + model, content: [{ type: "text", text: input }], meta: { voice_prompt: voicePrompt }, }); @@ -88,7 +91,7 @@ describe("openai.audioSpeech adapter requests", () => { new Headers({ Authorization: "Bearer secret-key", "Content-Type": "application/json" }), ); expect(requestBody(calls[0])).toEqual({ - model: "qwen-audio-3.1-tts-flash", + model, input, metadata: { voice_prompt: voicePrompt }, }); @@ -101,25 +104,6 @@ describe("openai.audioSpeech adapter requests", () => { ]); }); - it("sends qwen-audio-3.1-tts-plus as given while preserving Qwen input and voice prompt", async () => { - const { client, calls } = recordingClient(); - const input = " 原样保留的朗读文本内容,不应被修改。\n"; - const voicePrompt = " 沉稳清晰的声音。\n"; - - await client.generate({ - model: "qwen-audio-3.1-tts-plus", - content: [{ type: "text", text: input }], - meta: { voice_prompt: voicePrompt }, - }); - - expect(calls).toHaveLength(1); - expect(requestBody(calls[0])).toEqual({ - model: "qwen-audio-3.1-tts-plus", - input, - metadata: { voice_prompt: voicePrompt }, - }); - }); - it("maps Qwen reference audio and trims only its URL", async () => { const { client, calls } = recordingClient(); await client.generate({ @@ -268,34 +252,28 @@ describe("openai.audioSpeech adapter validation", () => { ); }); - it.each([ - { text: "a".repeat(14) }, - { text: "😀".repeat(14) }, - ])("rejects qwen-audio-3.1-tts-flash input below 15 Unicode code points ($text)", ({ text }) => { - const client = createGenerationClient({ apiKey: "key" }); - expect(() => - client.validate({ model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text }, audio()] }), - ).toThrow("requires input of at least 15 Unicode code points"); - }); + const QWEN_MODELS = ["qwen-audio-3.1-tts-flash", "qwen-audio-3.1-tts-plus"]; - it.each([ - { text: "a".repeat(14) }, - { text: "😀".repeat(14) }, - ])("rejects qwen-audio-3.1-tts-plus input below 15 Unicode code points ($text)", ({ text }) => { + it.each( + QWEN_MODELS.flatMap((model) => [ + { model, text: "a".repeat(14) }, + { model, text: "😀".repeat(14) }, + ]), + )("rejects $model input below 15 Unicode code points ($text)", ({ model, text }) => { const client = createGenerationClient({ apiKey: "key" }); - expect(() => - client.validate({ model: "qwen-audio-3.1-tts-plus", content: [{ type: "text", text }, audio()] }), - ).toThrow("requires input of at least 15 Unicode code points"); + expect(() => client.validate({ model, content: [{ type: "text", text }, audio()] })).toThrow( + "requires input of at least 15 Unicode code points", + ); }); - it.each([ - { text: "a".repeat(15) }, - { text: ` ${"😀".repeat(15)} ` }, - ])("accepts the qwen-audio-3.1-tts-flash input boundary ($text)", ({ text }) => { + it.each( + QWEN_MODELS.flatMap((model) => [ + { model, text: "a".repeat(15) }, + { model, text: ` ${"😀".repeat(15)} ` }, + ]), + )("accepts the $model input boundary ($text)", ({ model, text }) => { const client = createGenerationClient({ apiKey: "key" }); - expect(() => - client.validate({ model: "qwen-audio-3.1-tts-flash", content: [{ type: "text", text }, audio()] }), - ).not.toThrow(); + expect(() => client.validate({ model, content: [{ type: "text", text }, audio()] })).not.toThrow(); }); it.each<{ label: string; request: GenerateRequest }>([ diff --git a/test/config.test.ts b/test/config.test.ts index 4fb1477..dcef3c5 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -303,15 +303,14 @@ describe("config", () => { expect(JSON.parse(client.stringifyModelConfig(model, { format: "json" }))).toEqual(declaration); } - for (const [model, tier] of [ - ["qwen-audio-3.1-tts-flash", "Tier: default Qwen TTS."], - ["qwen-audio-3.1-tts-plus", "Tier: only when user/policy selects Plus."], - ] as const) { - const declaration = client.getModel(model); - expect(declaration?.description).toBe( - `Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. ${tier} Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.`, - ); - expect(declaration?.content.input.find((input) => input.type === "text")?.description).toContain( + expect(client.getModel("qwen-audio-3.1-tts-flash")?.description).toBe( + "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: default; Alibaba's low-latency tier. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", + ); + expect(client.getModel("qwen-audio-3.1-tts-plus")?.description).toBe( + "Modes: voice_prompt design OR one-reference clone. Text: >=15 Unicode code points. Tier: Alibaba's quality tier (audiobooks, dubbing); only when user or external policy selects it. Conflict: ask user; never combine/reinterpret. Dependency: clone prior generated audio.", + ); + for (const model of qwenModels) { + expect(client.getModel(model)?.content.input.find((input) => input.type === "text")?.description).toContain( "at least 15 Unicode code points", ); } @@ -330,7 +329,9 @@ describe("config", () => { expect(readme).toContain("Conflict: reference + redesign requires user choice before generation"); expect(readme).toContain("Dependency: clone prior generated audio"); expect(readme).toContain("Short text: input under 15 Unicode code points is rejected"); - expect(readme).toContain("use `qwen-audio-3.1-tts-plus` only when the user or an external policy selects it"); + expect(readme).toContain( + "In any row, use `qwen-audio-3.1-tts-plus` instead only when the user or an external policy selects it", + ); expect(readme).not.toMatch(/quality prioritized over latency|latency prioritized over maximum quality/); });