diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 158d6c48e7c..9c5acc348c7 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -157,12 +157,28 @@ The key concept is **Model Providers** (`modelProviders`): Qwen Code supports mu #### Supported protocols -| Protocol | `modelProviders` key | Environment variables | Providers | -| ----------------- | -------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` (alias: `QWEN_MODEL`) | OpenAI, Azure OpenAI, OpenRouter, Requesty, ModelScope, Alibaba Cloud, any OpenAI-compatible endpoint | -| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude | -| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | -| Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` (sets `GOOGLE_GENAI_USE_VERTEXAI=true`; uses the `gemini` protocol) | Google Vertex AI | +| Protocol | `modelProviders` key | Environment variables | Providers | +| ------------------ | -------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` (alias: `QWEN_MODEL`) | OpenAI, Azure OpenAI, OpenRouter, Requesty, ModelScope, Alibaba Cloud, any OpenAI-compatible endpoint | +| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude | +| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | +| Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` (sets `GOOGLE_GENAI_USE_VERTEXAI=true`; uses the `gemini` protocol) | Google Vertex AI | +| DashScope (native) | `dashscope` | `DASHSCOPE_API_KEY`, `DASHSCOPE_BASE_URL`, `DASHSCOPE_MODEL` | Alibaba ModelStudio's native DashScope API (`qwen3.8-max`) | + +#### DashScope (native API) + +`dashscope` connects to Alibaba ModelStudio's native DashScope generation API instead of +the OpenAI-compatible endpoint used by the `openai` protocol above. Get an API key from +[Alibaba Cloud ModelStudio](https://modelstudio.console.alibabacloud.com/), then either: + +- Run `/auth`, select **Alibaba ModelStudio**, then choose **Native DashScope API** from + the sub-menu, or +- Set `DASHSCOPE_API_KEY` and run `qwen --auth-type=dashscope`. + +`dashscope` uses the exact same API key as the ModelStudio **Standard API Key** option +(the `openai`-protocol, OpenAI-compatible setup) — only the wire protocol differs. See +[Alibaba ModelStudio — Native DashScope API](model-providers.md#alibaba-modelstudio--native-dashscope-api-dashscope-protocol) +for prompt caching, thinking control, and full `modelProviders` configuration details. #### Step 1: Configure models and providers in `~/.qwen/settings.json` diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index b30a74f187c..d1e3b5f5068 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -26,13 +26,14 @@ Below are comprehensive configuration examples for different authentication type The `modelProviders` object keys must be valid `authType` values. Currently supported auth types are: -| Auth Type | Description | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) | -| `anthropic` | Anthropic Claude API | -| `gemini` | Google Gemini API | -| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | -| `vertex-ai` | Google Vertex AI (uses the `gemini` protocol and the `@google/genai` SDK in Vertex AI mode; selecting it sets `GOOGLE_GENAI_USE_VERTEXAI=true`) | +| Auth Type | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) | +| `anthropic` | Anthropic Claude API | +| `gemini` | Google Gemini API | +| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | +| `vertex-ai` | Google Vertex AI (uses the `gemini` protocol and the `@google/genai` SDK in Vertex AI mode; selecting it sets `GOOGLE_GENAI_USE_VERTEXAI=true`) | +| `dashscope` | Alibaba ModelStudio's native DashScope generation API (not OpenAI-compatible) — see [Alibaba ModelStudio — Native DashScope API](#alibaba-modelstudio--native-dashscope-api-dashscope-protocol) below | > [!warning] > A provider id that is neither a built-in protocol nor mapped via `providerProtocol` (e.g. a typo like `"openai-custom"`) cannot be routed, so its whole entry is **skipped** with a warning — its models simply won't appear in the `/model` picker. Use one of the supported auth type values above for built-in providers, or add a [`providerProtocol`](#custom-provider-ids-providerprotocol) mapping for a custom id. @@ -70,6 +71,7 @@ Qwen Code uses the following official SDKs to send requests to each provider: | `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK | | `gemini` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK | | `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) | +| `dashscope` | Built-in `fetch`-based transport speaking DashScope's native wire format directly (no SDK) | This means the `baseUrl` you configure should be compatible with the corresponding SDK's expected API format. For example, when using `openai` auth type, the endpoint must accept OpenAI API format requests. @@ -204,6 +206,94 @@ This auth type supports not only OpenAI's official API but also any OpenAI-compa } ``` +### Alibaba ModelStudio — Native DashScope API (`dashscope` protocol) + +`dashscope` is a separate auth type from `openai`: instead of going through the +OpenAI-compatible `/compatible-mode/v1` endpoint, it speaks Alibaba ModelStudio's +**native** DashScope generation API +(`/api/v1/services/aigc/multimodal-generation/generation`) directly. It currently +supports the `qwen3.8-max` model. + +Prefer `dashscope` over the OpenAI-compatible `openai` protocol (with a DashScope +`baseUrl`) when you want: + +- **Explicit prompt caching** with separate cache-read and cache-creation token counts + reported in usage — cached input is billed at roughly 0.1x, cache writes at roughly + 1.25x. Cache markers are placed automatically; disable them per model with + `generationConfig.enableCacheControl: false`. +- **Raw `reasoning_content` streaming** — thinking output arrives in its own field, with + no `` tag scraping. +- **Native parallel tool calls.** + +Both `dashscope` and the OpenAI-compatible `openai` protocol (pointed at a DashScope +`baseUrl`) read the same `DASHSCOPE_API_KEY` — only the wire protocol differs. + +#### Regions + +| Region | `baseUrl` | +| ------------------- | -------------------------------------------- | +| Singapore (default) | `https://dashscope-intl.aliyuncs.com/api/v1` | +| Beijing | `https://dashscope.aliyuncs.com/api/v1` | +| US (Virginia) | `https://dashscope-us.aliyuncs.com/api/v1` | + +#### Settings example + +```json +{ + "security": { "auth": { "selectedType": "dashscope" } }, + "model": { "name": "qwen3.8-max" }, + "modelProviders": { + "dashscope": [ + { + "id": "qwen3.8-max", + "name": "[ModelStudio Native] qwen3.8-max", + "baseUrl": "https://dashscope-intl.aliyuncs.com/api/v1", + "envKey": "DASHSCOPE_API_KEY", + "generationConfig": { + "contextWindowSize": 1000000, + "reasoning": { "effort": "medium" } + } + } + ] + } +} +``` + +#### Headless / CI recipe + +```bash +export DASHSCOPE_API_KEY="sk-..." +export DASHSCOPE_MODEL="qwen3.8-max" +qwen --auth-type=dashscope -p "your prompt" +``` + +Optionally set `DASHSCOPE_BASE_URL` to pick a different region than the Singapore +default. + +#### Thinking control + +`generationConfig.reasoning` maps to the native `reasoning_effort` parameter: + +- `reasoning: { "effort": "low" | "medium" | "xhigh" }` sets + `reasoning_effort` to that native tier. Effort controls reject `high` and + `max`; if either remains in persisted configuration, request conversion + clamps it to `xhigh`. +- `reasoning: false` disables thinking (`reasoning_effort: "none"`). + +Native-only knobs not covered by `reasoning` — e.g. `thinking_budget`, +`max_completion_tokens`, `enable_search` — can be passed through via +`generationConfig.extra_body`. + +#### Caveats + +- Forcing a tool choice (function-calling mode `ANY` / a specific function name) disables + thinking for that request — this is a native DashScope API constraint, not a qwen-code + limitation. +- Embeddings are not supported on this protocol; use the OpenAI-compatible `openai` + protocol against a DashScope embeddings endpoint instead. +- Multimodal input currently supports images. Video/PDF/audio content blocks are passed + through but not yet battle-tested. + ### Google Gemini (`gemini`) ```json diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 2f10407e7e0..a94aa47a37a 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -916,6 +916,7 @@ export const AUTH_PREFLIGHT_ENV_KEYS: Readonly< anthropic: ['ANTHROPIC_API_KEY'], gemini: ['GEMINI_API_KEY'], 'vertex-ai': ['GOOGLE_API_KEY'], + dashscope: ['DASHSCOPE_API_KEY'], }; /** diff --git a/packages/cli/src/acp-integration/authMethods.test.ts b/packages/cli/src/acp-integration/authMethods.test.ts index 4f76df3fc91..667b23a32ce 100644 --- a/packages/cli/src/acp-integration/authMethods.test.ts +++ b/packages/cli/src/acp-integration/authMethods.test.ts @@ -17,6 +17,7 @@ describe('ACP auth methods', () => { expect(authMethods.map((method) => method.id)).toEqual([ AuthType.USE_OPENAI, + AuthType.USE_DASHSCOPE, ]); }); @@ -25,6 +26,7 @@ describe('ACP auth methods', () => { expect(authMethods.map((method) => method.id)).toEqual([ AuthType.USE_OPENAI, + AuthType.USE_DASHSCOPE, ]); }); }); diff --git a/packages/cli/src/acp-integration/authMethods.ts b/packages/cli/src/acp-integration/authMethods.ts index 75132391aed..77d8f3e84d9 100644 --- a/packages/cli/src/acp-integration/authMethods.ts +++ b/packages/cli/src/acp-integration/authMethods.ts @@ -18,6 +18,13 @@ export function buildAuthMethods(): AuthMethod[] { args: ['--auth-type=openai'], }, }, + { + id: AuthType.USE_DASHSCOPE, + name: 'Use DashScope API key (native)', + description: + 'Requires setting the `DASHSCOPE_API_KEY` environment variable', + _meta: { type: 'terminal', args: ['--auth-type=dashscope'] }, + }, ]; } diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index 276fd5fb96c..5abc549a2ec 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -36,6 +36,7 @@ describe('validateAuthMethod', () => { delete process.env['GOOGLE_API_KEY']; delete process.env['IDEALAB_KEY']; delete process.env['TOKEN_PLAN_KEY']; + delete process.env['DASHSCOPE_API_KEY']; }); it('should return null for USE_OPENAI with default env key', () => { @@ -49,6 +50,17 @@ describe('validateAuthMethod', () => { ); }); + it('should return null for USE_DASHSCOPE with default env key', () => { + process.env['DASHSCOPE_API_KEY'] = 'fake-key'; + expect(validateAuthMethod(AuthType.USE_DASHSCOPE)).toBeNull(); + }); + + it('should return an error message for USE_DASHSCOPE if no API key is available', () => { + expect(validateAuthMethod(AuthType.USE_DASHSCOPE)).toBe( + "Missing API key for DashScope native auth. Set settings.security.auth.apiKey, or set the 'DASHSCOPE_API_KEY' environment variable.", + ); + }); + it('should return null for USE_OPENAI with custom envKey from modelProviders', () => { vi.mocked(settings.loadSettings).mockReturnValue({ merged: { diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 3e0abcebf46..dbbabb2ea4b 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -23,6 +23,7 @@ const DEFAULT_ENV_KEYS: Record = { [AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY', [AuthType.USE_GEMINI]: 'GEMINI_API_KEY', [AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY', + [AuthType.USE_DASHSCOPE]: 'DASHSCOPE_API_KEY', }; /** @@ -259,6 +260,32 @@ export function validateAuthMethod( return null; } + if (authMethod === AuthType.USE_DASHSCOPE) { + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( + authMethod, + settings.merged, + config, + ); + if (!hasKey) { + const envKeyHint = checkedEnvKey + ? `'${checkedEnvKey}'` + : "'DASHSCOPE_API_KEY'"; + if (isExplicitEnvKey) { + // Explicit envKey configured - only suggest setting the env var + return t( + 'Missing API key for DashScope native auth. Set the {{envKeyHint}} environment variable.', + { envKeyHint }, + ); + } + // Default env key - can use either apiKey or env var + return t( + 'Missing API key for DashScope native auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', + { envKeyHint }, + ); + } + return null; + } + if (authMethod === AuthType.QWEN_OAUTH) { // Qwen OAuth free tier was discontinued on 2026-04-15. // Block new OAuth setups; existing cached tokens still work until server rejects them. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d27226420ef..028849dfc12 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -955,6 +955,7 @@ export async function parseArguments(): Promise { AuthType.QWEN_OAUTH, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, + AuthType.USE_DASHSCOPE, ], description: 'Authentication type', }) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2b0dbf6ce15..5b6ef61a89e 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -435,6 +435,9 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().permissions.showInDialog).toBe(false); expect(getSettingsSchema().mcpServers.showInDialog).toBe(false); expect(getSettingsSchema().telemetry.showInDialog).toBe(false); + expect( + getSettingsSchema().model.properties.reasoningEffort.showInDialog, + ).toBe(true); // Check that some settings are appropriately hidden expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(true); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e8c92f65bbb..0d3416066ea 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -375,7 +375,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {} as ProviderProtocolConfig, description: - 'Maps a custom modelProviders provider id to the SDK protocol that routes its requests (e.g. {"idealab": "openai"}). Lets a custom provider id reuse a built-in protocol. Built-in provider ids (openai, gemini, anthropic, vertex-ai, qwen-oauth) are routed automatically and need no entry.', + 'Maps a custom modelProviders provider id to the SDK protocol that routes its requests (e.g. {"idealab": "openai"}). Lets a custom provider id reuse a built-in protocol. Built-in provider ids (openai, gemini, anthropic, vertex-ai, qwen-oauth, dashscope) are routed automatically and need no entry.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, }, diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 98de51f7ed4..8d316b4d9ff 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1969,8 +1969,8 @@ export default { '(Useu les fletxes ↑ ↓ per navegar, Return per seleccionar, Ctrl+C per sortir)\n', 'Switch to plan mode or exit plan mode': 'Canviar al mode de planificació o sortir del mode de planificació', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - 'Defineix la intensitat de raonament dels models compatibles ({{tiers}}); mapejada i limitada segons el proveïdor.', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + 'Defineix la intensitat de raonament dels models compatibles; els nivells disponibles depenen del proveïdor/model actiu.', 'Exited plan mode. Previous approval mode restored.': "S'ha sortit del mode de planificació. S'ha restaurat el mode d'aprovació anterior.", 'Enabled plan mode. The agent will analyze and plan without executing tools.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 3635de7afc3..983dfaddec1 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1928,8 +1928,8 @@ export default { 'to expand details': 'zum Erweitern der Details', 'Switch to plan mode or exit plan mode': 'In den Plan-Modus wechseln oder den Plan-Modus verlassen', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - 'Legt fest, wie intensiv schlussfolgerungsfähige Modelle denken ({{tiers}}); je nach Anbieter zugeordnet und begrenzt.', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + 'Legt fest, wie intensiv schlussfolgerungsfähige Modelle denken; verfügbare Stufen hängen vom aktiven Anbieter/Modell ab.', 'Exited plan mode. Previous approval mode restored.': 'Plan-Modus verlassen. Vorheriger Genehmigungsmodus wiederhergestellt.', 'Enabled plan mode. The agent will analyze and plan without executing tools.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index b98ea4f1902..198ac48a069 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2488,8 +2488,8 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n', 'Switch to plan mode or exit plan mode': 'Switch to plan mode or exit plan mode', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.', 'Set a goal — keep working until the condition is met': 'Set a goal — keep working until the condition is met', 'Set or control a session goal': 'Set or control a session goal', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 2b65cca911e..7a3fc0fe86e 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1974,8 +1974,8 @@ export default { '(Utilisez les flèches ↑ ↓ pour naviguer, Enter pour sélectionner, Ctrl+C pour quitter)\n', 'Switch to plan mode or exit plan mode': 'Passer en mode plan ou quitter le mode plan', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - "Définit l'intensité de réflexion des modèles compatibles avec le raisonnement ({{tiers}}) ; mappée et limitée selon le fournisseur.", + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + "Définit l'intensité de réflexion des modèles compatibles avec le raisonnement ; les niveaux disponibles dépendent du fournisseur/modèle actif.", 'Exited plan mode. Previous approval mode restored.': "Mode plan quitté. Mode d'approbation précédent restauré.", 'Enabled plan mode. The agent will analyze and plan without executing tools.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 59fca723966..29f6ff82674 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1466,8 +1466,8 @@ export default { 'to expand details': '詳細を展開', 'Switch to plan mode or exit plan mode': 'プランモードに切り替えるか、プランモードを終了する', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - '推論対応モデルの思考の強さを設定します({{tiers}})。プロバイダーごとにマッピング・制限されます。', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + '推論対応モデルの思考の強さを設定します。利用可能なティアは現在のプロバイダー/モデルによって異なります。', 'Exited plan mode. Previous approval mode restored.': 'プランモードを終了しました。以前の承認モードに戻りました。', 'Enabled plan mode. The agent will analyze and plan without executing tools.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 6fbb480560b..f34c9622a52 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1924,8 +1924,8 @@ export default { '(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n', 'Switch to plan mode or exit plan mode': 'Alternar para o modo de planejamento ou sair do modo de planejamento', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - 'Define a intensidade de raciocínio dos modelos compatíveis ({{tiers}}); mapeada e limitada por provedor.', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + 'Define a intensidade de raciocínio dos modelos compatíveis; os níveis disponíveis dependem do provedor/modelo ativo.', 'Exited plan mode. Previous approval mode restored.': 'Modo de planejamento encerrado. Modo de aprovação anterior restaurado.', 'Enabled plan mode. The agent will analyze and plan without executing tools.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 21ee6a7a43b..f59b3996490 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1930,8 +1930,8 @@ export default { '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', 'Switch to plan mode or exit plan mode': 'Переключиться в режим плана или выйти из режима плана', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - 'Задаёт интенсивность рассуждений у моделей с поддержкой рассуждений ({{tiers}}); сопоставляется и ограничивается для каждого провайдера.', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + 'Задаёт интенсивность рассуждений у моделей с поддержкой рассуждений; доступные уровни зависят от активного провайдера/модели.', 'Exited plan mode. Previous approval mode restored.': 'Режим плана завершён. Предыдущий режим утверждения восстановлен.', 'Enabled plan mode. The agent will analyze and plan without executing tools.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index a92b7918d6f..31675cc376c 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2015,8 +2015,8 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(使用 ↑ ↓ 箭頭導航,Enter 選擇,Ctrl+C 退出)\n', 'Switch to plan mode or exit plan mode': '切換到計劃模式或退出計劃模式', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - '設定具備推理能力的模型思考的強度({{tiers}});依各供應商進行映射與鉗制。', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + '設定具備推理能力的模型思考的強度;可用層級取決於目前啟用的供應商/模型。', 'Set a goal — keep working until the condition is met': '設定目標 — 持續工作直到條件滿足', 'Set or control a session goal': '設定或控制工作階段目標', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5b8c0901266..3c197d536e3 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2221,8 +2221,8 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', 'Switch to plan mode or exit plan mode': '切换到计划模式或退出计划模式', - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.': - '设置具备推理能力的模型思考的强度({{tiers}});按各提供方进行映射与钳制。', + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.': + '设置具备推理能力的模型思考的强度;可用档位取决于当前启用的提供方/模型。', 'Set a goal — keep working until the condition is met': '设定目标 — 持续工作直到条件满足', 'Set or control a session goal': '设定或控制会话目标', diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts index 215f3178f5a..57223391ea9 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { InputFormat } from '@qwen-code/qwen-code-core'; +import { AuthType, InputFormat } from '@qwen-code/qwen-code-core'; import { createMinimalSettings } from '../../../config/settings.js'; import type { StreamJsonOutputAdapter } from '../../io/StreamJsonOutputAdapter.js'; import type { IControlContext } from '../ControlContext.js'; @@ -43,6 +43,8 @@ function createContext( setModel: vi.fn(), setReasoningEffort: vi.fn(), getReasoningEffort: vi.fn().mockReturnValue(undefined), + getAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + getModel: vi.fn().mockReturnValue('default-model'), getReasoningEffortOverride: vi.fn().mockReturnValue(undefined), getAvailableModels: vi.fn().mockReturnValue([]), } as unknown as IControlContext['config'], @@ -365,6 +367,53 @@ describe('SystemController', () => { ).rejects.toThrow('Invalid effort value'); }); + it('rejects undocumented tiers for native qwen3.8-max', async () => { + const context = createContext(); + (context.config.getAuthType as ReturnType).mockReturnValue( + AuthType.USE_DASHSCOPE, + ); + (context.config.getModel as ReturnType).mockReturnValue( + 'qwen3.8-max', + ); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + await expect( + controller.handleRequest( + { subtype: 'set_effort', effort: 'high' }, + 'effort-qwen', + ), + ).rejects.toThrow('Supported: low, medium, xhigh'); + }); + + it('does not constrain another editable native model', async () => { + const context = createContext(); + (context.config.getAuthType as ReturnType).mockReturnValue( + AuthType.USE_DASHSCOPE, + ); + (context.config.getModel as ReturnType).mockReturnValue( + 'custom-model', + ); + ( + context.config.getReasoningEffort as ReturnType + ).mockReturnValue('high'); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + await expect( + controller.handleRequest( + { subtype: 'set_effort', effort: 'high' }, + 'effort-custom', + ), + ).resolves.toMatchObject({ effort: 'high', applied: true }); + }); + it('rejects empty effort string', async () => { const controller = new SystemController( createContext(), @@ -626,5 +675,27 @@ describe('SystemController', () => { ), ).rejects.toThrow('Invalid effort value'); }); + + it('rejects undocumented effort during native qwen3.8-max initialize', async () => { + const context = createContext(); + (context.config.getAuthType as ReturnType).mockReturnValue( + AuthType.USE_DASHSCOPE, + ); + (context.config.getModel as ReturnType).mockReturnValue( + 'qwen3.8-max', + ); + const controller = new SystemController( + context, + createRegistry(), + 'SystemController', + ); + + await expect( + controller.handleRequest( + { subtype: 'initialize', effort: 'max' }, + 'init-effort-qwen', + ), + ).rejects.toThrow('Supported: low, medium, xhigh'); + }); }); }); diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 69dd2366253..799ff425993 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -29,6 +29,7 @@ import { MCPServerConfig, AuthProviderType, applyReasoningEffort, + getSupportedReasoningEffortTiers, normalizeReasoningEffort, loadUsageDashboard, type MCPOAuthConfig, @@ -184,7 +185,11 @@ export class SystemController extends BaseController { if (payload.effort) { const normalized = normalizeReasoningEffort(payload.effort); - if (normalized) { + const supportedTiers = getSupportedReasoningEffortTiers( + this.context.config.getAuthType(), + this.context.config.getModel(), + ); + if (normalized && supportedTiers.includes(normalized)) { try { const effortMatches = applyReasoningEffort( this.context.config, @@ -220,7 +225,7 @@ export class SystemController extends BaseController { } } else { throw new Error( - 'Invalid effort value. Supported: low, medium, high, xhigh, max', + `Invalid effort value. Supported: ${supportedTiers.join(', ')}`, ); } } @@ -552,9 +557,13 @@ export class SystemController extends BaseController { } const normalized = normalizeReasoningEffort(effort); - if (!normalized) { + const supportedTiers = getSupportedReasoningEffortTiers( + this.context.config.getAuthType(), + this.context.config.getModel(), + ); + if (!normalized || !supportedTiers.includes(normalized)) { throw new Error( - 'Invalid effort value. Supported: low, medium, high, xhigh, max', + `Invalid effort value. Supported: ${supportedTiers.join(', ')}`, ); } diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 9113ed79553..4a31268b126 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -236,6 +236,7 @@ export const useAuthCommand = ( AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, + AuthType.USE_DASHSCOPE, ]; if (val && !valid.includes(val as AuthType)) { onAuthError( diff --git a/packages/cli/src/ui/commands/effort-command.test.ts b/packages/cli/src/ui/commands/effort-command.test.ts index a834b80c140..d2cfda2ce8b 100644 --- a/packages/cli/src/ui/commands/effort-command.test.ts +++ b/packages/cli/src/ui/commands/effort-command.test.ts @@ -5,14 +5,18 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import type { Config } from '@qwen-code/qwen-code-core'; +import { AuthType, type Config } from '@qwen-code/qwen-code-core'; import { type CommandContext } from './types.js'; import { effortCommand } from './effort-command.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; // t() returns the key verbatim so assertions can match on the key text. vi.mock('../../i18n/index.js', () => ({ - t: vi.fn((key: string) => key), + t: vi.fn((key: string, values?: Record) => + key.replace(/\{\{(\w+)\}\}/g, (match: string, name: string) => + values?.[name] === undefined ? match : String(values[name]), + ), + ), })); describe('effortCommand', () => { @@ -33,6 +37,8 @@ describe('effortCommand', () => { context = createMockCommandContext({ services: { config: { + getAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + getModel: vi.fn().mockReturnValue('default-model'), getReasoningEffort, setReasoningEffort, getReasoningEffortOverride: vi.fn().mockReturnValue(undefined), @@ -64,6 +70,29 @@ describe('effortCommand', () => { expect(setReasoningEffort).not.toHaveBeenCalled(); }); + it('lists only native qwen3.8-max tiers and reports a clamped legacy value', async () => { + vi.mocked(context.services.config!.getAuthType).mockReturnValue( + AuthType.USE_DASHSCOPE, + ); + vi.mocked(context.services.config!.getModel).mockReturnValue('qwen3.8-max'); + getReasoningEffort.mockReturnValue('high'); + const nonInteractive = { ...context, executionMode: 'non_interactive' }; + + const res = await effortCommand.action!( + nonInteractive as typeof context, + '', + ); + + expect((res as { content: string }).content).toContain( + 'Current reasoning effort: xhigh', + ); + expect((res as { content: string }).content).toContain( + 'Available: low, medium, xhigh', + ); + expect((res as { content: string }).content).not.toContain('medium, high'); + expect((res as { content: string }).content).not.toContain('xhigh, max'); + }); + it('sets and persists a valid tier', async () => { const res = await effortCommand.action!(context, 'high'); expect(setReasoningEffort).toHaveBeenCalledWith('high'); @@ -145,6 +174,31 @@ describe('effortCommand', () => { expect(setReasoningEffort).toHaveBeenCalledWith('xhigh'); }); + it('rejects high and max for native qwen3.8-max', async () => { + vi.mocked(context.services.config!.getAuthType).mockReturnValue( + AuthType.USE_DASHSCOPE, + ); + vi.mocked(context.services.config!.getModel).mockReturnValue('qwen3.8-max'); + + for (const tier of ['high', 'max']) { + const res = await effortCommand.action!(context, tier); + expect(res).toMatchObject({ messageType: 'error' }); + } + expect(setReasoningEffort).not.toHaveBeenCalled(); + expect(setValue).not.toHaveBeenCalled(); + }); + + it('accepts xhigh for native qwen3.8-max', async () => { + vi.mocked(context.services.config!.getAuthType).mockReturnValue( + AuthType.USE_DASHSCOPE, + ); + vi.mocked(context.services.config!.getModel).mockReturnValue('qwen3.8-max'); + + const res = await effortCommand.action!(context, 'xhigh'); + expect(res).toMatchObject({ messageType: 'info' }); + expect(setReasoningEffort).toHaveBeenCalledWith('xhigh'); + }); + it('rejects an unknown tier without mutating config or settings', async () => { const res = await effortCommand.action!(context, 'turbo'); expect(setReasoningEffort).not.toHaveBeenCalled(); @@ -156,6 +210,6 @@ describe('effortCommand', () => { // No completion so bare `/effort` opens the picker instead of auto-picking // the first tier; `/effort ` still parses in the action above. expect(effortCommand.completion).toBeUndefined(); - expect(effortCommand.argumentHint).toBe('[low|medium|high|xhigh|max]'); + expect(effortCommand.argumentHint).toBe(''); }); }); diff --git a/packages/cli/src/ui/commands/effort-command.ts b/packages/cli/src/ui/commands/effort-command.ts index 285ee663e18..7580bccf155 100644 --- a/packages/cli/src/ui/commands/effort-command.ts +++ b/packages/cli/src/ui/commands/effort-command.ts @@ -15,27 +15,22 @@ import { t } from '../../i18n/index.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { applyReasoningEffort, + clampReasoningEffort, + getSupportedReasoningEffortTiers, normalizeReasoningEffort, - REASONING_EFFORT_TIERS, } from '@qwen-code/qwen-code-core'; import { formatEffortChangeMessage } from './effort-utils.js'; -const TIER_LIST = REASONING_EFFORT_TIERS.join(', '); - export const effortCommand: SlashCommand = { name: 'effort', get description() { return t( - 'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.', - { tiers: TIER_LIST }, + 'Set how hard reasoning-capable models think; available tiers depend on the active provider/model.', ); }, - // The tiers show up as a placeholder via argumentHint rather than as - // autocompletion suggestions: bare `/effort` should open the picker dialog - // (no tier auto-selected), while `/effort ` still sets one directly. A - // completion function would surface the tiers as submenu-like entries and let - // Enter auto-pick the first one, which we don't want here. - argumentHint: '[low|medium|high|xhigh|max]', + // Keep the static hint generic because the supported tiers are model-aware. + // Bare `/effort` opens the picker; `/effort ` still sets one directly. + argumentHint: '', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( @@ -53,6 +48,11 @@ export const effortCommand: SlashCommand = { }; } + const supportedTiers = getSupportedReasoningEffortTiers( + config.getAuthType(), + config.getModel(), + ); + const tierList = supportedTiers.join(', '); const args = context.invocation?.args?.trim() || actionArgs.trim(); // No argument: open the interactive picker, or (non-interactive/ACP) report @@ -61,18 +61,21 @@ export const effortCommand: SlashCommand = { if (context.executionMode === 'interactive') { return { type: 'dialog', dialog: 'effort' }; } - const current = config.getReasoningEffort(); + const configuredEffort = config.getReasoningEffort(); + const current = configuredEffort + ? clampReasoningEffort(configuredEffort, supportedTiers) + : undefined; return { type: 'message', messageType: 'info', content: current ? t( 'Current reasoning effort: {{current}}\nAvailable: {{tiers}}\nUse "/effort " to change it.', - { current, tiers: TIER_LIST }, + { current, tiers: tierList }, ) : t( 'Reasoning effort: not set (using the model/provider default).\nAvailable: {{tiers}}\nUse "/effort " to set it.', - { tiers: TIER_LIST }, + { tiers: tierList }, ), }; } @@ -84,7 +87,17 @@ export const effortCommand: SlashCommand = { messageType: 'error', content: t( 'Unknown reasoning effort "{{value}}". Choose one of: {{tiers}}.', - { value: args, tiers: TIER_LIST }, + { value: args, tiers: tierList }, + ), + }; + } + if (!supportedTiers.includes(tier)) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Reasoning effort "{{value}}" is not supported by the active provider/model. Choose one of: {{tiers}}.', + { value: args, tiers: tierList }, ), }; } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f60a5388a02..ad3006f70a4 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -37,7 +37,11 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { AuthState } from '../types.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { + AuthType, + clampReasoningEffort, + getSupportedReasoningEffortTiers, +} from '@qwen-code/qwen-code-core'; import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; @@ -353,10 +357,20 @@ export const DialogManager = ({ ); } if (uiState.isEffortDialogOpen) { + const supportedEfforts = getSupportedReasoningEffortTiers( + config.getAuthType(), + config.getModel(), + ); + const configuredEffort = config.getReasoningEffort(); return ( diff --git a/packages/cli/src/ui/components/EffortDialog.test.tsx b/packages/cli/src/ui/components/EffortDialog.test.tsx index e23e32ca7e0..bd00a562015 100644 --- a/packages/cli/src/ui/components/EffortDialog.test.tsx +++ b/packages/cli/src/ui/components/EffortDialog.test.tsx @@ -34,6 +34,22 @@ describe('EffortDialog', () => { expect(frame).toContain('Use Enter to select, Esc to cancel'); }); + it('renders only the supplied model-aware tiers', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('low'); + expect(frame).toContain('medium'); + expect(frame).toContain('xhigh'); + expect(frame).not.toContain('Default — strong reasoning'); + expect(frame).not.toContain('Maximum reasoning'); + }); + it('shows the "no effort configured" hint when currentEffort is unset', () => { const { lastFrame } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/EffortDialog.tsx b/packages/cli/src/ui/components/EffortDialog.tsx index 9d57d971902..80948fb3a2c 100644 --- a/packages/cli/src/ui/components/EffortDialog.tsx +++ b/packages/cli/src/ui/components/EffortDialog.tsx @@ -22,6 +22,8 @@ interface EffortDialogProps { /** The currently active effort, used to pre-select the list. */ currentEffort?: ReasoningEffort; + + supportedEfforts?: readonly ReasoningEffort[]; } const EFFORT_DESCRIPTIONS: Record = { @@ -35,8 +37,9 @@ const EFFORT_DESCRIPTIONS: Record = { export function EffortDialog({ onSelect, currentEffort, + supportedEfforts = REASONING_EFFORT_TIERS, }: EffortDialogProps): React.JSX.Element { - const items = REASONING_EFFORT_TIERS.map((tier) => ({ + const items = supportedEfforts.map((tier) => ({ label: `${tier} — ${t(EFFORT_DESCRIPTIONS[tier])}`, value: tier, key: tier, @@ -47,7 +50,7 @@ export function EffortDialog({ // which would mislead the user into thinking 'high' is their current setting // when in fact the model/provider default applies. const initialIndex = currentEffort - ? Math.max(0, REASONING_EFFORT_TIERS.indexOf(currentEffort)) + ? Math.max(0, supportedEfforts.indexOf(currentEffort)) : 0; const handleSelect = useCallback( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 94ef1850a00..9e32532b61f 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -342,6 +342,7 @@ export function ModelDialog({ const authTypeOrder: AuthType[] = [ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, + AuthType.USE_DASHSCOPE, AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b650e3c8b41..9526c021b57 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -37,7 +37,12 @@ import { useVimModeState, useVimModeActions, } from '../contexts/VimModeContext.js'; -import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + getSupportedReasoningEffortTiers, + type Config, + type ReasoningEffort, +} from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { isDeletionKey, @@ -78,6 +83,33 @@ type ConfigTab = 'settings' | 'status' | 'stats'; const CONFIG_TAB_ORDER: ConfigTab[] = ['settings', 'status', 'stats']; +function getDialogSettingDefinition( + key: string, + config?: Config, +): ReturnType { + const definition = getSettingDefinition(key); + if ( + key !== 'model.reasoningEffort' || + !config || + definition?.type !== 'enum' + ) { + return definition; + } + + const supportedTiers = getSupportedReasoningEffortTiers( + config.getAuthType(), + config.getModel(), + ); + return { + ...definition, + options: definition.options?.filter( + (option) => + typeof option.value === 'string' && + supportedTiers.includes(option.value as ReasoningEffort), + ), + }; +} + // Literal t() calls keep the labels extractable for translation. function configTabLabel(tab: ConfigTab): string { switch (tab) { @@ -199,7 +231,7 @@ export function SettingsDialog({ // Overlay globally pending (unsaved) changes so user sees their modifications in any scope const newModified = new Set(); for (const [key, value] of globalPendingChanges.entries()) { - const def = getSettingDefinition(key); + const def = getDialogSettingDefinition(key, config); if (def?.type === 'boolean' && typeof value === 'boolean') { updated = setPendingSettingValue(key, value, updated); } else if ( @@ -214,13 +246,13 @@ export function SettingsDialog({ } setPendingSettings(updated); setModifiedSettings(newModified); - }, [selectedScope, settings, globalPendingChanges]); + }, [selectedScope, settings, globalPendingChanges, config]); const generateSettingsItems = () => { const settingKeys = getDialogSettingKeys(); return settingKeys.map((key: string) => { - const definition = getSettingDefinition(key); + const definition = getDialogSettingDefinition(key, config); return { label: definition?.label @@ -464,7 +496,7 @@ export function SettingsDialog({ }; const commitEdit = (key: string) => { - const definition = getSettingDefinition(key); + const definition = getDialogSettingDefinition(key, config); const type = definition?.type; if (editBuffer.trim() === '' && type === 'number') { @@ -789,7 +821,7 @@ export function SettingsDialog({ if (mode === 'settings') { // If editing, capture input and control keys if (editingKey) { - const definition = getSettingDefinition(editingKey); + const definition = getDialogSettingDefinition(editingKey, config); const type = definition?.type; if (key.paste && key.sequence) { diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index da5f9e5eaec..14fada63588 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -70,6 +70,19 @@ export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { : null; } +export function getDashScopeAvailableModelFromEnv(): AvailableModel | null { + const id = process.env['DASHSCOPE_MODEL']?.trim(); + return id + ? { + id, + label: id, + get description() { + return t('Configured via DASHSCOPE_MODEL environment variable'); + }, + } + : null; +} + /** * Convert core AvailableModel to CLI AvailableModel format */ @@ -125,6 +138,10 @@ export function getAvailableModelsForAuthType( const anthropicModel = getAnthropicAvailableModelFromEnv(); return anthropicModel ? [anthropicModel] : []; } + case AuthType.USE_DASHSCOPE: { + const dashScopeModel = getDashScopeAvailableModelFromEnv(); + return dashScopeModel ? [dashScopeModel] : []; + } default: return []; } diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index b2edb1533b0..d9231b8c76d 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -224,6 +224,17 @@ describe('apiPreconnect', () => { ); }); + it('should use the Singapore default baseUrl for dashscope', async () => { + preconnectApi('dashscope', { + proxy: 'http://proxy.example.com:8080', + }); + await waitForPreconnect(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://dashscope-intl.aliyuncs.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + it('should pass shared dispatcher on Node.js runtime', async () => { preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080', diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 9bdfb74b18d..5f0011327ce 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -37,7 +37,7 @@ const DEFAULT_BASE_URLS: Record = { openai: 'https://api.openai.com', 'qwen-oauth': 'https://coding.dashscope.aliyuncs.com', anthropic: 'https://api.anthropic.com', - dashscope: 'https://dashscope.aliyuncs.com', + dashscope: 'https://dashscope-intl.aliyuncs.com', }; /** diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 2894b43484f..397339bc856 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -31,6 +31,7 @@ const AUTH_ENV_MODEL_VARS: Record = { [AuthType.USE_GEMINI]: ['GEMINI_MODEL'], [AuthType.USE_VERTEX_AI]: ['GOOGLE_MODEL'], [AuthType.USE_ANTHROPIC]: ['ANTHROPIC_MODEL'], + [AuthType.USE_DASHSCOPE]: ['DASHSCOPE_MODEL'], [AuthType.QWEN_OAUTH]: [], }; @@ -214,6 +215,14 @@ export function getAuthTypeFromEnv( return AuthType.USE_OPENAI; } + // Placed AFTER the OpenAI branch above: a user already driving DashScope + // through OPENAI_* vars (the compat-mode shim) must never be silently + // migrated to the native provider just because DASHSCOPE_API_KEY/ + // DASHSCOPE_MODEL also happen to be set in the same shell. + if (env['DASHSCOPE_API_KEY'] && env['DASHSCOPE_MODEL']) { + return AuthType.USE_DASHSCOPE; + } + if (env['GEMINI_API_KEY'] && env['GEMINI_MODEL']) { return AuthType.USE_GEMINI; } diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index df74cec0b7f..46dda4ee67a 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -639,6 +639,7 @@ export class BaseLlmClient { const allAuthTypes: AuthType[] = [ AuthType.QWEN_OAUTH, AuthType.USE_OPENAI, + AuthType.USE_DASHSCOPE, AuthType.USE_VERTEX_AI, AuthType.USE_ANTHROPIC, AuthType.USE_GEMINI, diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 0a55801a844..e6da209520b 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -42,6 +42,10 @@ const openaiLoggerMockState = vi.hoisted(() => ({ }>, })); +const dashscopeMockState = vi.hoisted(() => ({ + createCount: 0, +})); + vi.mock('./openaiContentGenerator/index.js', () => ({ createOpenAIContentGenerator: () => { if (openaiMockState.generatorError) { @@ -106,6 +110,26 @@ vi.mock('../utils/openaiLogger.js', () => ({ }, })); +vi.mock('./dashscopeContentGenerator/index.js', () => ({ + createDashScopeContentGenerator: () => { + dashscopeMockState.createCount += 1; + return { + generateContent: async () => ({}), + generateContentStream: async () => + (async function* () { + yield {}; + })(), + countTokens: async () => ({ totalTokens: 1 }), + embedContent: async () => { + throw new Error( + 'DashScope native provider does not support embeddings.', + ); + }, + useSummarizedThinking: () => false, + }; + }, +})); + describe('createContentGenerator', () => { beforeEach(() => { vi.clearAllMocks(); @@ -118,6 +142,7 @@ describe('createContentGenerator', () => { qwenMockState.constructorCount = 0; qwenMockState.constructorModels = []; openaiLoggerMockState.constructorCalls = []; + dashscopeMockState.createCount = 0; }); it('should defer Gemini content generator creation until first use', async () => { @@ -602,6 +627,58 @@ describe('createContentGenerator', () => { ), ).rejects.toThrow('Unsupported authType'); }); + + it('dispatches USE_DASHSCOPE to the native DashScope factory without loading the OpenAI provider', async () => { + const mockConfig = { + getUsageStatisticsEnabled: () => true, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', + getTelemetryEnabled: () => false, + getSessionId: () => 'test-session', + } as unknown as Config; + + const generator = await createContentGenerator( + { + model: 'qwen3.8-max', + apiKey: 'test-key', + authType: AuthType.USE_DASHSCOPE, + }, + mockConfig, + ); + + expect(dashscopeMockState.createCount).toBe(0); + await generator.countTokens({ model: 'qwen3.8-max', contents: 'hello' }); + expect(dashscopeMockState.createCount).toBe(1); + expect(openaiMockState.createCount).toBe(0); + }); + + it('AuthType.USE_DASHSCOPE has the wire value "dashscope"', () => { + expect(AuthType.USE_DASHSCOPE).toBe('dashscope'); + }); + + it('still routes a DashScope-hosted compatible-mode baseUrl through USE_OPENAI', async () => { + const mockConfig = { + getUsageStatisticsEnabled: () => true, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', + getTelemetryEnabled: () => false, + getSessionId: () => 'test-session', + } as unknown as Config; + + const generator = await createContentGenerator( + { + model: 'qwen3-coder-plus', + apiKey: 'test-key', + baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + authType: AuthType.USE_OPENAI, + }, + mockConfig, + ); + + await generator.countTokens({ model: 'qwen3-coder-plus', contents: 'hi' }); + expect(openaiMockState.createCount).toBe(1); + expect(dashscopeMockState.createCount).toBe(0); + }); }); describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 7216c26e388..49fc6bb2739 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -59,6 +59,7 @@ export enum AuthType { USE_GEMINI = 'gemini', USE_VERTEX_AI = 'vertex-ai', USE_ANTHROPIC = 'anthropic', + USE_DASHSCOPE = 'dashscope', } export type PromptCacheSharingParameters = GenerateContentParameters & { @@ -98,9 +99,10 @@ export type ContentGeneratorConfig = { // Total-lifetime cap for one streaming response, NOT refreshed by chunk // arrival: a drip-fed stream resets the idle watchdog forever while never // completing the message (issue #8597), so that shape needs a bound the - // chunks cannot reset. `<= 0` disables it. Honored only by the - // OpenAI-compatible pipeline today — the Anthropic/Gemini generators do not - // implement it, so on those auth types the drip-fed shape stays unbounded. + // chunks cannot reset. `<= 0` disables it. Honored by the OpenAI-compatible + // pipeline and the native DashScope transport — the Anthropic/Gemini + // generators do not implement it, so on those auth types the drip-fed shape + // stays unbounded. streamMaxLifetimeMs?: number; maxRetries?: number; // Maximum retries for rate-limit errors retryInitialDelayMs?: number; // Initial delay for stream rate-limit retries @@ -545,6 +547,13 @@ export async function createContentGenerator( ); return createAnthropicContentGenerator(generatorConfig, config); }; + } else if (authType === AuthType.USE_DASHSCOPE) { + loadBaseGenerator = async () => { + const { createDashScopeContentGenerator } = await import( + './dashscopeContentGenerator/index.js' + ); + return createDashScopeContentGenerator(generatorConfig, config); + }; } else if ( authType === AuthType.USE_GEMINI || authType === AuthType.USE_VERTEX_AI diff --git a/packages/core/src/core/dashscopeContentGenerator/__fixtures__/error-frame.sse.txt b/packages/core/src/core/dashscopeContentGenerator/__fixtures__/error-frame.sse.txt new file mode 100644 index 00000000000..b17907f4a09 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/__fixtures__/error-frame.sse.txt @@ -0,0 +1,5 @@ +id:1 +event:error +:HTTP_STATUS/400 +data:{"code":"InvalidParameter","message":"<400> InternalError.Algo.InvalidParameter: bad param","request_id":"err-req-1"} + diff --git a/packages/core/src/core/dashscopeContentGenerator/__fixtures__/stream-text.sse.txt b/packages/core/src/core/dashscopeContentGenerator/__fixtures__/stream-text.sse.txt new file mode 100644 index 00000000000..2484b543789 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/__fixtures__/stream-text.sse.txt @@ -0,0 +1,35 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"Let"}}]},"usage":{"input_tokens":20,"output_tokens":2,"total_tokens":22,"output_tokens_details":{"reasoning_tokens":2,"text_tokens":2},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" me think"}}]},"usage":{"input_tokens":20,"output_tokens":4,"total_tokens":24,"output_tokens_details":{"reasoning_tokens":4,"text_tokens":4},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" about that."}}]},"usage":{"input_tokens":20,"output_tokens":6,"total_tokens":26,"output_tokens_details":{"reasoning_tokens":6,"text_tokens":6},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[{"text":"The"}],"reasoning_content":""}}]},"usage":{"input_tokens":20,"output_tokens":7,"total_tokens":27,"output_tokens_details":{"reasoning_tokens":6,"text_tokens":7},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[{"text":" answer"}],"reasoning_content":""}}]},"usage":{"input_tokens":20,"output_tokens":8,"total_tokens":28,"output_tokens_details":{"reasoning_tokens":6,"text_tokens":8},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[{"text":" is 42."}],"reasoning_content":""}}]},"usage":{"input_tokens":20,"output_tokens":9,"total_tokens":29,"output_tokens_details":{"reasoning_tokens":6,"text_tokens":9},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":[],"reasoning_content":""}}]},"usage":{"input_tokens":20,"output_tokens":9,"total_tokens":29,"output_tokens_details":{"reasoning_tokens":6,"text_tokens":9},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":20}},"request_id":"a1b2c3d4-0000-0000-0000-000000000001"} + diff --git a/packages/core/src/core/dashscopeContentGenerator/__fixtures__/stream-tools.sse.txt b/packages/core/src/core/dashscopeContentGenerator/__fixtures__/stream-tools.sse.txt new file mode 100644 index 00000000000..f1f4528b287 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/__fixtures__/stream-tools.sse.txt @@ -0,0 +1,100 @@ +id:1 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"The"}}]},"usage":{"input_tokens":314,"output_tokens":3,"total_tokens":317,"output_tokens_details":{"reasoning_tokens":1,"text_tokens":3},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:2 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" user is"}}]},"usage":{"input_tokens":314,"output_tokens":5,"total_tokens":319,"output_tokens_details":{"reasoning_tokens":3,"text_tokens":5},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:3 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" asking for"}}]},"usage":{"input_tokens":314,"output_tokens":7,"total_tokens":321,"output_tokens_details":{"reasoning_tokens":5,"text_tokens":7},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:4 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" the weather in Paris"}}]},"usage":{"input_tokens":314,"output_tokens":11,"total_tokens":325,"output_tokens_details":{"reasoning_tokens":9,"text_tokens":11},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:5 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" and Tokyo. Both"}}]},"usage":{"input_tokens":314,"output_tokens":15,"total_tokens":329,"output_tokens_details":{"reasoning_tokens":13,"text_tokens":15},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:6 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" are independent calls,"}}]},"usage":{"input_tokens":314,"output_tokens":19,"total_tokens":333,"output_tokens_details":{"reasoning_tokens":17,"text_tokens":19},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:7 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" so they can be"}}]},"usage":{"input_tokens":314,"output_tokens":23,"total_tokens":337,"output_tokens_details":{"reasoning_tokens":21,"text_tokens":23},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:8 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" executed"}}]},"usage":{"input_tokens":314,"output_tokens":24,"total_tokens":338,"output_tokens_details":{"reasoning_tokens":22,"text_tokens":24},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:9 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":" simultaneously."}}]},"usage":{"input_tokens":314,"output_tokens":28,"total_tokens":342,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":28},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:10 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"call_f0df466b1fd44f7590ebf389","index":0,"type":"function","function":{"name":"get_weather","arguments":""}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":36,"total_tokens":350,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":36},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:11 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":0,"type":"function","function":{"arguments":"{\"city\": \"Paris"}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":44,"total_tokens":358,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":44},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:12 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":0,"type":"function","function":{"arguments":"\""}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":48,"total_tokens":362,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":48},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:13 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":0,"type":"function","function":{"arguments":"}"}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":52,"total_tokens":366,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":52},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:14 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":0,"type":"function","function":{"arguments":""}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":56,"total_tokens":370,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":56},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:15 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"call_85d6e563c2e94bb68c5abea3","index":1,"type":"function","function":{"name":"get_weather","arguments":""}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":64,"total_tokens":378,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":64},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:16 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":1,"type":"function","function":{"arguments":"{\"city\": "}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":68,"total_tokens":382,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":68},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:17 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":1,"type":"function","function":{"arguments":"\"Tokyo"}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":72,"total_tokens":386,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":72},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:18 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":1,"type":"function","function":{"arguments":"\""}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":76,"total_tokens":390,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":76},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:19 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":1,"type":"function","function":{"arguments":"}"}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":80,"total_tokens":394,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":80},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + +id:20 +event:result +:HTTP_STATUS/200 +data:{"output":{"choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","content":[],"reasoning_content":"","tool_calls":[{"id":"","index":1,"type":"function","function":{"arguments":""}}]},"index":0}]},"usage":{"input_tokens":314,"output_tokens":81,"total_tokens":395,"output_tokens_details":{"reasoning_tokens":24,"text_tokens":81},"prompt_tokens_details":{"cached_tokens":0},"input_tokens_details":{"text_tokens":314}},"request_id":"d72b921c-83c1-9661-8c64-381adb3ff66b"} + diff --git a/packages/core/src/core/dashscopeContentGenerator/cache.test.ts b/packages/core/src/core/dashscopeContentGenerator/cache.test.ts new file mode 100644 index 00000000000..7ae17aa05c2 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/cache.test.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { planCacheMarkers } from './cache.js'; +import type { DashScopeMessage } from './types.js'; + +describe('planCacheMarkers', () => { + it('marks the system message and the last message when streaming', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: [{ text: 'system prompt' }] }, + { role: 'user', content: [{ text: 'hi' }] }, + { role: 'assistant', content: [{ text: 'hello' }] }, + { role: 'user', content: [{ text: 'bye' }] }, + ]; + + const result = planCacheMarkers(messages, { + enabled: true, + streaming: true, + }); + + expect(result[0]!.content).toEqual([ + { text: 'system prompt', cache_control: { type: 'ephemeral' } }, + ]); + expect(result[1]).toEqual(messages[1]); + expect(result[2]).toEqual(messages[2]); + expect(result[3]!.content).toEqual([ + { text: 'bye', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('marks only the system message when not streaming', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: [{ text: 'system prompt' }] }, + { role: 'user', content: [{ text: 'hi' }] }, + ]; + + const result = planCacheMarkers(messages, { + enabled: true, + streaming: false, + }); + + expect(result[0]!.content).toEqual([ + { text: 'system prompt', cache_control: { type: 'ephemeral' } }, + ]); + expect(result[1]).toEqual(messages[1]); + }); + + it('marks the last text block of a trailing tool message', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: [{ text: 'system prompt' }] }, + { role: 'user', content: [{ text: 'call a tool' }] }, + { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_1', + index: 0, + type: 'function', + function: { name: 'get_weather', arguments: '{}' }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: [{ text: '22C sunny' }], + }, + ]; + + const result = planCacheMarkers(messages, { + enabled: true, + streaming: true, + }); + + const lastMessage = result[result.length - 1]!; + expect(lastMessage.role).toBe('tool'); + expect(lastMessage.content).toEqual([ + { text: '22C sunny', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('promotes a plain string system message to array content', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: 'system prompt' }, + { role: 'user', content: [{ text: 'hi' }] }, + ]; + + const result = planCacheMarkers(messages, { + enabled: true, + streaming: false, + }); + + expect(result[0]!.content).toEqual([ + { text: 'system prompt', cache_control: { type: 'ephemeral' } }, + ]); + }); + + it('skips a media-only last message without throwing', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: [{ text: 'system prompt' }] }, + { role: 'user', content: [{ image: 'data:image/png;base64,abc' }] }, + ]; + + const result = planCacheMarkers(messages, { + enabled: true, + streaming: true, + }); + + expect(result[1]).toEqual(messages[1]); + expect(JSON.stringify(result[1])).not.toContain('cache_control'); + }); + + it('returns the input unchanged when disabled', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: [{ text: 'system prompt' }] }, + { role: 'user', content: [{ text: 'hi' }] }, + ]; + + const result = planCacheMarkers(messages, { + enabled: false, + streaming: true, + }); + + expect(result).toBe(messages); + expect(JSON.stringify(result)).not.toContain('cache_control'); + }); + + it('never mutates the input messages or their blocks', () => { + const messages: DashScopeMessage[] = [ + { role: 'system', content: [{ text: 'system prompt' }] }, + { role: 'user', content: [{ text: 'hi' }] }, + ]; + const before = JSON.parse(JSON.stringify(messages)); + + planCacheMarkers(messages, { + enabled: true, + streaming: true, + }); + + expect(messages).toEqual(before); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/cache.ts b/packages/core/src/core/dashscopeContentGenerator/cache.ts new file mode 100644 index 00000000000..104c00e124a --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/cache.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DashScopeContentBlock, DashScopeMessage } from './types.js'; + +export interface PlanCacheMarkersOptions { + enabled: boolean; + streaming: boolean; +} + +/** + * Plans the explicit prompt-cache breakpoints for one request's messages. + * Pure and non-mutating: the input array and its messages/blocks are never + * modified, only shallow-cloned where a marker is attached. + * + * Anchors (api-contract.md §7, live-verified profitable): + * 1. The first `role: 'system'` message — always marked when enabled. + * 2. The last message of the request — only on streaming requests (the + * rolling marker that makes turn N+1 hit turn N's prefix). Non-streaming + * one-shot side queries must not churn the rolling breakpoint. + */ +export function planCacheMarkers( + messages: DashScopeMessage[], + opts: PlanCacheMarkersOptions, +): DashScopeMessage[] { + if (!opts.enabled) { + return messages; + } + + const systemIndex = messages.findIndex( + (message) => message.role === 'system', + ); + const lastIndex = opts.streaming ? messages.length - 1 : -1; + + const anchors = new Set(); + if (systemIndex >= 0) { + anchors.add(systemIndex); + } + if (lastIndex >= 0) { + anchors.add(lastIndex); + } + + if (anchors.size === 0) { + return messages; + } + + return messages.map((message, index) => + anchors.has(index) ? markMessageWithCacheControl(message) : message, + ); +} + +/** + * Shallow-clones `message` and attaches `cache_control: { type: 'ephemeral' }` + * to the last content block that carries a `text` field, promoting a plain + * string `content` to array form first. Returns the original message + * unchanged when no text block exists (e.g. a media-only message) — the + * marker is silently skipped rather than throwing. + */ +function markMessageWithCacheControl( + message: DashScopeMessage, +): DashScopeMessage { + const blocks: DashScopeContentBlock[] = + typeof message.content === 'string' + ? [{ text: message.content }] + : (message.content ?? []).map((block) => ({ ...block })); + + let lastTextIndex = -1; + for (let i = blocks.length - 1; i >= 0; i--) { + if (typeof blocks[i]?.text === 'string') { + lastTextIndex = i; + break; + } + } + + if (lastTextIndex === -1) { + return message; + } + + blocks[lastTextIndex] = { + ...blocks[lastTextIndex], + cache_control: { type: 'ephemeral' }, + }; + + return { ...message, content: blocks }; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/dashscope-content-generator.test.ts b/packages/core/src/core/dashscopeContentGenerator/dashscope-content-generator.test.ts new file mode 100644 index 00000000000..8472bbcb018 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/dashscope-content-generator.test.ts @@ -0,0 +1,327 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GenerateContentParameters } from '@google/genai'; +import { FinishReason } from '@google/genai'; +import { AuthType } from '../contentGenerator.js'; +import { DashScopeContentGenerator } from './dashscope-content-generator.js'; +import { DashScopeStreamTruncatedError } from './errors.js'; +import type { DashScopeSseFrame } from './sse.js'; +import { + FakeDashScopeTransport, + createDashScopeGeneratorConfig, + createFakeCliConfig, + framesFromSseText, +} from './test-utils.js'; + +const mockReportDashScopeRequest = vi.hoisted(() => vi.fn()); +const mockReportGeminiResponse = vi.hoisted(() => vi.fn()); +const mockReportGeminiChunk = vi.hoisted(() => vi.fn()); + +vi.mock('../../telemetry/gen-ai-request.js', () => ({ + reportDashScopeRequest: mockReportDashScopeRequest, + reportGeminiResponse: mockReportGeminiResponse, + reportGeminiChunk: mockReportGeminiChunk, +})); + +const STREAM_TOOLS_FIXTURE = readFileSync( + new URL('./__fixtures__/stream-tools.sse.txt', import.meta.url), + 'utf-8', +); + +function textRequest(model = 'qwen3.8-max'): GenerateContentParameters { + return { + model, + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + }; +} + +describe('DashScopeContentGenerator', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('generateContent', () => { + it('sends result_format:message and converts the response', async () => { + const transport = new FakeDashScopeTransport({ + json: [ + { + output: { + choices: [ + { + finish_reason: 'stop', + message: { role: 'assistant', content: [{ text: 'OK' }] }, + }, + ], + }, + usage: { input_tokens: 5, output_tokens: 1 }, + request_id: 'req-1', + }, + ], + }); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + const telemetryAttempt = {}; + mockReportDashScopeRequest.mockReturnValueOnce(telemetryAttempt); + + const response = await generator.generateContent( + textRequest('per-request-model'), + 'prompt-1', + ); + + expect(transport.calls).toHaveLength(1); + expect(transport.calls[0].body.model).toBe('per-request-model'); + expect(transport.calls[0].body.parameters['result_format']).toBe( + 'message', + ); + expect(response.candidates?.[0]?.content?.parts?.[0]?.text).toBe('OK'); + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(response.modelVersion).toBe('per-request-model'); + expect(mockReportDashScopeRequest).toHaveBeenCalledWith( + transport.calls[0].body, + ); + expect(mockReportGeminiResponse).toHaveBeenCalledWith( + telemetryAttempt, + response, + ); + }); + + it('uses mandatory-thinking capability from an override model', async () => { + const transport = new FakeDashScopeTransport({ + json: [{ output: { choices: [{ finish_reason: 'stop' }] } }], + }); + const cliConfig = createFakeCliConfig(); + vi.mocked(cliConfig.getResolvedModelConfig).mockReturnValue({ + id: 'mandatory-model', + name: 'mandatory-model', + authType: AuthType.USE_DASHSCOPE, + baseUrl: 'https://dashscope.aliyuncs.com/api/v1', + generationConfig: { thinkingMandatory: true }, + capabilities: {}, + }); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig({ reasoning: false }), + cliConfig, + transport, + ); + const telemetryAttempt = {}; + mockReportDashScopeRequest.mockReturnValueOnce(telemetryAttempt); + + await generator.generateContent( + textRequest('mandatory-model'), + 'prompt-1', + ); + + expect( + transport.calls[0].body.parameters['reasoning_effort'], + ).toBeUndefined(); + }); + + it('aborts the child controller after the request completes', async () => { + const transport = new FakeDashScopeTransport({ + json: [{ output: { choices: [{ finish_reason: 'stop' }] } }], + }); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + + await generator.generateContent(textRequest(), 'prompt-1'); + + expect(transport.calls[0].signal.aborted).toBe(true); + }); + }); + + describe('generateContentStream', () => { + it('streams the fixture with usage only on the final chunk and two functionCall parts', async () => { + const frames = await framesFromSseText(STREAM_TOOLS_FIXTURE); + const transport = new FakeDashScopeTransport({ frames: [frames] }); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + const telemetryAttempt = {}; + mockReportDashScopeRequest.mockReturnValueOnce(telemetryAttempt); + + const stream = await generator.generateContentStream( + textRequest('per-request-model'), + 'prompt-1', + ); + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const intermediate = chunks.slice(0, -1); + for (const chunk of intermediate) { + expect(chunk.usageMetadata).toBeUndefined(); + } + const finalChunk = chunks[chunks.length - 1]; + expect(finalChunk.usageMetadata).toBeDefined(); + expect(finalChunk.modelVersion).toBe('per-request-model'); + expect(transport.calls[0].body.model).toBe('per-request-model'); + + const functionCallParts = chunks.flatMap( + (chunk) => + chunk.candidates?.[0]?.content?.parts?.filter( + (part) => part.functionCall, + ) ?? [], + ); + expect(functionCallParts).toHaveLength(2); + expect(mockReportDashScopeRequest).toHaveBeenCalledWith( + transport.calls[0].body, + ); + expect(mockReportGeminiChunk).toHaveBeenCalledTimes(chunks.length); + for (const chunk of chunks) { + expect(mockReportGeminiChunk).toHaveBeenCalledWith( + telemetryAttempt, + chunk, + ); + } + }); + + it('throws DashScopeStreamTruncatedError when the stream ends with no terminal frame and no tool calls', async () => { + const frames: DashScopeSseFrame[] = [ + { + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'null', + message: { + role: 'assistant', + content: [{ text: 'partial' }], + }, + }, + ], + }, + }), + }, + ]; + const transport = new FakeDashScopeTransport({ frames: [frames] }); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + + const stream = await generator.generateContentStream( + textRequest(), + 'prompt-1', + ); + + await expect( + (async () => { + for await (const _chunk of stream) { + // drain + } + })(), + ).rejects.toBeInstanceOf(DashScopeStreamTruncatedError); + expect(transport.calls[0].signal.aborted).toBe(true); + }); + + it('resolves without throwing when truncated after tool calls were already emitted', async () => { + const frames: DashScopeSseFrame[] = [ + { + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'tool_calls', + message: { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_a', + index: 0, + type: 'function', + function: { + name: 'get_weather', + arguments: '{"city":"Paris"}', + }, + }, + ], + }, + }, + ], + }, + usage: { input_tokens: 5, output_tokens: 3 }, + }), + }, + ]; + const transport = new FakeDashScopeTransport({ frames: [frames] }); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + + const stream = await generator.generateContentStream( + textRequest(), + 'prompt-1', + ); + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + }); + }); + + describe('countTokens', () => { + it('estimates tokens without calling the transport', async () => { + const transport = new FakeDashScopeTransport({}); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + + const result = await generator.countTokens({ + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hello there' }] }], + }); + + expect(result.totalTokens).toBeGreaterThan(0); + expect(transport.calls).toHaveLength(0); + }); + }); + + describe('embedContent', () => { + it('rejects — native DashScope does not support embeddings', async () => { + const transport = new FakeDashScopeTransport({}); + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + transport, + ); + + await expect( + generator.embedContent({ + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'x' }] }], + }), + ).rejects.toThrow('does not support embeddings'); + }); + }); + + it('useSummarizedThinking returns false', () => { + const generator = new DashScopeContentGenerator( + createDashScopeGeneratorConfig(), + createFakeCliConfig(), + new FakeDashScopeTransport({}), + ); + expect(generator.useSummarizedThinking()).toBe(false); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/dashscope-content-generator.ts b/packages/core/src/core/dashscopeContentGenerator/dashscope-content-generator.ts new file mode 100644 index 00000000000..6f703e5b281 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/dashscope-content-generator.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + CountTokensParameters, + CountTokensResponse, + EmbedContentParameters, + EmbedContentResponse, + GenerateContentParameters, + GenerateContentResponse, +} from '@google/genai'; +import type { Config } from '../../config/config.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +import { createChildAbortController } from '../../utils/abortController.js'; +import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { + reportDashScopeRequest, + reportGeminiChunk, + reportGeminiResponse, +} from '../../telemetry/gen-ai-request.js'; +import { buildDashScopeRequest } from './request-converter.js'; +import { convertDashScopeResponseToGemini } from './response-converter.js'; +import { DashScopeStreamAccumulator } from './stream-accumulator.js'; +import { DashScopeStreamTruncatedError } from './errors.js'; +import type { DashScopeSseFrame } from './sse.js'; +import { + FetchDashScopeTransport, + type DashScopeTransport, +} from './transport.js'; + +const debugLogger = createDebugLogger('DASHSCOPE'); + +/** + * Native DashScope `ContentGenerator`. Talks to the multimodal-generation + * endpoint via a {@link DashScopeTransport} rather than the OpenAI-compatible + * shim, so it gets raw `reasoning_content`, explicit prompt caching, and + * true-incremental streaming deltas natively. + */ +export class DashScopeContentGenerator implements ContentGenerator { + private readonly transport: DashScopeTransport; + + constructor( + private readonly contentGeneratorConfig: ContentGeneratorConfig, + private readonly cliConfig: Config, + transport?: DashScopeTransport, + ) { + this.transport = + transport ?? + new FetchDashScopeTransport(contentGeneratorConfig, cliConfig); + } + + async generateContent( + request: GenerateContentParameters, + _userPromptId: string, + ): Promise { + const child = createChildAbortController(request.config?.abortSignal); + try { + const body = buildDashScopeRequest(request, { + contentGeneratorConfig: this.contentGeneratorConfig, + streaming: false, + thinkingMandatory: this.isThinkingMandatory( + request.model || this.contentGeneratorConfig.model, + ), + }); + const telemetryAttempt = reportDashScopeRequest(body); + const payload = await this.transport.postJson(body, { + signal: child.signal, + }); + const response = convertDashScopeResponseToGemini(payload, body.model); + reportGeminiResponse(telemetryAttempt, response); + return response; + } finally { + child.abort(); + } + } + + async generateContentStream( + request: GenerateContentParameters, + _userPromptId: string, + ): Promise> { + const child = createChildAbortController(request.config?.abortSignal); + const body = buildDashScopeRequest(request, { + contentGeneratorConfig: this.contentGeneratorConfig, + streaming: true, + thinkingMandatory: this.isThinkingMandatory( + request.model || this.contentGeneratorConfig.model, + ), + }); + const telemetryAttempt = reportDashScopeRequest(body); + + let frames: AsyncGenerator; + try { + frames = await this.transport.postSse(body, { signal: child.signal }); + } catch (error) { + child.abort(); + throw error; + } + + const model = body.model; + + async function* run(): AsyncGenerator { + try { + const accumulator = new DashScopeStreamAccumulator(model); + for await (const frame of frames) { + for (const chunk of accumulator.push(frame)) { + reportGeminiChunk(telemetryAttempt, chunk); + yield chunk; + } + } + const { truncated, emittedToolCalls } = accumulator.finish(); + if (truncated && !emittedToolCalls) { + throw new DashScopeStreamTruncatedError( + 'DashScope stream ended without a terminal frame', + ); + } + // If tool calls were already emitted, don't throw — a retry of this + // request would duplicate tool side effects on the caller's end. + } finally { + child.abort(); + } + } + + return run(); + } + + async countTokens( + request: CountTokensParameters, + ): Promise { + try { + const estimator = new RequestTokenEstimator(); + const result = await estimator.calculateTokens(request); + return { totalTokens: result.totalTokens }; + } catch (error) { + debugLogger.warn( + 'Failed to calculate tokens with tokenizer, falling back to simple method:', + error, + ); + const content = JSON.stringify(request.contents); + const totalTokens = Math.ceil(content.length / 4); + return { totalTokens }; + } + } + + async embedContent( + _request: EmbedContentParameters, + ): Promise { + throw new Error('DashScope native provider does not support embeddings.'); + } + + useSummarizedThinking(): boolean { + return false; + } + + private isThinkingMandatory(model: string): boolean { + if ( + model.toLowerCase() === this.contentGeneratorConfig.model.toLowerCase() + ) { + return this.contentGeneratorConfig.thinkingMandatory === true; + } + const authType = this.contentGeneratorConfig.authType; + if (!authType) { + return false; + } + return ( + this.cliConfig.getResolvedModelConfig?.( + authType, + model, + this.contentGeneratorConfig.baseUrl, + )?.generationConfig.thinkingMandatory === true + ); + } +} diff --git a/packages/core/src/core/dashscopeContentGenerator/dashscope-native.live.test.ts b/packages/core/src/core/dashscopeContentGenerator/dashscope-native.live.test.ts new file mode 100644 index 00000000000..35525020995 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/dashscope-native.live.test.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Live smoke test for the native DashScope provider against qwen3.8-max. + * Skipped by default; runs only when `QWEN_CODE_RUN_LIVE_TESTS=1` and + * `DASHSCOPE_API_KEY` are set in the environment: + * + * cd packages/core && QWEN_CODE_RUN_LIVE_TESTS=1 npx vitest run \ + * src/core/dashscopeContentGenerator/dashscope-native.live.test.ts + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import type { GenerateContentResponse } from '@google/genai'; +import { FinishReason } from '@google/genai'; +import { preloadRuntimeFetchModule } from '../../utils/runtimeFetchOptions.js'; +import { createDashScopeContentGenerator } from './index.js'; +import { + createDashScopeGeneratorConfig, + createFakeCliConfig, +} from './test-utils.js'; + +const apiKey = process.env['DASHSCOPE_API_KEY']; +const runLiveTests = + process.env['QWEN_CODE_RUN_LIVE_TESTS'] === '1' && Boolean(apiKey); +const baseUrl = process.env['DASHSCOPE_BASE_URL']; +const MODEL = 'qwen3.8-max'; +const TIMEOUT_MS = 90_000; + +function buildGenerator() { + return createDashScopeContentGenerator( + createDashScopeGeneratorConfig({ + apiKey: apiKey ?? '', + model: MODEL, + ...(baseUrl ? { baseUrl } : {}), + }), + createFakeCliConfig(), + ); +} + +async function collectStream( + stream: AsyncGenerator, +): Promise { + const chunks: GenerateContentResponse[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return chunks; +} + +describe.skipIf(!runLiveTests)('DashScope native (live)', () => { + beforeAll(async () => { + await preloadRuntimeFetchModule(); + }); + + it( + 'non-streaming text with thinking off returns text and usage', + async () => { + const generator = buildGenerator(); + const response = await generator.generateContent( + { + model: MODEL, + contents: [ + { role: 'user', parts: [{ text: 'Say the single word OK.' }] }, + ], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'live-prompt-1', + ); + + const text = response.candidates?.[0]?.content?.parts + ?.map((part) => part.text ?? '') + .join(''); + expect(text?.length ?? 0).toBeGreaterThan(0); + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(response.usageMetadata?.promptTokenCount ?? 0).toBeGreaterThan(0); + }, + TIMEOUT_MS, + ); + + it( + 'streaming with reasoning on emits a thought part before the first text part, usage once on the last chunk', + async () => { + const generator = buildGenerator(); + const stream = await generator.generateContentStream( + { + model: MODEL, + contents: [ + { + role: 'user', + parts: [{ text: 'What is 2 + 2? Answer in one short sentence.' }], + }, + ], + config: { thinkingConfig: { includeThoughts: true } }, + }, + 'live-prompt-2', + ); + const chunks = await collectStream(stream); + + const firstThoughtIndex = chunks.findIndex((chunk) => + chunk.candidates?.[0]?.content?.parts?.some((part) => part.thought), + ); + const firstTextIndex = chunks.findIndex((chunk) => + chunk.candidates?.[0]?.content?.parts?.some( + (part) => part.text && !part.thought, + ), + ); + expect(firstThoughtIndex).toBeGreaterThanOrEqual(0); + expect(firstThoughtIndex).toBeLessThan(firstTextIndex); + + const usageChunks = chunks.filter((chunk) => chunk.usageMetadata); + expect(usageChunks).toHaveLength(1); + expect(usageChunks[0]).toBe(chunks[chunks.length - 1]); + expect( + usageChunks[0].usageMetadata?.thoughtsTokenCount ?? 0, + ).toBeGreaterThan(0); + }, + TIMEOUT_MS, + ); + + it( + 'streaming tool calls for two cities round-trip through a second turn', + async () => { + const generator = buildGenerator(); + const tool = { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get the current weather for a city.', + parametersJsonSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + ], + }; + + const firstTurn = { + model: MODEL, + contents: [ + { + role: 'user' as const, + parts: [ + { + text: 'Call get_weather for Paris AND Tokyo, one call per city.', + }, + ], + }, + ], + config: { tools: [tool] }, + }; + + const stream = await generator.generateContentStream( + firstTurn, + 'live-prompt-3', + ); + const chunks = await collectStream(stream); + const functionCallParts = chunks.flatMap( + (chunk) => + chunk.candidates?.[0]?.content?.parts?.filter( + (part) => part.functionCall, + ) ?? [], + ); + expect(functionCallParts.length).toBe(2); + const ids = functionCallParts.map((part) => part.functionCall?.id); + expect(new Set(ids).size).toBe(2); + const cities = functionCallParts.map( + (part) => (part.functionCall?.args as { city?: string })?.city, + ); + expect(cities.sort()).toEqual(['Paris', 'Tokyo']); + + const secondTurn = await generator.generateContent( + { + model: MODEL, + contents: [ + ...firstTurn.contents, + { + role: 'model' as const, + parts: functionCallParts, + }, + { + role: 'user' as const, + parts: functionCallParts.map((part) => ({ + functionResponse: { + id: part.functionCall?.id, + name: part.functionCall?.name, + response: { output: '22C sunny' }, + }, + })), + }, + ], + config: { tools: [tool] }, + }, + 'live-prompt-3b', + ); + const finalText = secondTurn.candidates?.[0]?.content?.parts + ?.map((part) => part.text ?? '') + .join(''); + expect(finalText?.length ?? 0).toBeGreaterThan(0); + }, + TIMEOUT_MS, + ); + + it( + 'explicit cache: a large shared systemInstruction is a cache miss then a cache hit', + async () => { + const generator = buildGenerator(); + // Explicit caching silently no-ops below the server's ~1024-token + // minimum prefix length (api-contract.md §7) — repeat enough to clear + // ~1500+ tokens with margin, matching the live-verified capture. A + // per-run nonce keeps this prefix unique so a prior run's 5-minute + // cache entry (or a stray manual probe against the same base text) + // can't produce a false cache hit on the supposedly-cold first turn. + const systemInstruction = + `Session nonce: ${Date.now()}-${Math.random()}. ` + + 'You are a helpful assistant. '.repeat(260) + + 'Always answer concisely.'; + + const firstTurn = await generator.generateContent( + { + model: MODEL, + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + config: { + systemInstruction, + thinkingConfig: { includeThoughts: false }, + }, + }, + 'live-prompt-4a', + ); + expect(firstTurn.usageMetadata?.cachedContentTokenCount ?? 0).toBe(0); + + const secondTurn = await generator.generateContent( + { + model: MODEL, + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of Japan?' }], + }, + ], + config: { + systemInstruction, + thinkingConfig: { includeThoughts: false }, + }, + }, + 'live-prompt-4b', + ); + expect( + secondTurn.usageMetadata?.cachedContentTokenCount ?? 0, + ).toBeGreaterThan(1000); + }, + TIMEOUT_MS, + ); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/endpoints.test.ts b/packages/core/src/core/dashscopeContentGenerator/endpoints.test.ts new file mode 100644 index 00000000000..05451547c66 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/endpoints.test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + DASHSCOPE_NATIVE_GENERATION_PATH, + DEFAULT_DASHSCOPE_NATIVE_BASE_URL, + resolveDashScopeGenerationEndpoint, +} from './endpoints.js'; + +const EXPECTED_DEFAULT_ENDPOINT = `${DEFAULT_DASHSCOPE_NATIVE_BASE_URL}/${DASHSCOPE_NATIVE_GENERATION_PATH}`; + +describe('resolveDashScopeGenerationEndpoint', () => { + it('defaults to the Singapore base URL when undefined', () => { + expect(resolveDashScopeGenerationEndpoint(undefined)).toBe( + EXPECTED_DEFAULT_ENDPOINT, + ); + }); + + it('defaults to the Singapore base URL when empty', () => { + expect(resolveDashScopeGenerationEndpoint('')).toBe( + EXPECTED_DEFAULT_ENDPOINT, + ); + }); + + it('appends the generation path to an /api/v1 base URL', () => { + expect( + resolveDashScopeGenerationEndpoint( + 'https://dashscope.aliyuncs.com/api/v1', + ), + ).toBe( + `https://dashscope.aliyuncs.com/api/v1/${DASHSCOPE_NATIVE_GENERATION_PATH}`, + ); + }); + + it('strips a trailing slash without producing a double slash', () => { + expect( + resolveDashScopeGenerationEndpoint( + 'https://dashscope-intl.aliyuncs.com/api/v1/', + ), + ).toBe(EXPECTED_DEFAULT_ENDPOINT); + }); + + it('handles a long slash run followed by a non-slash character', () => { + const baseUrl = `https://example.com/${'/'.repeat(10_000)}models`; + expect(resolveDashScopeGenerationEndpoint(baseUrl)).toBe( + `${baseUrl}/api/v1/${DASHSCOPE_NATIVE_GENERATION_PATH}`, + ); + }); + + it('appends /api/v1 to a bare host', () => { + expect( + resolveDashScopeGenerationEndpoint('https://dashscope-intl.aliyuncs.com'), + ).toBe(EXPECTED_DEFAULT_ENDPOINT); + }); + + it('replaces a pasted /compatible-mode/v1 compat URL with /api/v1', () => { + expect( + resolveDashScopeGenerationEndpoint( + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + ), + ).toBe(EXPECTED_DEFAULT_ENDPOINT); + }); + + it('does not duplicate /api/v1 for a compat URL already rooted at /api/v1', () => { + expect( + resolveDashScopeGenerationEndpoint( + 'https://dashscope-intl.aliyuncs.com/api/v1/compatible-mode/v1', + ), + ).toBe(EXPECTED_DEFAULT_ENDPOINT); + }); + + it('does not duplicate /api/v1 for a compat URL with a doubled slash', () => { + expect( + resolveDashScopeGenerationEndpoint( + 'https://dashscope-intl.aliyuncs.com/api/v1//compatible-mode/v1', + ), + ).toBe(EXPECTED_DEFAULT_ENDPOINT); + }); + + it('returns a full endpoint ending in /generation unchanged', () => { + const fullEndpoint = `https://dashscope-intl.aliyuncs.com/api/v1/${DASHSCOPE_NATIVE_GENERATION_PATH}`; + expect(resolveDashScopeGenerationEndpoint(fullEndpoint)).toBe(fullEndpoint); + }); + + it('returns a full endpoint with a trailing slash stripped and unchanged', () => { + const fullEndpoint = `https://dashscope-intl.aliyuncs.com/api/v1/${DASHSCOPE_NATIVE_GENERATION_PATH}`; + expect(resolveDashScopeGenerationEndpoint(`${fullEndpoint}/`)).toBe( + fullEndpoint, + ); + }); + + it('falls back to string-suffix logic when the URL fails to parse', () => { + expect(resolveDashScopeGenerationEndpoint('not a url /api/v1')).toBe( + `not a url /api/v1/${DASHSCOPE_NATIVE_GENERATION_PATH}`, + ); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/endpoints.ts b/packages/core/src/core/dashscopeContentGenerator/endpoints.ts new file mode 100644 index 00000000000..e9c91a14a38 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/endpoints.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DEFAULT_DASHSCOPE_NATIVE_BASE_URL = + 'https://dashscope-intl.aliyuncs.com/api/v1'; + +export const DASHSCOPE_NATIVE_GENERATION_PATH = + 'services/aigc/multimodal-generation/generation'; + +const COMPAT_MODE_SUFFIX = '/compatible-mode/v1'; + +function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47 /* / */) end--; + return value.slice(0, end); +} + +function resolvePathname(url: string): string | undefined { + try { + return new URL(url).pathname; + } catch { + return undefined; + } +} + +/** + * Resolves a user-supplied base URL (bare host, `/api/v1`, a pasted + * `/compatible-mode/v1` compat URL, or a full generation endpoint) into the + * native DashScope multimodal-generation endpoint. + */ +export function resolveDashScopeGenerationEndpoint( + baseUrl: string | undefined, +): string { + const trimmed = (baseUrl ?? '').trim(); + let base = stripTrailingSlashes( + trimmed.length > 0 ? trimmed : DEFAULT_DASHSCOPE_NATIVE_BASE_URL, + ); + + if (base.endsWith('/generation')) { + return base; + } + + // A pasted compat URL may already be rooted at /api/v1 + // (`https://host/api/v1/compatible-mode/v1`), so drop the compat suffix and + // let the /api/v1 detection below decide whether one needs appending. + if (base.endsWith(COMPAT_MODE_SUFFIX)) { + base = stripTrailingSlashes(base.slice(0, -COMPAT_MODE_SUFFIX.length)); + } + + const pathname = resolvePathname(base); + const hasApiV1 = + pathname !== undefined + ? pathname.includes('/api/v1') + : base.includes('/api/v1'); + + const withApiV1 = hasApiV1 ? base : `${base}/api/v1`; + return `${withApiV1}/${DASHSCOPE_NATIVE_GENERATION_PATH}`; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/errors.test.ts b/packages/core/src/core/dashscopeContentGenerator/errors.test.ts new file mode 100644 index 00000000000..ad7cadca843 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/errors.test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + DashScopeApiError, + dashScopeErrorFromFrame, + toDashScopeApiError, +} from './errors.js'; +import type { DashScopeSseFrame } from './sse.js'; +import { classifyRetryError } from '../../utils/retryErrorClassification.js'; +import { getErrorStatus } from '../../utils/errors.js'; + +const JSON_ENVELOPE_400 = JSON.stringify({ + code: 'InvalidParameter', + message: '<400> InternalError.Algo.InvalidParameter: bad request', + request_id: 'req-json-400', +}); + +const SSE_FRAMED_400_BODY = [ + 'id:1', + 'event:error', + ':HTTP_STATUS/400', + 'data:{"code":"InvalidParameter","message":"<400> InternalError.Algo.InvalidParameter: bad request","request_id":"req-sse-400"}', + '', +].join('\n'); + +describe('toDashScopeApiError', () => { + it('extracts status/code/requestId from a JSON envelope body', () => { + const error = toDashScopeApiError({ + status: 400, + rawBody: JSON_ENVELOPE_400, + }); + expect(error).toBeInstanceOf(DashScopeApiError); + expect(error.status).toBe(400); + expect(error.code).toBe('InvalidParameter'); + expect(error.requestId).toBe('req-json-400'); + expect(error.message).toContain('400'); + expect(error.message).toContain('InvalidParameter'); + expect(error.message).toContain( + '<400> InternalError.Algo.InvalidParameter: bad request', + ); + expect(error.message).toContain('req-json-400'); + }); + + it('extracts the same fields from an SSE-framed 400 raw body', () => { + const error = toDashScopeApiError({ + status: 400, + rawBody: SSE_FRAMED_400_BODY, + }); + expect(error.status).toBe(400); + expect(error.code).toBe('InvalidParameter'); + expect(error.requestId).toBe('req-sse-400'); + expect(error.message).toContain( + '<400> InternalError.Algo.InvalidParameter: bad request', + ); + }); + + it('falls back to the X-Request-Id header when request_id is absent from the body', () => { + const body = JSON.stringify({ + code: 'invalid_request_error', + message: 'model is not allowed', + }); + const headers = new Headers({ 'X-Request-Id': 'hdr-123' }); + const error = toDashScopeApiError({ status: 400, rawBody: body, headers }); + expect(error.requestId).toBe('hdr-123'); + }); + + it('does not throw on an unparseable body and includes the raw text in the message', () => { + const error = toDashScopeApiError({ + status: 502, + rawBody: 'gateway timeout', + }); + expect(error.status).toBe(502); + expect(error.code).toBeUndefined(); + expect(error.message).toContain('gateway timeout'); + }); +}); + +describe('dashScopeErrorFromFrame', () => { + it('returns an error for an event:error frame', () => { + const frame: DashScopeSseFrame = { + event: 'error', + httpStatus: 400, + data: JSON_ENVELOPE_400, + }; + const error = dashScopeErrorFromFrame(frame); + expect(error).toBeInstanceOf(DashScopeApiError); + expect(error?.status).toBe(400); + }); + + it('returns an error for a result frame with httpStatus >= 400', () => { + const frame: DashScopeSseFrame = { + event: 'result', + httpStatus: 429, + data: JSON.stringify({ code: 'Throttling', message: 'slow down' }), + }; + const error = dashScopeErrorFromFrame(frame); + expect(error?.status).toBe(429); + expect(error?.code).toBe('Throttling'); + }); + + it('returns undefined for a normal result frame with httpStatus 200', () => { + const frame: DashScopeSseFrame = { + event: 'result', + httpStatus: 200, + data: JSON.stringify({ output: { choices: [] } }), + }; + expect(dashScopeErrorFromFrame(frame)).toBeUndefined(); + }); +}); + +describe('DashScopeApiError classification with repo retry utilities', () => { + it('getErrorStatus reads the numeric status property', () => { + expect( + getErrorStatus(new DashScopeApiError({ message: 'x', status: 429 })), + ).toBe(429); + }); + + it('classifies a 429 Throttling error as retryable', () => { + const error = new DashScopeApiError({ + message: 'DashScope API error 429 (Throttling): slow down', + status: 429, + code: 'Throttling', + }); + const classification = classifyRetryError(error); + expect(classification.diagnosis).toBe('retryable'); + }); + + it('classifies a 400 InvalidParameter error as fail-fast', () => { + const error = new DashScopeApiError({ + message: 'DashScope API error 400 (InvalidParameter): bad request', + status: 400, + code: 'InvalidParameter', + }); + const classification = classifyRetryError(error); + expect(classification.diagnosis).toBe('fail-fast'); + }); + + it('classifies a 500 error as retryable', () => { + const error = new DashScopeApiError({ + message: 'DashScope API error 500: internal error', + status: 500, + }); + const classification = classifyRetryError(error); + expect(classification.diagnosis).toBe('retryable'); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/errors.ts b/packages/core/src/core/dashscopeContentGenerator/errors.ts new file mode 100644 index 00000000000..87face94187 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/errors.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DashScopeErrorEnvelope } from './types.js'; +import type { DashScopeSseFrame } from './sse.js'; + +const MAX_RAW_BODY_MESSAGE_LENGTH = 500; + +export class DashScopeApiError extends Error { + readonly status?: number; + readonly code?: string; + readonly requestId?: string; + readonly request_id?: string; + + constructor(args: { + message: string; + status?: number; + code?: string; + requestId?: string; + }) { + super(args.message); + this.name = 'DashScopeApiError'; + this.status = args.status; + this.code = args.code; + this.requestId = args.requestId; + this.request_id = args.requestId; + } +} + +export class DashScopeStreamTruncatedError extends Error { + readonly code = 'ETIMEDOUT'; + + constructor(message: string) { + super(message); + this.name = 'DashScopeStreamTruncatedError'; + } +} + +function truncate(value: string): string { + return value.length > MAX_RAW_BODY_MESSAGE_LENGTH + ? `${value.slice(0, MAX_RAW_BODY_MESSAGE_LENGTH)}...` + : value; +} + +function parseJsonEnvelope(value: string): DashScopeErrorEnvelope | undefined { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null) { + return parsed as DashScopeErrorEnvelope; + } + return undefined; + } catch { + return undefined; + } +} + +function parseSseFramedEnvelope( + rawBody: string, +): DashScopeErrorEnvelope | undefined { + for (const line of rawBody.split('\n')) { + const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line; + if (trimmed.startsWith('data:')) { + return parseJsonEnvelope(trimmed.slice('data:'.length)); + } + } + return undefined; +} + +function extractErrorEnvelope( + rawBody: string | undefined, +): DashScopeErrorEnvelope | undefined { + if (rawBody === undefined) { + return undefined; + } + return parseJsonEnvelope(rawBody) ?? parseSseFramedEnvelope(rawBody); +} + +function buildErrorMessage(args: { + status?: number; + code?: string; + message: string; + requestId?: string; +}): string { + let prefix = 'DashScope API error'; + if (args.status !== undefined) { + prefix += ` ${args.status}`; + } + if (args.code) { + prefix += ` (${args.code})`; + } + let message = `${prefix}: ${args.message}`; + if (args.requestId) { + message += ` [request_id: ${args.requestId}]`; + } + return message; +} + +export function toDashScopeApiError(args: { + status?: number; + rawBody?: string; + headers?: Headers; +}): DashScopeApiError { + const envelope = extractErrorEnvelope(args.rawBody); + const requestId = + envelope?.request_id ?? args.headers?.get('x-request-id') ?? undefined; + const providerMessage = + envelope?.message ?? + (args.rawBody !== undefined ? truncate(args.rawBody) : ''); + + return new DashScopeApiError({ + message: buildErrorMessage({ + status: args.status, + code: envelope?.code, + message: providerMessage, + requestId, + }), + status: args.status, + code: envelope?.code, + requestId, + }); +} + +export function dashScopeErrorFromFrame( + frame: DashScopeSseFrame, +): DashScopeApiError | undefined { + if ( + frame.event === 'error' || + (frame.httpStatus !== undefined && frame.httpStatus >= 400) + ) { + return toDashScopeApiError({ + status: frame.httpStatus, + rawBody: frame.data, + }); + } + return undefined; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/index.ts b/packages/core/src/core/dashscopeContentGenerator/index.ts new file mode 100644 index 00000000000..7d7e8cdfcec --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import { DashScopeContentGenerator } from './dashscope-content-generator.js'; + +export { DashScopeContentGenerator } from './dashscope-content-generator.js'; + +export function createDashScopeContentGenerator( + contentGeneratorConfig: ContentGeneratorConfig, + cliConfig: Config, +): ContentGenerator { + return new DashScopeContentGenerator(contentGeneratorConfig, cliConfig); +} diff --git a/packages/core/src/core/dashscopeContentGenerator/request-converter.test.ts b/packages/core/src/core/dashscopeContentGenerator/request-converter.test.ts new file mode 100644 index 00000000000..f1dbdb60321 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/request-converter.test.ts @@ -0,0 +1,927 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + FunctionResponsePart, + GenerateContentParameters, + Part, + Tool, +} from '@google/genai'; +import { FunctionCallingConfigMode } from '@google/genai'; +import type { ContentGeneratorConfig } from '../contentGenerator.js'; +import { + buildDashScopeRequest, + canonicalizeToolJson, + cleanOrphanedToolCalls, + convertGeminiContentsToDashScopeMessages, + convertGeminiToolsToDashScopeTools, +} from './request-converter.js'; +import type { DashScopeMessage, DashScopeTool } from './types.js'; + +function createTestConfig( + overrides: Partial = {}, +): ContentGeneratorConfig { + return { + apiKey: 'test-key', + model: 'qwen3.8-max', + ...overrides, + } as ContentGeneratorConfig; +} + +function build( + request: GenerateContentParameters, + configOverrides: Partial = {}, + streaming = false, +) { + return buildDashScopeRequest(request, { + contentGeneratorConfig: createTestConfig(configOverrides), + streaming, + }); +} + +describe('buildDashScopeRequest — system instruction', () => { + it('flattens a string systemInstruction into the first message', () => { + const result = build( + { + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + config: { systemInstruction: 'be helpful' }, + }, + { enableCacheControl: false }, + ); + + expect(result.input.messages[0]).toEqual({ + role: 'system', + content: [{ text: 'be helpful' }], + }); + }); + + it('flattens a Content systemInstruction', () => { + const result = build( + { + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + config: { + systemInstruction: { + role: 'system', + parts: [{ text: 'be helpful' }], + }, + }, + }, + { enableCacheControl: false }, + ); + + expect(result.input.messages[0]).toEqual({ + role: 'system', + content: [{ text: 'be helpful' }], + }); + }); + + it('flattens a Part[] systemInstruction', () => { + const result = build( + { + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + config: { systemInstruction: [{ text: 'be helpful' }] }, + }, + { enableCacheControl: false }, + ); + + expect(result.input.messages[0]).toEqual({ + role: 'system', + content: [{ text: 'be helpful' }], + }); + }); + + it('omits the system message when systemInstruction is empty', () => { + const result = build({ + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + + expect(result.input.messages[0]!.role).toBe('user'); + }); +}); + +describe('convertGeminiContentsToDashScopeMessages — user content', () => { + it('converts a plain string content to a user text block', () => { + const messages = convertGeminiContentsToDashScopeMessages('hi', { + splitToolMedia: true, + }); + expect(messages).toEqual([{ role: 'user', content: [{ text: 'hi' }] }]); + }); + + it('normalizes a bare text part into a user message', () => { + const messages = convertGeminiContentsToDashScopeMessages( + { text: 'hello' }, + { splitToolMedia: true }, + ); + expect(messages).toEqual([{ role: 'user', content: [{ text: 'hello' }] }]); + }); + + it('normalizes a bare part array into one user message', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { text: 'hello' }, + { inlineData: { mimeType: 'image/png', data: 'abc' } }, + ], + { splitToolMedia: true }, + ); + expect(messages).toEqual([ + { + role: 'user', + content: [{ text: 'hello' }, { image: 'data:image/png;base64,abc' }], + }, + ]); + }); + + it('rejects bare function call and response parts', () => { + const parts: Part[] = [ + { functionCall: { name: 'get_weather', args: {} } }, + { + functionResponse: { + name: 'get_weather', + response: { output: 'sunny' }, + }, + }, + ]; + + for (const part of parts) { + expect(() => + convertGeminiContentsToDashScopeMessages(part, { + splitToolMedia: true, + }), + ).toThrow(/wrap them in a Content object/); + expect(() => + convertGeminiContentsToDashScopeMessages([part], { + splitToolMedia: true, + }), + ).toThrow(/wrap them, and any other parts, in Content objects/); + } + }); + + it('maps inline PNG data to an image block', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'user', + parts: [{ inlineData: { mimeType: 'image/png', data: 'abc' } }], + }, + ], + { splitToolMedia: true }, + ); + expect(messages).toEqual([ + { + role: 'user', + content: [{ image: 'data:image/png;base64,abc' }], + }, + ]); + }); + + it('maps inline and file audio data to audio blocks', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'user', + parts: [ + { inlineData: { mimeType: 'audio/mpeg', data: 'abc' } }, + { + fileData: { + mimeType: 'audio/wav', + fileUri: 'https://example.test/audio.wav', + }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + expect(messages).toEqual([ + { + role: 'user', + content: [ + { audio: 'data:audio/mpeg;base64,abc' }, + { audio: 'https://example.test/audio.wav' }, + ], + }, + ]); + }); + + it('maps an unsupported mime type to a text placeholder', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'user', + parts: [{ inlineData: { mimeType: 'application/zip', data: 'abc' } }], + }, + ], + { splitToolMedia: true }, + ); + expect(messages).toEqual([ + { + role: 'user', + content: [{ text: '[Unsupported content type: application/zip]' }], + }, + ]); + }); +}); + +describe('convertGeminiContentsToDashScopeMessages — assistant content', () => { + it('concatenates thought parts into reasoning_content', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { text: 'step one ', thought: true }, + { text: 'step two', thought: true }, + { text: 'answer' }, + ], + }, + ], + { splitToolMedia: true }, + ); + expect(messages).toEqual([ + { + role: 'assistant', + content: [{ text: 'answer' }], + reasoning_content: 'step one step two', + }, + ]); + }); + + it('emits content: [] when only tool_calls are present', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { + functionCall: { name: 'get_weather', args: { city: 'Paris' } }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + expect(messages).toHaveLength(1); + const message = messages[0]!; + expect(message.role).toBe('assistant'); + expect(message.content).toEqual([]); + expect(message.tool_calls).toHaveLength(1); + expect(message.tool_calls![0]!.function.name).toBe('get_weather'); + expect(message.tool_calls![0]!.function.arguments).toBe('{"city":"Paris"}'); + }); + + it('synthesizes deterministic ids stable across two independent builds', () => { + const contents: GenerateContentParameters['contents'] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'get_weather', args: { city: 'Paris' } } }, + ], + }, + ]; + + const first = convertGeminiContentsToDashScopeMessages(contents, { + splitToolMedia: true, + }); + const second = convertGeminiContentsToDashScopeMessages(contents, { + splitToolMedia: true, + }); + + const firstId = first[0]!.tool_calls![0]!.id; + const secondId = second[0]!.tool_calls![0]!.id; + expect(firstId).toMatch(/^call_[0-9a-f]{24}$/); + expect(firstId).toBe(secondId); + }); + + it('uses the explicit functionCall id when present', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_explicit', + name: 'get_weather', + args: {}, + }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + expect(messages[0]!.tool_calls![0]!.id).toBe('call_explicit'); + }); +}); + +describe('convertGeminiContentsToDashScopeMessages — tool results', () => { + it('emits a separate tool message with a matching tool_call_id', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_1', + name: 'get_weather', + args: { city: 'Paris' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'get_weather', + response: { output: '22C sunny' }, + }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + + expect(messages[1]).toEqual({ + role: 'tool', + tool_call_id: 'call_1', + content: [{ text: '22C sunny' }], + }); + }); + + it('matches a functionResponse with no id by name and call order', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { functionCall: { name: 'get_weather', args: { city: 'Paris' } } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'get_weather', + response: { output: '22C sunny' }, + }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + + const callId = messages[0]!.tool_calls![0]!.id; + expect(messages[1]!.tool_call_id).toBe(callId); + }); + + it('advances call-order matching after an explicit-id response', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_1', + name: 'get_weather', + args: { city: 'Paris' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'get_weather', + response: { output: '22C sunny' }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { functionCall: { name: 'get_weather', args: { city: 'Tokyo' } } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'get_weather', + response: { output: '28C sunny' }, + }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + + const secondCallId = messages[2]!.tool_calls![0]!.id; + expect(messages[3]!.tool_call_id).toBe(secondCallId); + }); + + it('preserves text parts in a functionResponse', () => { + const messages = convertGeminiContentsToDashScopeMessages( + [ + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_1', + name: 'read_file', + args: {}, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'read_file', + response: { output: 'file contents' }, + parts: [ + { + text: '[Image omitted during compaction]', + } as FunctionResponsePart, + ], + }, + }, + ], + }, + ], + { splitToolMedia: true }, + ); + + expect(messages[1]).toEqual({ + role: 'tool', + tool_call_id: 'call_1', + content: [ + { text: 'file contents' }, + { text: '[Image omitted during compaction]' }, + ], + }); + }); +}); + +describe('cleanOrphanedToolCalls', () => { + it('drops an orphaned tool_calls entry and keeps the message if content survives', () => { + const messages: DashScopeMessage[] = [ + { + role: 'assistant', + content: [{ text: 'ok' }], + tool_calls: [ + { + id: 'call_orphan', + index: 0, + type: 'function', + function: { name: 'noop', arguments: '{}' }, + }, + ], + }, + ]; + const cleaned = cleanOrphanedToolCalls(messages); + expect(cleaned).toEqual([{ role: 'assistant', content: [{ text: 'ok' }] }]); + }); + + it('drops an assistant message left empty after orphan removal', () => { + const messages: DashScopeMessage[] = [ + { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_orphan', + index: 0, + type: 'function', + function: { name: 'noop', arguments: '{}' }, + }, + ], + }, + ]; + expect(cleanOrphanedToolCalls(messages)).toEqual([]); + }); + + it('drops an orphaned tool message with no matching tool_calls entry', () => { + const messages: DashScopeMessage[] = [ + { role: 'tool', tool_call_id: 'call_missing', content: [{ text: 'x' }] }, + ]; + expect(cleanOrphanedToolCalls(messages)).toEqual([]); + }); + + it('keeps a matched tool_calls/tool pair', () => { + const messages: DashScopeMessage[] = [ + { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_1', + index: 0, + type: 'function', + function: { name: 'get_weather', arguments: '{}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_1', content: [{ text: 'ok' }] }, + ]; + expect(cleanOrphanedToolCalls(messages)).toEqual(messages); + }); +}); + +describe('convertGeminiToolsToDashScopeTools + canonicalizeToolJson', () => { + function makeTool(order: 'a' | 'b'): Tool { + const propsA = { city: { type: 'string' }, unit: { type: 'string' } }; + const propsB = { unit: { type: 'string' }, city: { type: 'string' } }; + return { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather', + parametersJsonSchema: { + type: 'object', + properties: order === 'a' ? propsA : propsB, + required: ['city'], + }, + }, + ], + }; + } + + it('produces byte-identical JSON regardless of source property order', () => { + const toolsA = convertGeminiToolsToDashScopeTools([makeTool('a')]); + const toolsB = convertGeminiToolsToDashScopeTools([makeTool('b')]); + expect(JSON.stringify(toolsA)).toBe(JSON.stringify(toolsB)); + }); + + it('orders schema keys deterministically', () => { + const tool: DashScopeTool = { + type: 'function', + function: { + name: 'f', + parameters: { + required: ['b'], + properties: { b: { type: 'string' }, a: { type: 'string' } }, + type: 'object', + }, + }, + }; + const canonical = canonicalizeToolJson(tool); + expect(Object.keys(canonical.function.parameters!)).toEqual([ + 'type', + 'properties', + 'required', + ]); + expect( + Object.keys(canonical.function.parameters!['properties'] as object), + ).toEqual(['a', 'b']); + }); + + it('drops CallableTool entries (async resolution unsupported here)', () => { + const callable = { tool: async () => ({ functionDeclarations: [] }) }; + const result = convertGeminiToolsToDashScopeTools([callable as never]); + expect(result).toBeUndefined(); + }); +}); + +describe('buildDashScopeRequest — tools + cache_control', () => { + const tools: Tool[] = [ + { + functionDeclarations: [ + { name: 'tool_a', parametersJsonSchema: { type: 'object' } }, + ], + }, + { + functionDeclarations: [ + { name: 'tool_b', parametersJsonSchema: { type: 'object' } }, + ], + }, + ]; + + it('sets cache_control only on the last tool when caching is enabled', () => { + const result = build({ + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + config: { tools }, + }); + const wireTools = result.parameters['tools'] as DashScopeTool[]; + expect(wireTools[0]!.cache_control).toBeUndefined(); + expect(wireTools[1]!.cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('omits cache_control on tools when caching is disabled', () => { + const result = build( + { + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + config: { tools }, + }, + { enableCacheControl: false }, + ); + expect(JSON.stringify(result.parameters['tools'])).not.toContain( + 'cache_control', + ); + }); +}); + +describe('buildDashScopeRequest — parameters assembly', () => { + const baseRequest: GenerateContentParameters = { + model: 'qwen3.8-max', + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + }; + + it('uses the request model with the configured model as fallback', () => { + expect(build({ ...baseRequest, model: 'per-request-model' }).model).toBe( + 'per-request-model', + ); + expect(build({ ...baseRequest, model: '' }).model).toBe('qwen3.8-max'); + }); + + it('does not apply configured mandatory thinking to a model override', () => { + const result = build( + { ...baseRequest, model: 'per-request-model' }, + { + thinkingMandatory: true, + extra_body: { enable_thinking: false }, + }, + ); + expect(result.parameters['reasoning_effort']).toBe('none'); + }); + + it.each(['low', 'medium', 'xhigh'] as const)( + 'emits supported qwen3.8-max effort tier %s', + (effort) => { + const result = build(baseRequest, { reasoning: { effort } }); + expect(result.parameters['reasoning_effort']).toBe(effort); + }, + ); + + it.each(['high', 'max'] as const)( + 'clamps legacy qwen3.8-max effort tier %s to xhigh', + (effort) => { + const result = build(baseRequest, { reasoning: { effort } }); + expect(result.parameters['reasoning_effort']).toBe('xhigh'); + }, + ); + + it('does not constrain an editable native model with another id', () => { + const result = build( + { ...baseRequest, model: 'custom-model' }, + { reasoning: { effort: 'high' } }, + ); + expect(result.parameters['reasoning_effort']).toBe('high'); + }); + + it('preserves an explicit raw reasoning_effort override', () => { + const result = build(baseRequest, { + reasoning: { effort: 'low' }, + extra_body: { reasoning_effort: 'max' }, + }); + expect(result.parameters['reasoning_effort']).toBe('max'); + }); + + it('always sets result_format: message', () => { + const result = build(baseRequest); + expect(result.parameters['result_format']).toBe('message'); + }); + + it('sets incremental_output only when streaming', () => { + expect(build(baseRequest, {}, true).parameters['incremental_output']).toBe( + true, + ); + expect( + build(baseRequest, {}, false).parameters['incremental_output'], + ).toBeUndefined(); + }); + + it('maps maxOutputTokens to max_tokens and never emits max_completion_tokens', () => { + const result = build({ + ...baseRequest, + config: { maxOutputTokens: 1024 }, + }); + expect(result.parameters['max_tokens']).toBe(1024); + expect(result.parameters['max_completion_tokens']).toBeUndefined(); + }); + + it('maps request-level sampling controls', () => { + const result = build({ + ...baseRequest, + config: { + topK: 12, + presencePenalty: 0.25, + frequencyPenalty: -0.5, + }, + }); + + expect(result.parameters['top_k']).toBe(12); + expect(result.parameters['presence_penalty']).toBe(0.25); + expect(result.parameters['frequency_penalty']).toBe(-0.5); + }); + + it('maps configured frequency and repetition penalties', () => { + const result = build(baseRequest, { + samplingParams: { + frequency_penalty: 0.3, + repetition_penalty: 1.1, + }, + }); + + expect(result.parameters['frequency_penalty']).toBe(0.3); + expect(result.parameters['repetition_penalty']).toBe(1.1); + }); + + it('prefers request-level sampling controls over configured values', () => { + const result = build( + { + ...baseRequest, + config: { + topK: 12, + presencePenalty: 0.25, + frequencyPenalty: -0.5, + }, + }, + { + samplingParams: { + top_k: 20, + presence_penalty: 0.4, + frequency_penalty: 0.6, + }, + }, + ); + + expect(result.parameters['top_k']).toBe(12); + expect(result.parameters['presence_penalty']).toBe(0.25); + expect(result.parameters['frequency_penalty']).toBe(-0.5); + }); + + it('lets extra_body pass max_completion_tokens through explicitly', () => { + const result = build(baseRequest, { + extra_body: { max_completion_tokens: 2048 }, + }); + expect(result.parameters['max_completion_tokens']).toBe(2048); + }); + + it('passes an unknown extra_body key through and intercepts thinking keys', () => { + const result = build(baseRequest, { + extra_body: { + some_future_key: 'value', + enable_thinking: false, + reasoning_effort: 'low', + thinking_budget: 999, + }, + }); + expect(result.parameters['some_future_key']).toBe('value'); + expect(result.parameters['enable_thinking']).toBeUndefined(); + expect(result.parameters['reasoning_effort']).toBe('none'); + expect(result.parameters['thinking_budget']).toBeUndefined(); + }); + + it('omits tool_choice for AUTO mode', () => { + const result = build({ + ...baseRequest, + config: { + tools: [ + { + functionDeclarations: [ + { name: 'a', parametersJsonSchema: { type: 'object' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO }, + }, + }, + }); + expect(result.parameters['tool_choice']).toBeUndefined(); + }); + + it('maps ANY with a single allowed name to a named tool_choice object', () => { + const result = build({ + ...baseRequest, + config: { + tools: [ + { + functionDeclarations: [ + { name: 'a', parametersJsonSchema: { type: 'object' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { + mode: FunctionCallingConfigMode.ANY, + allowedFunctionNames: ['a'], + }, + }, + }, + }); + expect(result.parameters['tool_choice']).toEqual({ + type: 'function', + function: { name: 'a' }, + }); + }); + + it('maps ANY with multiple/no allowed names to required', () => { + const result = build({ + ...baseRequest, + config: { + tools: [ + { + functionDeclarations: [ + { name: 'a', parametersJsonSchema: { type: 'object' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: FunctionCallingConfigMode.ANY }, + }, + }, + }); + expect(result.parameters['tool_choice']).toBe('required'); + }); + + it('maps NONE to "none"', () => { + const result = build({ + ...baseRequest, + config: { + tools: [ + { + functionDeclarations: [ + { name: 'a', parametersJsonSchema: { type: 'object' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: FunctionCallingConfigMode.NONE }, + }, + }, + }); + expect(result.parameters['tool_choice']).toBe('none'); + }); + + it('downgrades a forced tool_choice to auto when thinking is mandatory', () => { + const result = build( + { + ...baseRequest, + config: { + tools: [ + { + functionDeclarations: [ + { name: 'a', parametersJsonSchema: { type: 'object' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: FunctionCallingConfigMode.ANY }, + }, + }, + }, + { thinkingMandatory: true }, + ); + expect(result.parameters['tool_choice']).toBe('auto'); + expect(result.parameters['reasoning_effort']).toBeUndefined(); + }); + + it('forces reasoning_effort:none for a forced tool_choice when not mandatory', () => { + const result = build({ + ...baseRequest, + config: { + tools: [ + { + functionDeclarations: [ + { name: 'a', parametersJsonSchema: { type: 'object' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: FunctionCallingConfigMode.ANY }, + }, + }, + }); + expect(result.parameters['reasoning_effort']).toBe('none'); + expect(result.parameters['tool_choice']).toBe('required'); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/request-converter.ts b/packages/core/src/core/dashscopeContentGenerator/request-converter.ts new file mode 100644 index 00000000000..a1e9514b367 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/request-converter.ts @@ -0,0 +1,911 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { + Content, + ContentListUnion, + FunctionCallingConfig, + FunctionResponse, + GenerateContentParameters, + Part, + PartUnion, + ToolListUnion, +} from '@google/genai'; +import { FunctionCallingConfigMode } from '@google/genai'; +import { AuthType, type ContentGeneratorConfig } from '../contentGenerator.js'; +import { getSupportedReasoningEffortTiers } from '../reasoning-effort.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { normalizeMcpToolName } from '../../utils/tool-name-utils.js'; +import { + convertSchema, + relaxSchemaForFunctionCalling, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; +import { planCacheMarkers } from './cache.js'; +import { resolveThinkingParameters } from './thinking.js'; +import type { + DashScopeContentBlock, + DashScopeMessage, + DashScopeRequest, + DashScopeTool, + DashScopeToolCall, +} from './types.js'; + +const debugLogger = createDebugLogger('DASHSCOPE'); +let warnedDroppedForcedToolChoice = false; + +/** + * Per-call state threaded through {@link convertGeminiContentsToDashScopeMessages} + * so a `functionResponse` with no explicit `id` resolves to the SAME + * `tool_call_id` its matching `functionCall` was assigned, by function name + * and call order. + */ +interface ConversionState { + callIdsByName: Map; + responseMatchIndexByName: Map; + synthesizedOrdinal: number; +} + +function createConversionState(): ConversionState { + return { + callIdsByName: new Map(), + responseMatchIndexByName: new Map(), + synthesizedOrdinal: 0, + }; +} + +/** + * Builds the native DashScope request body from a Gemini + * `GenerateContentParameters`. Pure — performs no I/O. + */ +export function buildDashScopeRequest( + request: GenerateContentParameters, + args: { + contentGeneratorConfig: ContentGeneratorConfig; + streaming: boolean; + thinkingMandatory?: boolean; + }, +): DashScopeRequest { + const { contentGeneratorConfig: config, streaming } = args; + const model = request.model || config.model; + + const systemText = extractTextFromContentUnion( + request.config?.systemInstruction, + ); + const splitToolMedia = config.splitToolMedia !== false; + + let messages = convertGeminiContentsToDashScopeMessages(request.contents, { + splitToolMedia, + }); + if (systemText) { + messages = [ + { role: 'system', content: [{ text: systemText }] }, + ...messages, + ]; + } + messages = cleanOrphanedToolCalls(messages); + + const enableCacheControl = config.enableCacheControl !== false; + + const tools = convertGeminiToolsToDashScopeTools( + request.config?.tools, + config.schemaCompliance, + ); + const finalTools = applyToolCacheControl(tools, enableCacheControl); + + const toolChoiceResolution = resolveToolChoiceRequest( + request.config?.toolConfig?.functionCallingConfig, + ); + + const resolvedThinking = resolveThinkingParameters({ + reasoning: config.reasoning, + thinkingConfig: request.config?.thinkingConfig + ? { + thinkingBudget: request.config.thinkingConfig.thinkingBudget, + includeThoughts: request.config.thinkingConfig.includeThoughts, + } + : undefined, + thinkingMandatory: + args.thinkingMandatory ?? + (config.thinkingMandatory === true && + model.toLowerCase() === config.model.toLowerCase()), + extraBody: config.extra_body, + supportedEfforts: getSupportedReasoningEffortTiers( + AuthType.USE_DASHSCOPE, + model, + ), + forcedToolChoice: toolChoiceResolution?.forced ?? false, + }); + + let toolChoiceValue = toolChoiceResolution?.value; + if (toolChoiceResolution?.forced && resolvedThinking.dropForcedToolChoice) { + toolChoiceValue = 'auto'; + if (!warnedDroppedForcedToolChoice) { + warnedDroppedForcedToolChoice = true; + debugLogger.warn( + 'buildDashScopeRequest: downgrading a forced tool_choice to "auto" ' + + 'because thinking is mandatory for this model.', + ); + } + } + + const parameters = buildParameters({ + config, + streaming, + request, + tools: finalTools, + toolChoiceValue, + resolvedThinking: resolvedThinking.params, + hasAssistantMessage: messages.some( + (message) => message.role === 'assistant', + ), + }); + + const cachedMessages = planCacheMarkers(messages, { + enabled: enableCacheControl, + streaming, + }); + + return { + model, + input: { messages: cachedMessages }, + parameters, + }; +} + +function buildParameters(args: { + config: ContentGeneratorConfig; + streaming: boolean; + request: GenerateContentParameters; + tools: DashScopeTool[] | undefined; + toolChoiceValue: unknown; + resolvedThinking: Record; + hasAssistantMessage: boolean; +}): Record { + const { + config, + streaming, + request, + tools, + toolChoiceValue, + resolvedThinking, + hasAssistantMessage, + } = args; + const samplingParams = config.samplingParams ?? {}; + + const parameters: Record = { result_format: 'message' }; + if (streaming) { + parameters['incremental_output'] = true; + } + + const temperature = request.config?.temperature ?? samplingParams.temperature; + if (temperature !== undefined) { + parameters['temperature'] = temperature; + } + + const topP = request.config?.topP ?? samplingParams.top_p; + if (topP !== undefined) { + parameters['top_p'] = topP; + } + + const topK = request.config?.topK ?? samplingParams.top_k; + if (topK !== undefined) { + parameters['top_k'] = topK; + } + + const seed = request.config?.seed; + if (seed !== undefined) { + parameters['seed'] = seed; + } + + const presencePenalty = + request.config?.presencePenalty ?? samplingParams.presence_penalty; + if (presencePenalty !== undefined) { + parameters['presence_penalty'] = presencePenalty; + } + + const frequencyPenalty = + request.config?.frequencyPenalty ?? samplingParams.frequency_penalty; + if (frequencyPenalty !== undefined) { + parameters['frequency_penalty'] = frequencyPenalty; + } + + const repetitionPenalty = samplingParams.repetition_penalty; + if (repetitionPenalty !== undefined) { + parameters['repetition_penalty'] = repetitionPenalty; + } + + const stop = request.config?.stopSequences; + if (stop !== undefined) { + parameters['stop'] = stop; + } + + const maxTokens = + request.config?.maxOutputTokens ?? samplingParams.max_tokens; + if (maxTokens !== undefined) { + parameters['max_tokens'] = maxTokens; + } + + Object.assign(parameters, resolvedThinking); + + if (tools && tools.length > 0) { + parameters['tools'] = tools; + if (toolChoiceValue !== undefined) { + parameters['tool_choice'] = toolChoiceValue; + } + parameters['parallel_tool_calls'] = true; + } + + if (hasAssistantMessage) { + parameters['preserve_thinking'] = true; + } + + if (config.extra_body) { + for (const [key, value] of Object.entries(config.extra_body)) { + if ( + key === 'enable_thinking' || + key === 'reasoning_effort' || + key === 'thinking_budget' + ) { + continue; + } + parameters[key] = value; + } + } + + return parameters; +} + +function applyToolCacheControl( + tools: DashScopeTool[] | undefined, + enabled: boolean, +): DashScopeTool[] | undefined { + if (!tools || tools.length === 0 || !enabled) { + return tools; + } + const lastIndex = tools.length - 1; + return tools.map((tool, index) => + index === lastIndex + ? { ...tool, cache_control: { type: 'ephemeral' as const } } + : tool, + ); +} + +function resolveToolChoiceRequest( + functionCallingConfig: FunctionCallingConfig | undefined, +): { value: unknown; forced: boolean } | undefined { + const mode = functionCallingConfig?.mode; + + if (mode === FunctionCallingConfigMode.NONE) { + return { value: 'none', forced: false }; + } + + if (mode === FunctionCallingConfigMode.ANY) { + const allowed = functionCallingConfig?.allowedFunctionNames; + if (allowed && allowed.length === 1) { + return { + value: { type: 'function', function: { name: allowed[0] } }, + forced: true, + }; + } + return { value: 'required', forced: true }; + } + + return undefined; +} + +/** + * Converts normalized Gemini `Content`/`Part` history into native DashScope + * messages. Never merges same-role messages; runs before + * {@link cleanOrphanedToolCalls}. + */ +export function convertGeminiContentsToDashScopeMessages( + contents: ContentListUnion, + opts: { splitToolMedia: boolean }, +): DashScopeMessage[] { + const messages: DashScopeMessage[] = []; + const state = createConversionState(); + + for (const item of normalizeContents(contents)) { + processContentItem(item, messages, state, opts); + } + + return messages; +} + +function normalizeContents(contents: ContentListUnion): Content[] { + if (contents === undefined) { + return []; + } + + if (!Array.isArray(contents)) { + if (isFunctionPart(contents)) { + throw new Error( + 'To specify functionCall or functionResponse parts, please wrap them in a Content object, specifying the role for them', + ); + } + return isContentObject(contents) + ? [contents] + : [{ role: 'user', parts: [normalizePart(contents)] }]; + } + + const normalized: Content[] = []; + const parts: Part[] = []; + const isContentArray = isContentObject(contents[0]); + + for (const item of contents) { + const isContent = isContentObject(item); + if (isContent !== isContentArray) { + throw new Error( + 'Mixing Content and Parts is not supported, please group the parts into a the appropriate Content objects and specify the roles for them', + ); + } + if (isContent) { + normalized.push(item); + } else if (isFunctionPart(item)) { + throw new Error( + 'To specify functionCall or functionResponse parts, please wrap them, and any other parts, in Content objects as appropriate, specifying the role for them', + ); + } else { + parts.push(normalizePart(item)); + } + } + + if (!isContentArray && parts.length > 0) { + normalized.push({ role: 'user', parts }); + } + return normalized; +} + +function normalizePart(part: PartUnion): Part { + return typeof part === 'string' ? { text: part } : part; +} + +function isFunctionPart(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + ('functionCall' in value || 'functionResponse' in value) + ); +} + +function processContentItem( + item: unknown, + messages: DashScopeMessage[], + state: ConversionState, + opts: { splitToolMedia: boolean }, +): void { + if (typeof item === 'string') { + messages.push({ role: 'user', content: [{ text: item }] }); + return; + } + + if (!isContentObject(item)) { + return; + } + + const role = item.role === 'model' ? 'assistant' : 'user'; + const parts = item.parts ?? []; + + if (role === 'assistant') { + processAssistantContent(parts, messages, state); + } else { + processUserContent(parts, messages, state, opts); + } +} + +function isContentObject(value: unknown): value is Content { + return ( + typeof value === 'object' && + value !== null && + 'parts' in value && + Array.isArray((value as Record)['parts']) + ); +} + +function processAssistantContent( + parts: Part[], + messages: DashScopeMessage[], + state: ConversionState, +): void { + const contentBlocks: DashScopeContentBlock[] = []; + const reasoningParts: string[] = []; + const toolCalls: DashScopeToolCall[] = []; + + for (const part of parts) { + if (typeof part === 'string') { + contentBlocks.push({ text: part }); + continue; + } + + if (part.thought) { + if (part.text) { + reasoningParts.push(part.text); + } + continue; + } + + if (part.text) { + contentBlocks.push({ text: part.text }); + continue; + } + + if (part.functionCall) { + const rawName = part.functionCall.name ?? ''; + const id = resolveFunctionCallId( + { + id: part.functionCall.id, + name: rawName, + args: part.functionCall.args, + }, + state, + ); + recordFunctionCallId(rawName, id, state); + toolCalls.push({ + id, + index: toolCalls.length, + type: 'function', + function: { + name: normalizeMcpToolName(rawName), + arguments: JSON.stringify(part.functionCall.args ?? {}), + }, + }); + } + } + + const reasoningContent = reasoningParts.join(''); + if ( + contentBlocks.length === 0 && + toolCalls.length === 0 && + reasoningContent.length === 0 + ) { + return; + } + + const message: DashScopeMessage = { + role: 'assistant', + content: contentBlocks, + }; + if (reasoningContent) { + message.reasoning_content = reasoningContent; + } + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + } + messages.push(message); +} + +function processUserContent( + parts: Part[], + messages: DashScopeMessage[], + state: ConversionState, + opts: { splitToolMedia: boolean }, +): void { + const contentBlocks: DashScopeContentBlock[] = []; + const splitMediaBlocks: DashScopeContentBlock[] = []; + + for (const part of parts) { + if (typeof part === 'string') { + contentBlocks.push({ text: part }); + continue; + } + + if (part.functionResponse) { + const toolCallId = resolveFunctionResponseToolCallId( + part.functionResponse, + state, + ); + const { toolMessage, mediaBlocks } = buildToolMessage( + part.functionResponse, + toolCallId, + opts.splitToolMedia, + ); + messages.push(toolMessage); + splitMediaBlocks.push(...mediaBlocks); + continue; + } + + if (part.text) { + contentBlocks.push({ text: part.text }); + continue; + } + + const mediaBlock = partToMediaBlock(part); + if (mediaBlock) { + contentBlocks.push(mediaBlock); + } + } + + if (contentBlocks.length > 0) { + messages.push({ role: 'user', content: contentBlocks }); + } + if (splitMediaBlocks.length > 0) { + messages.push({ role: 'user', content: splitMediaBlocks }); + } +} + +function buildToolMessage( + functionResponse: FunctionResponse, + toolCallId: string, + splitToolMedia: boolean, +): { toolMessage: DashScopeMessage; mediaBlocks: DashScopeContentBlock[] } { + const text = extractFunctionResponseContent(functionResponse.response); + const mediaBlocks: DashScopeContentBlock[] = []; + const inlineBlocks: DashScopeContentBlock[] = []; + + for (const responsePart of functionResponse.parts ?? []) { + const part = responsePart as Part; + if (part.text !== undefined) { + inlineBlocks.push({ text: part.text }); + continue; + } + const block = partToMediaBlock(part); + if (!block) continue; + if (splitToolMedia) { + mediaBlocks.push(block); + } else { + inlineBlocks.push(block); + } + } + + const content: DashScopeContentBlock[] = [{ text }, ...inlineBlocks]; + + return { + toolMessage: { role: 'tool', tool_call_id: toolCallId, content }, + mediaBlocks, + }; +} + +function partToMediaBlock(part: Part): DashScopeContentBlock | undefined { + if (part.inlineData?.mimeType && part.inlineData?.data) { + return blockForMimeType( + part.inlineData.mimeType, + `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`, + ); + } + if (part.fileData?.mimeType && part.fileData?.fileUri) { + return blockForMimeType(part.fileData.mimeType, part.fileData.fileUri); + } + return undefined; +} + +function blockForMimeType( + mimeType: string, + value: string, +): DashScopeContentBlock { + if (mimeType.startsWith('image/')) { + return { image: value }; + } + if (mimeType.startsWith('video/')) { + return { video: value }; + } + if (mimeType.startsWith('audio/')) { + return { audio: value }; + } + if (mimeType === 'application/pdf') { + return { file: value }; + } + return { text: `[Unsupported content type: ${mimeType}]` }; +} + +function resolveFunctionCallId( + functionCall: { id?: string; name: string; args?: Record }, + state: ConversionState, +): string { + if (typeof functionCall.id === 'string' && functionCall.id.length > 0) { + return functionCall.id; + } + + const argsJson = JSON.stringify(functionCall.args ?? {}); + const ordinal = state.synthesizedOrdinal; + state.synthesizedOrdinal += 1; + const hash = createHash('sha256') + .update(`${functionCall.name}:${argsJson}:${ordinal}`) + .digest('hex') + .slice(0, 24); + return `call_${hash}`; +} + +function recordFunctionCallId( + name: string, + id: string, + state: ConversionState, +): void { + const existing = state.callIdsByName.get(name); + if (existing) { + existing.push(id); + } else { + state.callIdsByName.set(name, [id]); + } +} + +function resolveFunctionResponseToolCallId( + functionResponse: FunctionResponse, + state: ConversionState, +): string { + const name = functionResponse.name ?? ''; + const matchIndex = state.responseMatchIndexByName.get(name) ?? 0; + state.responseMatchIndexByName.set(name, matchIndex + 1); + + if ( + typeof functionResponse.id === 'string' && + functionResponse.id.length > 0 + ) { + return functionResponse.id; + } + + const ids = state.callIdsByName.get(name); + return ids?.[matchIndex] ?? ''; +} + +function extractFunctionResponseContent(response: unknown): string { + if (response === null || response === undefined) { + return ''; + } + + if (typeof response === 'string') { + return response; + } + + if (typeof response === 'object') { + const responseObject = response as Record; + const output = responseObject['output']; + if (typeof output === 'string') { + return output; + } + + const error = responseObject['error']; + if (typeof error === 'string') { + return error; + } + } + + try { + return JSON.stringify(response) ?? String(response); + } catch { + return String(response); + } +} + +function extractTextFromContentUnion(contentUnion: unknown): string { + if (typeof contentUnion === 'string') { + return contentUnion; + } + + if (Array.isArray(contentUnion)) { + return contentUnion + .map((item) => extractTextFromContentUnion(item)) + .filter(Boolean) + .join('\n'); + } + + if (typeof contentUnion === 'object' && contentUnion !== null) { + if ('parts' in contentUnion) { + const content = contentUnion as Content; + return ( + content.parts + ?.map((part: Part) => { + if (typeof part === 'string') return part; + if ('text' in part) return part.text || ''; + return ''; + }) + .filter(Boolean) + .join('\n') || '' + ); + } + if ('text' in contentUnion) { + return (contentUnion as Part).text || ''; + } + } + + return ''; +} + +/** + * Removes `tool_calls` entries with no matching later `role: 'tool'` + * message, and `role: 'tool'` messages whose `tool_call_id` matches no + * earlier `tool_calls[].id`. Drops assistant messages left with an empty + * `content`, no `reasoning_content`, and no surviving `tool_calls`. + */ +export function cleanOrphanedToolCalls( + messages: DashScopeMessage[], +): DashScopeMessage[] { + const resultIds = new Set( + messages + .filter((message) => message.role === 'tool' && message.tool_call_id) + .map((message) => message.tool_call_id as string), + ); + const callIds = new Set( + messages.flatMap((message) => + message.role === 'assistant' && message.tool_calls + ? message.tool_calls.map((toolCall) => toolCall.id) + : [], + ), + ); + + const cleaned: DashScopeMessage[] = []; + for (const message of messages) { + if (message.role === 'tool') { + if (message.tool_call_id && callIds.has(message.tool_call_id)) { + cleaned.push(message); + } + continue; + } + + if (message.role === 'assistant') { + const survivingToolCalls = message.tool_calls?.filter((toolCall) => + resultIds.has(toolCall.id), + ); + const hasContent = + Array.isArray(message.content) && message.content.length > 0; + const hasReasoning = Boolean(message.reasoning_content); + const hasToolCalls = Boolean(survivingToolCalls?.length); + + if (!hasContent && !hasReasoning && !hasToolCalls) { + continue; + } + + cleaned.push({ + ...message, + tool_calls: hasToolCalls ? survivingToolCalls : undefined, + }); + continue; + } + + cleaned.push(message); + } + + return cleaned; +} + +/** + * Converts Gemini tool declarations (plain `Tool` entries with either + * `parameters` or MCP-style `parametersJsonSchema`) into native DashScope + * tools, then runs {@link canonicalizeToolJson} for byte-stable output. + * `CallableTool` entries are skipped — resolving them requires an async + * `tool()` call, and this converter (and `buildDashScopeRequest`) is + * synchronous; callers must resolve `CallableTool`s to plain `Tool`s first. + */ +export function convertGeminiToolsToDashScopeTools( + tools: ToolListUnion | undefined, + schemaCompliance: SchemaComplianceMode = 'auto', +): DashScopeTool[] | undefined { + if (!tools || tools.length === 0) { + return undefined; + } + + const result: DashScopeTool[] = []; + + for (const tool of tools) { + if ('tool' in tool) { + continue; + } + + const functionDeclarations = tool.functionDeclarations; + if (!functionDeclarations) { + continue; + } + + for (const decl of functionDeclarations) { + if (!decl.name) continue; + + let parameters: Record | undefined; + if (decl.parametersJsonSchema) { + parameters = { + ...(decl.parametersJsonSchema as Record), + }; + } else if (decl.parameters) { + parameters = decl.parameters as Record; + } + + if (parameters) { + parameters = convertSchema(parameters, schemaCompliance); + parameters = relaxSchemaForFunctionCalling(parameters); + } + + result.push( + canonicalizeToolJson({ + type: 'function', + function: { + name: decl.name, + description: decl.description, + parameters, + }, + }), + ); + } + } + + return result.length > 0 ? result : undefined; +} + +const SCHEMA_KEY_ORDER = [ + 'type', + 'description', + 'properties', + 'required', + 'enum', + 'items', + 'additionalProperties', +] as const; + +/** + * Rebuilds a tool with a fixed, deterministic key order — `type`, `function` + * (`name`, `description`, `parameters`), `cache_control`; and inside every + * schema object, `type`, `description`, `properties` (alphabetized), + * `required`, `enum`, `items`, `additionalProperties`, then any remaining + * keys sorted alphabetically. Drops `undefined` values. Makes + * `JSON.stringify` byte-stable across independently-built requests, which + * explicit prompt caching depends on for prefix hits. + */ +export function canonicalizeToolJson(tool: DashScopeTool): DashScopeTool { + const canonical: DashScopeTool = { + type: 'function', + function: { + name: tool.function.name, + ...(tool.function.description !== undefined + ? { description: tool.function.description } + : {}), + ...(tool.function.parameters !== undefined + ? { + parameters: canonicalizeSchemaValue( + tool.function.parameters, + ) as Record, + } + : {}), + }, + }; + + if (tool.cache_control !== undefined) { + canonical.cache_control = tool.cache_control; + } + + return canonical; +} + +function canonicalizeSchemaValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalizeSchemaValue(item)); + } + if (value === null || typeof value !== 'object') { + return value; + } + + const source = value as Record; + const remainingKeys = Object.keys(source) + .filter((key) => !(SCHEMA_KEY_ORDER as readonly string[]).includes(key)) + .sort(); + const orderedKeys = [...SCHEMA_KEY_ORDER, ...remainingKeys]; + + const result: Record = {}; + for (const key of orderedKeys) { + if (!(key in source)) continue; + const raw = source[key]; + if (raw === undefined) continue; + + if ( + key === 'properties' && + typeof raw === 'object' && + raw !== null && + !Array.isArray(raw) + ) { + const properties = raw as Record; + const sortedProperties: Record = {}; + for (const propertyKey of Object.keys(properties).sort()) { + sortedProperties[propertyKey] = canonicalizeSchemaValue( + properties[propertyKey], + ); + } + result[key] = sortedProperties; + continue; + } + + result[key] = canonicalizeSchemaValue(raw); + } + + return result; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/response-converter.test.ts b/packages/core/src/core/dashscopeContentGenerator/response-converter.test.ts new file mode 100644 index 00000000000..5d1d86ae722 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/response-converter.test.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { FinishReason } from '@google/genai'; +import { + convertDashScopeResponseToGemini, + convertToolCallToFunctionCallPart, + mapDashScopeFinishReason, +} from './response-converter.js'; +import type { DashScopeResponsePayload, DashScopeToolCall } from './types.js'; + +describe('mapDashScopeFinishReason', () => { + it.each([ + ['stop', FinishReason.STOP], + ['length', FinishReason.MAX_TOKENS], + ['tool_calls', FinishReason.STOP], + ['content_filter', FinishReason.SAFETY], + ] as const)('maps %s to %s', (raw, expected) => { + expect(mapDashScopeFinishReason(raw)).toBe(expected); + }); + + it.each([['null'], [''], [null], [undefined]] as const)( + 'treats %s as still generating (undefined)', + (raw) => { + expect(mapDashScopeFinishReason(raw)).toBeUndefined(); + }, + ); + + it('falls back to STOP for an unrecognized value', () => { + expect(mapDashScopeFinishReason('something_new')).toBe(FinishReason.STOP); + }); +}); + +describe('convertToolCallToFunctionCallPart', () => { + it('parses arguments and preserves id/name', () => { + const call: DashScopeToolCall = { + id: 'call_abc', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Paris"}' }, + }; + + expect(convertToolCallToFunctionCallPart(call)).toEqual({ + functionCall: { + id: 'call_abc', + name: 'get_weather', + args: { city: 'Paris' }, + }, + }); + }); + + it('treats empty id/arguments as undefined id and empty args', () => { + const call: DashScopeToolCall = { + id: '', + type: 'function', + function: { arguments: '' }, + }; + + expect(convertToolCallToFunctionCallPart(call)).toEqual({ + functionCall: { id: undefined, name: undefined, args: {} }, + }); + }); +}); + +describe('convertDashScopeResponseToGemini', () => { + it('orders parts: thought -> text -> functionCall', () => { + const payload: DashScopeResponsePayload = { + output: { + choices: [ + { + finish_reason: 'tool_calls', + message: { + role: 'assistant', + reasoning_content: 'thinking...', + content: [{ text: 'here is the answer' }], + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"city":"Paris"}', + }, + }, + ], + }, + }, + ], + }, + request_id: 'req-1', + }; + + const response = convertDashScopeResponseToGemini(payload, 'qwen3.8-max'); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'thinking...', thought: true }, + { text: 'here is the answer' }, + { + functionCall: { + id: 'call_1', + name: 'get_weather', + args: { city: 'Paris' }, + }, + }, + ]); + expect(response.responseId).toBe('req-1'); + expect(response.modelVersion).toBe('qwen3.8-max'); + }); + + it('tolerates missing message/content (empty parts, STOP fallback)', () => { + const response = convertDashScopeResponseToGemini({}, 'qwen3.8-max'); + + expect(response.candidates?.[0]?.content?.parts).toEqual([]); + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + }); + + it('yields functionCall parts even when finish_reason is "stop" (never infer from finish_reason)', () => { + const payload: DashScopeResponsePayload = { + output: { + choices: [ + { + finish_reason: 'stop', + message: { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{}' }, + }, + ], + }, + }, + ], + }, + }; + + const response = convertDashScopeResponseToGemini(payload, 'qwen3.8-max'); + + expect(response.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(response.candidates?.[0]?.content?.parts).toContainEqual({ + functionCall: { id: 'call_1', name: 'get_weather', args: {} }, + }); + }); + + it('withholds tool calls when finish_reason is "length"', () => { + const payload: DashScopeResponsePayload = { + output: { + choices: [ + { + finish_reason: 'length', + message: { + role: 'assistant', + content: [{ text: 'partial response' }], + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup_user', + arguments: '{"name":"Alexander', + }, + }, + ], + }, + }, + ], + }, + }; + + const response = convertDashScopeResponseToGemini(payload, 'qwen3.8-max'); + + expect(response.candidates?.[0]?.finishReason).toBe( + FinishReason.MAX_TOKENS, + ); + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'partial response' }, + ]); + }); + + it('skips empty reasoning_content and empty text blocks', () => { + const payload: DashScopeResponsePayload = { + output: { + choices: [ + { + finish_reason: 'stop', + message: { + role: 'assistant', + reasoning_content: '', + content: [{ text: '' }, { text: 'hello' }], + }, + }, + ], + }, + }; + + const response = convertDashScopeResponseToGemini(payload, 'qwen3.8-max'); + + expect(response.candidates?.[0]?.content?.parts).toEqual([ + { text: 'hello' }, + ]); + }); + + it('attaches usageMetadata built via buildDashScopeUsageMetadata', () => { + const payload: DashScopeResponsePayload = { + output: { + choices: [ + { + finish_reason: 'stop', + message: { role: 'assistant', content: [] }, + }, + ], + }, + usage: { + input_tokens: 1581, + output_tokens: 9, + prompt_tokens_details: { cached_tokens: 1564 }, + }, + }; + + const response = convertDashScopeResponseToGemini(payload, 'qwen3.8-max'); + + expect(response.usageMetadata?.promptTokenCount).toBe(1581); + expect(response.usageMetadata?.cachedContentTokenCount).toBe(1564); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/response-converter.ts b/packages/core/src/core/dashscopeContentGenerator/response-converter.ts new file mode 100644 index 00000000000..56465341929 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/response-converter.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; +import { safeJsonParse } from '../../utils/safeJsonParse.js'; +import { buildDashScopeUsageMetadata } from './usage.js'; +import type { DashScopeResponsePayload, DashScopeToolCall } from './types.js'; + +/** + * Maps a DashScope `finish_reason` to Gemini's `FinishReason`. + * + * The wire sends the literal 4-char JSON string `"null"` on every + * non-terminal streaming frame (not the JSON value `null`) — treat it, `''`, + * `null`, and `undefined` alike as "still generating" and return + * `undefined`. Only the known terminal set maps explicitly; anything else + * unrecognized falls back to `STOP` (api-contract.md §4). + */ +export function mapDashScopeFinishReason( + raw: string | null | undefined, +): FinishReason | undefined { + switch (raw) { + case undefined: + case null: + case '': + case 'null': + return undefined; + case 'stop': + return FinishReason.STOP; + case 'length': + return FinishReason.MAX_TOKENS; + case 'tool_calls': + return FinishReason.STOP; + case 'content_filter': + return FinishReason.SAFETY; + default: + return FinishReason.STOP; + } +} + +export function convertToolCallToFunctionCallPart( + call: DashScopeToolCall, +): Part { + return { + functionCall: { + id: call.id || undefined, + name: call.function?.name, + args: safeJsonParse(call.function?.arguments || '{}', {}), + }, + }; +} + +export function convertDashScopeResponseToGemini( + payload: DashScopeResponsePayload, + model: string, +): GenerateContentResponse { + const response = new GenerateContentResponse(); + const choice = payload.output?.choices?.[0]; + const message = choice?.message; + const finishReason = + mapDashScopeFinishReason(choice?.finish_reason) ?? FinishReason.STOP; + const parts: Part[] = []; + + if (message?.reasoning_content) { + parts.push({ text: message.reasoning_content, thought: true }); + } + + const content = message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.text) { + parts.push({ text: block.text }); + } + } + } else if (typeof content === 'string' && content) { + parts.push({ text: content }); + } + + if (finishReason !== FinishReason.MAX_TOKENS) { + for (const call of message?.tool_calls ?? []) { + parts.push(convertToolCallToFunctionCallPart(call)); + } + } + + response.candidates = [ + { + content: { role: 'model', parts }, + finishReason, + index: 0, + }, + ]; + response.responseId = payload.request_id; + response.modelVersion = model; + response.usageMetadata = buildDashScopeUsageMetadata(payload.usage); + + return response; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/sse.test.ts b/packages/core/src/core/dashscopeContentGenerator/sse.test.ts new file mode 100644 index 00000000000..3ed7d08394d --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/sse.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { parseDashScopeSse, type DashScopeSseFrame } from './sse.js'; + +function streamFromString(body: string): ReadableStream { + const bytes = new TextEncoder().encode(body); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +function streamByteAtATime(body: string): ReadableStream { + const bytes = new TextEncoder().encode(body); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index >= bytes.length) { + controller.close(); + return; + } + controller.enqueue(bytes.slice(index, index + 1)); + index += 1; + }, + }); +} + +async function collect( + stream: ReadableStream, +): Promise { + const frames: DashScopeSseFrame[] = []; + for await (const frame of parseDashScopeSse(stream)) { + frames.push(frame); + } + return frames; +} + +const THREE_FRAME_SAMPLE = [ + 'id:1', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"The"}}]}}', + '', + 'id:2', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[{"text":"Hi"}]}}]}}', + '', + 'id:3', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":[]}}]}}', + '', +].join('\n'); + +describe('parseDashScopeSse', () => { + it('parses a whole-body 3-frame sample into 3 frames', async () => { + const frames = await collect(streamFromString(THREE_FRAME_SAMPLE)); + expect(frames).toHaveLength(3); + expect(frames[0]).toEqual({ + id: '1', + event: 'result', + httpStatus: 200, + data: '{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"The"}}]}}', + }); + expect(frames[1].id).toBe('2'); + expect(frames[2].data).toContain('"finish_reason":"stop"'); + }); + + it('parses byte-at-a-time identically to the whole-body parse', async () => { + const wholeFrames = await collect(streamFromString(THREE_FRAME_SAMPLE)); + const byteFrames = await collect(streamByteAtATime(THREE_FRAME_SAMPLE)); + expect(byteFrames).toEqual(wholeFrames); + }); + + it('tolerates CRLF line endings', async () => { + const crlfSample = THREE_FRAME_SAMPLE.split('\n').join('\r\n'); + const frames = await collect(streamFromString(crlfSample)); + expect(frames).toHaveLength(3); + expect(frames[0].id).toBe('1'); + expect(frames[0].httpStatus).toBe(200); + }); + + it('joins multiple data: lines in one frame with a newline', async () => { + const body = [ + 'id:1', + 'event:result', + 'data:{"a":1}', + 'data:{"b":2}', + '', + ].join('\n'); + const frames = await collect(streamFromString(body)); + expect(frames).toHaveLength(1); + expect(frames[0].data).toBe('{"a":1}\n{"b":2}'); + }); + + it('surfaces an event:error frame with :HTTP_STATUS/400', async () => { + const body = [ + 'id:1', + 'event:error', + ':HTTP_STATUS/400', + 'data:{"code":"InvalidParameter","message":"<400> bad request","request_id":"req-1"}', + '', + ].join('\n'); + const frames = await collect(streamFromString(body)); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ + id: '1', + event: 'error', + httpStatus: 400, + }); + }); + + it('flushes a trailing frame without a final blank line at EOF', async () => { + const body = ['id:1', 'event:result', 'data:{"a":1}'].join('\n'); + const frames = await collect(streamFromString(body)); + expect(frames).toHaveLength(1); + expect(frames[0].data).toBe('{"a":1}'); + }); + + it('does not yield a frame with no data: lines', async () => { + const body = ['id:1', 'event:result', ':HTTP_STATUS/200', ''].join('\n'); + const frames = await collect(streamFromString(body)); + expect(frames).toHaveLength(0); + }); + + it('releases the reader lock after normal completion', async () => { + const stream = streamFromString(THREE_FRAME_SAMPLE); + for await (const _frame of parseDashScopeSse(stream)) { + // drain + } + expect(stream.locked).toBe(false); + }); + + it('cancels the underlying reader when the generator is returned early', async () => { + const stream = streamFromString(THREE_FRAME_SAMPLE); + const generator = parseDashScopeSse(stream); + await generator.next(); + await generator.return(undefined); + expect(stream.locked).toBe(false); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/sse.ts b/packages/core/src/core/dashscopeContentGenerator/sse.ts new file mode 100644 index 00000000000..237f59fcd30 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/sse.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface DashScopeSseFrame { + id?: string; + event?: string; + httpStatus?: number; + data: string; +} + +interface PendingFrame { + id?: string; + event?: string; + httpStatus?: number; + dataLines: string[]; +} + +function createPendingFrame(): PendingFrame { + return { dataLines: [] }; +} + +const HTTP_STATUS_COMMENT_RE = /^:HTTP_STATUS\/(\d{3})/; + +function applyLine(frame: PendingFrame, line: string): void { + if (line.length === 0) { + return; + } + if (line.startsWith('id:')) { + frame.id = line.slice('id:'.length); + return; + } + if (line.startsWith('event:')) { + frame.event = line.slice('event:'.length); + return; + } + if (line.startsWith('data:')) { + frame.dataLines.push(line.slice('data:'.length)); + return; + } + if (line.startsWith(':')) { + const match = HTTP_STATUS_COMMENT_RE.exec(line); + if (match) { + frame.httpStatus = Number(match[1]); + } + return; + } + // Any other non-blank line (unrecognized field) is ignored. +} + +function toFrame(frame: PendingFrame): DashScopeSseFrame | undefined { + if (frame.dataLines.length === 0) { + return undefined; + } + return { + ...(frame.id !== undefined ? { id: frame.id } : {}), + ...(frame.event !== undefined ? { event: frame.event } : {}), + ...(frame.httpStatus !== undefined ? { httpStatus: frame.httpStatus } : {}), + data: frame.dataLines.join('\n'), + }; +} + +/** + * Decodes the native DashScope SSE wire format: field lines with no space + * after the colon (`data:{...}`), an `:HTTP_STATUS/NNN` comment line carrying + * the HTTP status, and a blank line terminating each frame. Never throws on + * malformed content; JSON parsing of `data` happens downstream. + */ +export async function* parseDashScopeSse( + body: ReadableStream, +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let frame = createPendingFrame(); + let finishedNormally = false; + + function consumeLines(chunk: string): DashScopeSseFrame[] { + buffer += chunk; + const frames: DashScopeSseFrame[] = []; + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + let line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + if (line.endsWith('\r')) { + line = line.slice(0, -1); + } + if (line.length === 0) { + const completed = toFrame(frame); + frame = createPendingFrame(); + if (completed) { + frames.push(completed); + } + continue; + } + applyLine(frame, line); + } + return frames; + } + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const chunk = decoder.decode(value, { stream: true }); + for (const completed of consumeLines(chunk)) { + yield completed; + } + } + + const finalChunk = decoder.decode(); + for (const completed of consumeLines(finalChunk)) { + yield completed; + } + if (buffer.length > 0) { + let line = buffer; + if (line.endsWith('\r')) { + line = line.slice(0, -1); + } + if (line.length > 0) { + applyLine(frame, line); + } + } + const trailing = toFrame(frame); + if (trailing) { + yield trailing; + } + finishedNormally = true; + } finally { + if (!finishedNormally) { + await reader.cancel().catch(() => {}); + } + reader.releaseLock(); + } +} diff --git a/packages/core/src/core/dashscopeContentGenerator/stream-accumulator.test.ts b/packages/core/src/core/dashscopeContentGenerator/stream-accumulator.test.ts new file mode 100644 index 00000000000..2fd60c51a53 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/stream-accumulator.test.ts @@ -0,0 +1,377 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import type { GenerateContentResponse } from '@google/genai'; +import { FinishReason } from '@google/genai'; +import { DashScopeStreamAccumulator } from './stream-accumulator.js'; +import { parseDashScopeSse, type DashScopeSseFrame } from './sse.js'; +import { DashScopeApiError } from './errors.js'; +import { getToolCallPreparations } from '../tool-call-preparation.js'; + +const STREAM_TOOLS_FIXTURE = readFileSync( + new URL('./__fixtures__/stream-tools.sse.txt', import.meta.url), + 'utf-8', +); +const STREAM_TEXT_FIXTURE = readFileSync( + new URL('./__fixtures__/stream-text.sse.txt', import.meta.url), + 'utf-8', +); +const ERROR_FRAME_FIXTURE = readFileSync( + new URL('./__fixtures__/error-frame.sse.txt', import.meta.url), + 'utf-8', +); + +function streamFromString(body: string): ReadableStream { + const bytes = new TextEncoder().encode(body); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +function streamByteAtATime(body: string): ReadableStream { + const bytes = new TextEncoder().encode(body); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index >= bytes.length) { + controller.close(); + return; + } + controller.enqueue(bytes.slice(index, index + 1)); + index += 1; + }, + }); +} + +async function collectFrames( + stream: ReadableStream, +): Promise { + const frames: DashScopeSseFrame[] = []; + for await (const frame of parseDashScopeSse(stream)) { + frames.push(frame); + } + return frames; +} + +async function replay( + fixture: string, + model = 'qwen3.8-max', +): Promise<{ + accumulator: DashScopeStreamAccumulator; + chunks: GenerateContentResponse[]; +}> { + const accumulator = new DashScopeStreamAccumulator(model); + const chunks: GenerateContentResponse[] = []; + for (const frame of await collectFrames(streamFromString(fixture))) { + chunks.push(...accumulator.push(frame)); + } + return { accumulator, chunks }; +} + +describe('DashScopeStreamAccumulator', () => { + it('replays stream-tools.sse.txt: thoughts first, no intermediate finishReason/usageMetadata', async () => { + const { accumulator, chunks } = await replay(STREAM_TOOLS_FIXTURE); + + expect(chunks.length).toBeGreaterThan(0); + const finalChunk = chunks[chunks.length - 1]; + const intermediateChunks = chunks.slice(0, -1); + + for (const chunk of intermediateChunks) { + expect(chunk.candidates?.[0]?.finishReason).toBeUndefined(); + expect(chunk.usageMetadata).toBeUndefined(); + } + + // Thought parts appear before any functionCall part across the stream. + const firstFunctionCallChunkIndex = chunks.findIndex((chunk) => + chunk.candidates?.[0]?.content?.parts?.some((part) => part.functionCall), + ); + const firstThoughtChunkIndex = chunks.findIndex((chunk) => + chunk.candidates?.[0]?.content?.parts?.some((part) => part.thought), + ); + expect(firstThoughtChunkIndex).toBeGreaterThanOrEqual(0); + expect(firstThoughtChunkIndex).toBeLessThan(firstFunctionCallChunkIndex); + + expect(finalChunk.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(finalChunk.usageMetadata?.candidatesTokenCount).toBe(81); + + const functionCallParts = + finalChunk.candidates?.[0]?.content?.parts?.filter( + (part) => part.functionCall, + ) ?? []; + expect(functionCallParts).toHaveLength(2); + expect(functionCallParts[0].functionCall?.id).toBe( + 'call_f0df466b1fd44f7590ebf389', + ); + expect(functionCallParts[0].functionCall?.args).toEqual({ + city: 'Paris', + }); + expect(functionCallParts[1].functionCall?.id).toBe( + 'call_85d6e563c2e94bb68c5abea3', + ); + expect(functionCallParts[1].functionCall?.args).toEqual({ + city: 'Tokyo', + }); + + expect(accumulator.finish()).toEqual({ + truncated: false, + emittedToolCalls: true, + }); + }); + + it('emits tool-call preparation exactly once per call, at the open frame', async () => { + const { chunks } = await replay(STREAM_TOOLS_FIXTURE); + + const allPreparations = chunks.flatMap((chunk) => + getToolCallPreparations(chunk), + ); + expect(allPreparations).toEqual([ + { callId: 'call_f0df466b1fd44f7590ebf389', toolName: 'get_weather' }, + { callId: 'call_85d6e563c2e94bb68c5abea3', toolName: 'get_weather' }, + ]); + }); + + it('does not let an empty-id continuation frame clobber the latched id', async () => { + const { chunks } = await replay(STREAM_TOOLS_FIXTURE); + const finalChunk = chunks[chunks.length - 1]; + const functionCallParts = + finalChunk.candidates?.[0]?.content?.parts?.filter( + (part) => part.functionCall, + ) ?? []; + for (const part of functionCallParts) { + expect(part.functionCall?.id).toBeTruthy(); + } + }); + + it('produces identical results when fed byte-at-a-time', async () => { + const wholeAccumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const wholeChunks: GenerateContentResponse[] = []; + for (const frame of await collectFrames( + streamFromString(STREAM_TOOLS_FIXTURE), + )) { + wholeChunks.push(...wholeAccumulator.push(frame)); + } + + const byteAccumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const byteChunks: GenerateContentResponse[] = []; + for (const frame of await collectFrames( + streamByteAtATime(STREAM_TOOLS_FIXTURE), + )) { + byteChunks.push(...byteAccumulator.push(frame)); + } + + expect(byteChunks).toEqual(wholeChunks); + expect(byteAccumulator.finish()).toEqual(wholeAccumulator.finish()); + }); + + it('replays stream-text.sse.txt: text-only terminal chunk with STOP + usage', async () => { + const { accumulator, chunks } = await replay(STREAM_TEXT_FIXTURE); + const finalChunk = chunks[chunks.length - 1]; + + const combinedText = chunks + .flatMap((chunk) => chunk.candidates?.[0]?.content?.parts ?? []) + .filter((part) => part.text && !part.thought) + .map((part) => part.text) + .join(''); + expect(combinedText).toBe('The answer is 42.'); + + expect(finalChunk.candidates?.[0]?.finishReason).toBe(FinishReason.STOP); + expect(finalChunk.usageMetadata).toBeDefined(); + expect(accumulator.finish()).toEqual({ + truncated: false, + emittedToolCalls: false, + }); + }); + + it('emits text from string content frames', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const chunks = ['The answer ', 'is 42.'].flatMap((content) => + accumulator.push({ + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'null', + message: { role: 'assistant', content }, + }, + ], + }, + }), + }), + ); + + const combinedText = chunks + .flatMap((chunk) => chunk.candidates?.[0]?.content?.parts ?? []) + .map((part) => part.text) + .join(''); + expect(combinedText).toBe('The answer is 42.'); + }); + + it('emits nothing for reasoning_content: "" frames', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const chunks = accumulator.push({ + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'null', + message: { + role: 'assistant', + content: [], + reasoning_content: '', + }, + }, + ], + }, + }), + }); + expect(chunks).toEqual([]); + }); + + it('falls back to empty args on an unparseable argument buffer', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + accumulator.push({ + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'null', + message: { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_x', + index: 0, + type: 'function', + function: { name: 'get_weather', arguments: '' }, + }, + ], + }, + }, + ], + }, + }), + }); + const chunks = accumulator.push({ + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'tool_calls', + message: { + role: 'assistant', + content: [], + tool_calls: [ + { + id: '', + index: 0, + type: 'function', + function: { arguments: 'not json{{{' }, + }, + ], + }, + }, + ], + }, + }), + }); + const functionCallParts = + chunks[0]?.candidates?.[0]?.content?.parts?.filter( + (part) => part.functionCall, + ) ?? []; + expect(functionCallParts).toHaveLength(1); + expect(functionCallParts[0].functionCall?.args).toEqual({}); + }); + + it('withholds tool calls when finish_reason is "length"', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const chunks = accumulator.push({ + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'length', + message: { + role: 'assistant', + content: [], + tool_calls: [ + { + id: 'call_x', + index: 0, + type: 'function', + function: { + name: 'lookup_user', + arguments: '{"name":"Alexander', + }, + }, + ], + }, + }, + ], + }, + }), + }); + + expect(chunks[0]?.candidates?.[0]?.finishReason).toBe( + FinishReason.MAX_TOKENS, + ); + expect(chunks[0]?.candidates?.[0]?.content?.parts).toEqual([]); + expect(accumulator.finish()).toEqual({ + truncated: false, + emittedToolCalls: false, + }); + }); + + it('throws a DashScopeApiError for an event:error frame', async () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const frames = await collectFrames(streamFromString(ERROR_FRAME_FIXTURE)); + expect(() => accumulator.push(frames[0])).toThrow(DashScopeApiError); + try { + accumulator.push(frames[0]); + } catch (err) { + expect((err as DashScopeApiError).status).toBe(400); + } + }); + + it('reports truncated: true when finish() is called with no terminal frame', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + accumulator.push({ + data: JSON.stringify({ + output: { + choices: [ + { + finish_reason: 'null', + message: { role: 'assistant', content: [{ text: 'partial' }] }, + }, + ], + }, + }), + }); + expect(accumulator.finish()).toEqual({ + truncated: true, + emittedToolCalls: false, + }); + }); + + it('returns [] for a malformed (non-JSON) frame without throwing', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + expect(() => accumulator.push({ data: 'not valid json' })).not.toThrow(); + expect(accumulator.push({ data: 'not valid json' })).toEqual([]); + }); + + it('returns [] and buffers usage when output.choices[0] is absent', () => { + const accumulator = new DashScopeStreamAccumulator('qwen3.8-max'); + const chunks = accumulator.push({ + data: JSON.stringify({ usage: { input_tokens: 5, output_tokens: 0 } }), + }); + expect(chunks).toEqual([]); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/stream-accumulator.ts b/packages/core/src/core/dashscopeContentGenerator/stream-accumulator.ts new file mode 100644 index 00000000000..c8bf4f35817 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/stream-accumulator.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; +import { FinishReason, GenerateContentResponse } from '@google/genai'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { setToolCallPreparations } from '../tool-call-preparation.js'; +import { dashScopeErrorFromFrame } from './errors.js'; +import { + convertToolCallToFunctionCallPart, + mapDashScopeFinishReason, +} from './response-converter.js'; +import { buildDashScopeUsageMetadata } from './usage.js'; +import type { DashScopeSseFrame } from './sse.js'; +import type { DashScopeResponsePayload, DashScopeUsage } from './types.js'; + +const debugLogger = createDebugLogger('DASHSCOPE'); + +interface ToolSlot { + id?: string; + name?: string; + args: string; + prepared: boolean; +} + +interface BuildChunkOptions { + finishReason?: FinishReason; + usageMetadata?: GenerateContentResponse['usageMetadata']; + responseId?: string; + modelVersion?: string; +} + +function buildChunk( + parts: Part[], + options: BuildChunkOptions = {}, +): GenerateContentResponse { + const response = new GenerateContentResponse(); + response.candidates = [ + { + content: { role: 'model', parts }, + index: 0, + ...(options.finishReason !== undefined + ? { finishReason: options.finishReason } + : {}), + }, + ]; + if (options.usageMetadata !== undefined) { + response.usageMetadata = options.usageMetadata; + } + if (options.responseId !== undefined) { + response.responseId = options.responseId; + } + if (options.modelVersion !== undefined) { + response.modelVersion = options.modelVersion; + } + return response; +} + +/** + * Pure streaming state machine that turns DashScope SSE frames into + * incremental Gemini `GenerateContentResponse` chunks. + * + * Every rule below is live-verified against a captured 20-frame parallel + * tool-call stream (api-contract.md §4): + * - Tool-call continuation frames carry `id: ""` (empty string, not + * omitted); latching must check truthiness, never `!== undefined`, or a + * continuation frame would clobber the real id captured on the open + * frame. + * - `usage` rides every frame and is cumulative; only the LAST frame's + * usage is meaningful, and it is attached ONLY to the terminal chunk. + */ +export class DashScopeStreamAccumulator { + private readonly model: string; + private readonly toolSlots = new Map(); + private lastUsage: DashScopeUsage | undefined; + private sawTerminal = false; + private emittedToolCallsFlag = false; + + constructor(model: string) { + this.model = model; + } + + push(frame: DashScopeSseFrame): GenerateContentResponse[] { + const err = dashScopeErrorFromFrame(frame); + if (err) { + throw err; + } + + let payload: DashScopeResponsePayload; + try { + payload = JSON.parse(frame.data) as DashScopeResponsePayload; + } catch { + debugLogger.warn('Failed to parse DashScope SSE frame data', { + data: frame.data, + }); + return []; + } + + const choice = payload.output?.choices?.[0]; + if (payload.usage) { + this.lastUsage = payload.usage; + } + if (!choice) { + return []; + } + + const parts: Part[] = []; + const message = choice.message; + + if (message?.reasoning_content) { + parts.push({ text: message.reasoning_content, thought: true }); + } + + if (typeof message?.content === 'string') { + if (message.content) { + parts.push({ text: message.content }); + } + } else if (Array.isArray(message?.content)) { + for (const block of message.content) { + if (block.text) { + parts.push({ text: block.text }); + } + } + } + + const preparations: Array<{ callId: string; toolName: string }> = []; + for (const call of message?.tool_calls ?? []) { + const index = call.index ?? 0; + let slot = this.toolSlots.get(index); + if (!slot) { + slot = { args: '', prepared: false }; + this.toolSlots.set(index, slot); + } + if (call.id) { + slot.id = call.id; + } + if (call.function?.name) { + slot.name = call.function.name; + } + slot.args += call.function?.arguments ?? ''; + + if (slot.id && slot.name && !slot.prepared) { + slot.prepared = true; + preparations.push({ callId: slot.id, toolName: slot.name }); + } + } + + const fr = mapDashScopeFinishReason(choice.finish_reason); + if (fr === undefined) { + if (parts.length === 0 && preparations.length === 0) { + return []; + } + const chunk = buildChunk(parts); + if (preparations.length > 0) { + setToolCallPreparations(chunk, preparations); + } + return [chunk]; + } + + this.sawTerminal = true; + if (fr !== FinishReason.MAX_TOKENS) { + for (const index of [...this.toolSlots.keys()].sort((a, b) => a - b)) { + const slot = this.toolSlots.get(index); + if (!slot) { + continue; + } + this.emittedToolCallsFlag = true; + parts.push( + convertToolCallToFunctionCallPart({ + id: slot.id ?? '', + type: 'function', + function: { name: slot.name, arguments: slot.args }, + }), + ); + } + } + + const finalChunk = buildChunk(parts, { + finishReason: fr, + usageMetadata: buildDashScopeUsageMetadata(this.lastUsage), + responseId: payload.request_id, + modelVersion: this.model, + }); + if (fr !== FinishReason.MAX_TOKENS && preparations.length > 0) { + setToolCallPreparations(finalChunk, preparations); + } + return [finalChunk]; + } + + finish(): { truncated: boolean; emittedToolCalls: boolean } { + return { + truncated: !this.sawTerminal, + emittedToolCalls: this.emittedToolCallsFlag, + }; + } +} diff --git a/packages/core/src/core/dashscopeContentGenerator/test-utils.ts b/packages/core/src/core/dashscopeContentGenerator/test-utils.ts new file mode 100644 index 00000000000..8220e8da69e --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/test-utils.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import type { Config } from '../../config/config.js'; +import { AuthType, type ContentGeneratorConfig } from '../contentGenerator.js'; +import { parseDashScopeSse, type DashScopeSseFrame } from './sse.js'; +import type { DashScopeRequest, DashScopeResponsePayload } from './types.js'; +import type { DashScopeTransport } from './transport.js'; + +/** + * Scripted in-memory {@link DashScopeTransport} for generator-level tests. + * Records every call it receives; `postJson` shifts one response per call + * (throwing once the script is exhausted), and `postSse` replays the next + * frames array as an async generator. + */ +export class FakeDashScopeTransport implements DashScopeTransport { + readonly calls: Array<{ + kind: 'json' | 'sse'; + body: DashScopeRequest; + signal: AbortSignal; + }> = []; + + constructor( + private readonly script: { + json?: DashScopeResponsePayload[]; + frames?: DashScopeSseFrame[][]; + }, + ) {} + + async postJson( + body: DashScopeRequest, + opts: { signal: AbortSignal }, + ): Promise { + this.calls.push({ kind: 'json', body, signal: opts.signal }); + const next = this.script.json?.shift(); + if (!next) { + throw new Error('FakeDashScopeTransport: postJson script exhausted'); + } + return next; + } + + async postSse( + body: DashScopeRequest, + opts: { signal: AbortSignal }, + ): Promise> { + this.calls.push({ kind: 'sse', body, signal: opts.signal }); + const next = this.script.frames?.shift(); + if (!next) { + throw new Error('FakeDashScopeTransport: postSse script exhausted'); + } + return (async function* (): AsyncGenerator { + for (const frame of next) { + yield frame; + } + })(); + } +} + +export function createDashScopeGeneratorConfig( + overrides: Partial = {}, +): ContentGeneratorConfig { + return { + apiKey: 'test-key', + model: 'qwen3.8-max', + authType: AuthType.USE_DASHSCOPE, + ...overrides, + } as ContentGeneratorConfig; +} + +export function createFakeCliConfig(): Config { + return { + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getProxy: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session'), + getResolvedModelConfig: vi.fn().mockReturnValue(undefined), + } as unknown as Config; +} + +/** + * Decodes raw DashScope SSE text (e.g. a fixture file's contents) into + * frames via the real {@link parseDashScopeSse} decoder, so tests exercise + * the same parsing path production code uses. + */ +export async function framesFromSseText( + raw: string, +): Promise { + const stream = new Blob([raw]).stream() as ReadableStream; + const frames: DashScopeSseFrame[] = []; + for await (const frame of parseDashScopeSse(stream)) { + frames.push(frame); + } + return frames; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/thinking.test.ts b/packages/core/src/core/dashscopeContentGenerator/thinking.test.ts new file mode 100644 index 00000000000..61d72b8681b --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/thinking.test.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { resolveThinkingParameters } from './thinking.js'; +import type { ResolveThinkingParametersInput } from './thinking.js'; + +function baseInput( + overrides: Partial = {}, +): ResolveThinkingParametersInput { + return { + reasoning: undefined, + forcedToolChoice: false, + ...overrides, + }; +} + +describe('resolveThinkingParameters', () => { + it('emits {} when nothing is configured', () => { + const result = resolveThinkingParameters(baseInput()); + expect(result.params).toEqual({}); + expect(result.dropForcedToolChoice).toBe(false); + }); + + it('turns thinking off when reasoning is false', () => { + const result = resolveThinkingParameters(baseInput({ reasoning: false })); + expect(result.params).toEqual({ reasoning_effort: 'none' }); + }); + + it('emits {} when reasoning is false but thinking is mandatory', () => { + const result = resolveThinkingParameters( + baseInput({ reasoning: false, thinkingMandatory: true }), + ); + expect(result.params).toEqual({}); + }); + + it('passes an unclamped effort tier through', () => { + const result = resolveThinkingParameters( + baseInput({ reasoning: { effort: 'max' } }), + ); + expect(result.params).toEqual({ reasoning_effort: 'max' }); + }); + + it.each(['low', 'medium', 'xhigh'] as const)( + 'preserves supported effort tier %s', + (effort) => { + const result = resolveThinkingParameters( + baseInput({ + reasoning: { effort }, + supportedEfforts: ['low', 'medium', 'xhigh'], + }), + ); + expect(result.params).toEqual({ reasoning_effort: effort }); + }, + ); + + it.each(['high', 'max'] as const)( + 'clamps legacy effort tier %s to xhigh', + (effort) => { + const result = resolveThinkingParameters( + baseInput({ + reasoning: { effort }, + supportedEfforts: ['low', 'medium', 'xhigh'], + }), + ); + expect(result.params).toEqual({ reasoning_effort: 'xhigh' }); + }, + ); + + it('forces thinking off for a forced tool choice when not mandatory', () => { + const result = resolveThinkingParameters( + baseInput({ reasoning: { effort: 'high' }, forcedToolChoice: true }), + ); + expect(result.params).toEqual({ reasoning_effort: 'none' }); + expect(result.dropForcedToolChoice).toBe(false); + }); + + it('keeps the configured effort and drops the forced choice when mandatory', () => { + const result = resolveThinkingParameters( + baseInput({ + reasoning: { effort: 'high' }, + forcedToolChoice: true, + thinkingMandatory: true, + }), + ); + expect(result.params).toEqual({ reasoning_effort: 'high' }); + expect(result.dropForcedToolChoice).toBe(true); + }); + + it('maps a thinking budget when no effort is configured', () => { + const result = resolveThinkingParameters( + baseInput({ thinkingConfig: { thinkingBudget: 2048 } }), + ); + expect(result.params).toEqual({ thinking_budget: 2048 }); + }); + + it('prefers effort over a configured budget', () => { + const result = resolveThinkingParameters( + baseInput({ + reasoning: { effort: 'low' }, + thinkingConfig: { thinkingBudget: 2048 }, + }), + ); + expect(result.params).toEqual({ reasoning_effort: 'low' }); + }); + + it('reads a budget from reasoning.budget_tokens too', () => { + const result = resolveThinkingParameters( + baseInput({ reasoning: { budget_tokens: 4096 } }), + ); + expect(result.params).toEqual({ thinking_budget: 4096 }); + }); + + it('normalizes extraBody.enable_thinking:false to reasoning_effort:none', () => { + const result = resolveThinkingParameters( + baseInput({ extraBody: { enable_thinking: false } }), + ); + expect(result.params).toEqual({ reasoning_effort: 'none' }); + }); + + it.each([{ enable_thinking: false }, { reasoning_effort: 'none' }])( + 'ignores extra-body disable aliases when thinking is mandatory', + (extraBody) => { + const result = resolveThinkingParameters( + baseInput({ extraBody, thinkingMandatory: true }), + ); + expect(result.params).toEqual({}); + }, + ); + + it('honors extraBody.reasoning_effort as if configured', () => { + const result = resolveThinkingParameters( + baseInput({ extraBody: { reasoning_effort: 'low' } }), + ); + expect(result.params).toEqual({ reasoning_effort: 'low' }); + }); + + it('honors extraBody.thinking_budget as if configured', () => { + const result = resolveThinkingParameters( + baseInput({ extraBody: { thinking_budget: 1024 } }), + ); + expect(result.params).toEqual({ thinking_budget: 1024 }); + }); + + it('lets extraBody.reasoning_effort win over reasoning.effort', () => { + const result = resolveThinkingParameters( + baseInput({ + reasoning: { effort: 'low' }, + extraBody: { reasoning_effort: 'high' }, + supportedEfforts: ['low', 'medium', 'xhigh'], + }), + ); + expect(result.params).toEqual({ reasoning_effort: 'high' }); + }); + + it('turns thinking off when includeThoughts is false', () => { + const result = resolveThinkingParameters( + baseInput({ thinkingConfig: { includeThoughts: false } }), + ); + expect(result.params).toEqual({ reasoning_effort: 'none' }); + }); + + it('never emits enable_thinking and never emits two knobs at once', () => { + const cases: ResolveThinkingParametersInput[] = [ + baseInput(), + baseInput({ reasoning: false }), + baseInput({ reasoning: false, thinkingMandatory: true }), + baseInput({ reasoning: { effort: 'max' } }), + baseInput({ reasoning: { effort: 'high' }, forcedToolChoice: true }), + baseInput({ + reasoning: { effort: 'high' }, + forcedToolChoice: true, + thinkingMandatory: true, + }), + baseInput({ thinkingConfig: { thinkingBudget: 2048 } }), + baseInput({ + reasoning: { effort: 'low' }, + thinkingConfig: { thinkingBudget: 2048 }, + }), + baseInput({ extraBody: { enable_thinking: false } }), + baseInput({ extraBody: { reasoning_effort: 'low' } }), + ]; + + for (const input of cases) { + const result = resolveThinkingParameters(input); + expect(Object.keys(result.params).length).toBeLessThanOrEqual(1); + expect(result.params['enable_thinking']).toBeUndefined(); + } + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/thinking.ts b/packages/core/src/core/dashscopeContentGenerator/thinking.ts new file mode 100644 index 00000000000..0cb8632d34a --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/thinking.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ContentGeneratorConfig } from '../contentGenerator.js'; +import { + clampReasoningEffort, + type ReasoningEffort, +} from '../reasoning-effort.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('DASHSCOPE'); + +/** + * `'none'` is the canonical wire off-switch (live-verified, see + * api-contract.md §5) and is deliberately not part of the shared + * {@link ReasoningEffort} ladder, which only enumerates the + * user-selectable tiers. + */ +type DashScopeReasoningEffort = ReasoningEffort | 'none'; + +export interface ResolvedThinking { + /** `{}` or exactly one of `{reasoning_effort}` / `{thinking_budget}`. */ + params: Record; + /** + * When `true`, the converter must downgrade a forced `tool_choice` to + * `'auto'` — the API 400s on a forced choice while thinking is on, and + * `thinkingMandatory` forbids turning thinking off to route around it. + */ + dropForcedToolChoice: boolean; +} + +export interface ResolveThinkingParametersInput { + reasoning: ContentGeneratorConfig['reasoning']; + thinkingConfig?: { thinkingBudget?: number; includeThoughts?: boolean }; + /** + * When `true`, the active model rejects `enable_thinking: false` (or, by + * extension, any request that would leave thinking off) with an HTTP 400. + */ + thinkingMandatory?: boolean; + /** + * Raw provider passthrough. `enable_thinking` / `reasoning_effort` / + * `thinking_budget` are intercepted here and never reach the assembled + * `parameters` object verbatim. + */ + extraBody?: Record; + supportedEfforts?: readonly ReasoningEffort[]; + /** `true` for tool_choice `'required'` or a named-function object form. */ + forcedToolChoice: boolean; +} + +let warnedEffortAndBudgetConflict = false; + +/** + * Single source of truth for the native DashScope thinking knobs. Emits at + * most one of `reasoning_effort` / `thinking_budget` and never emits + * `enable_thinking` (see api-contract.md §5 for the live-verified 400s this + * avoids). + */ +export function resolveThinkingParameters( + input: ResolveThinkingParametersInput, +): ResolvedThinking { + const underlying = resolveUnderlyingThinkingParams(input); + + if (input.forcedToolChoice) { + if (input.thinkingMandatory) { + return { params: underlying, dropForcedToolChoice: true }; + } + return { + params: { reasoning_effort: 'none' satisfies DashScopeReasoningEffort }, + dropForcedToolChoice: false, + }; + } + + return { params: underlying, dropForcedToolChoice: false }; +} + +function resolveUnderlyingThinkingParams( + input: ResolveThinkingParametersInput, +): Record { + const { reasoning, thinkingConfig, thinkingMandatory, extraBody } = input; + + if (reasoning === false || thinkingConfig?.includeThoughts === false) { + return thinkingMandatory + ? {} + : { reasoning_effort: 'none' satisfies DashScopeReasoningEffort }; + } + + if (extraBody?.['enable_thinking'] === false && !thinkingMandatory) { + return { reasoning_effort: 'none' satisfies DashScopeReasoningEffort }; + } + + const extraEffort = extraBody?.['reasoning_effort']; + const effort = + typeof extraEffort === 'string' + ? (extraEffort as DashScopeReasoningEffort) + : reasoning?.effort === undefined + ? undefined + : clampReasoningEffort(reasoning.effort, input.supportedEfforts); + + const extraBudget = extraBody?.['thinking_budget']; + const budget = + typeof extraBudget === 'number' + ? extraBudget + : typeof reasoning?.budget_tokens === 'number' + ? reasoning.budget_tokens + : thinkingConfig?.thinkingBudget; + + if (effort !== undefined && !(thinkingMandatory && effort === 'none')) { + if (budget !== undefined && !warnedEffortAndBudgetConflict) { + warnedEffortAndBudgetConflict = true; + debugLogger.warn( + 'resolveThinkingParameters: both a reasoning effort and a thinking ' + + 'budget were configured; reasoning_effort wins and thinking_budget ' + + 'is dropped.', + ); + } + return { reasoning_effort: effort }; + } + + if (typeof budget === 'number' && budget > 0) { + return { thinking_budget: budget }; + } + + return {}; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/transport.test.ts b/packages/core/src/core/dashscopeContentGenerator/transport.test.ts new file mode 100644 index 00000000000..dd45e0455ee --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/transport.test.ts @@ -0,0 +1,705 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, + type MockedFunction, +} from 'vitest'; +import type { ContentGeneratorConfig } from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import type { DashScopeRequest } from './types.js'; +import { FetchDashScopeTransport } from './transport.js'; +import { DashScopeApiError } from './errors.js'; +import { + StreamInactivityTimeoutError, + StreamLifetimeExceededError, +} from '../openaiContentGenerator/pipeline.js'; +import { + DEFAULT_STREAM_IDLE_TIMEOUT_MS, + QWEN_STREAM_IDLE_TIMEOUT_MS_ENV, +} from '../openaiContentGenerator/constants.js'; +import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js'; +import type { AnthropicRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js'; +import { classifyRetryError } from '../../utils/retryErrorClassification.js'; + +// The real implementation pins `fetch` to a bundled-undici function (to keep +// it version-matched with its dispatcher) even with no proxy configured, +// which would bypass `vi.stubGlobal('fetch', ...)` below — mock it the same +// way the OpenAI-compatible providers' tests do. +vi.mock('../../utils/runtimeFetchOptions.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../utils/runtimeFetchOptions.js') + >()), + buildRuntimeFetchOptions: vi.fn(), +})); + +function createTestConfig( + overrides: Partial = {}, +): ContentGeneratorConfig { + return { + apiKey: 'test-key', + model: 'qwen3.8-max', + ...overrides, + } as ContentGeneratorConfig; +} + +function createTestCliConfig(): Config { + return { + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getProxy: vi.fn().mockReturnValue(undefined), + } as unknown as Config; +} + +function jsonResponse( + payload: unknown, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(JSON.stringify(payload), { + status: init.status ?? 200, + headers: { 'content-type': 'application/json', ...(init.headers ?? {}) }, + }); +} + +function sseResponse( + raw: string, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(new Blob([raw]).stream(), { + status: init.status ?? 200, + headers: { + 'content-type': 'text/event-stream', + ...(init.headers ?? {}), + }, + }); +} + +/** + * A ReadableStream that never enqueues or closes on its own, but errors as + * soon as the fetch `signal` it was requested with aborts — mirroring how a + * real (undici-backed) fetch ties response-body reads to the request's + * AbortSignal. Without this, a guard-fired `abortRequest()` would have + * nothing to tear down and the reader's pending `read()` would hang forever, + * deadlocking the generator's own cleanup (`frames.return()` queues behind + * the still-outstanding `frames.next()` call). + */ +function neverEndingSignalAwareStream( + signal: AbortSignal | null | undefined, +): ReadableStream { + return new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + controller.error(new DOMException('Aborted', 'AbortError')); + }); + }, + }); +} + +/** + * A signal-aware ReadableStream that drip-feeds `chunks` on their own delays + * (mirroring a slow-but-healthy SSE body) and closes shortly after the last + * one, but errors immediately if `signal` aborts — same abort-wiring as + * {@link neverEndingSignalAwareStream}, used to prove the request-timeout + * signal is no longer armed once headers have arrived. + */ +function dripFedSignalAwareStream( + signal: AbortSignal | null | undefined, + chunks: Array<{ delayMs: number; data: Uint8Array }>, +): ReadableStream { + return new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + controller.error(new DOMException('Aborted', 'AbortError')); + }); + for (const chunk of chunks) { + setTimeout(() => { + try { + controller.enqueue(chunk.data); + } catch { + // Stream may already be closed/errored — ignore. + } + }, chunk.delayMs); + } + const lastDelay = chunks.reduce((max, c) => Math.max(max, c.delayMs), 0); + setTimeout(() => { + try { + controller.close(); + } catch { + // Already closed/errored — ignore. + } + }, lastDelay + 10); + }, + }); +} + +function testRequest(): DashScopeRequest { + return { + model: 'qwen3.8-max', + input: { messages: [{ role: 'user', content: 'hi' }] }, + parameters: { result_format: 'message' }, + }; +} + +const ERROR_FRAME_400 = [ + 'id:1', + 'event:error', + ':HTTP_STATUS/400', + 'data:{"code":"InvalidParameter","message":"<400> InternalError.Algo.InvalidParameter: bad","request_id":"req-1"}', + '', +].join('\n'); + +describe('FetchDashScopeTransport', () => { + let fetchMock: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + ( + buildRuntimeFetchOptions as unknown as MockedFunction< + ( + sdkType: 'anthropic', + proxyUrl?: string, + ) => AnthropicRuntimeFetchOptions + > + ).mockReturnValue({}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('postJson', () => { + it('posts to the resolved endpoint with the expected headers and no SSE header', async () => { + fetchMock.mockResolvedValue(jsonResponse({ output: { choices: [] } })); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const body = testRequest(); + await transport.postJson(body, { signal: new AbortController().signal }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + ); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer test-key'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['User-Agent']).toBe( + `QwenCode/1.0.0 (${process.platform}; ${process.arch})`, + ); + expect(headers['X-DashScope-SSE']).toBeUndefined(); + expect(JSON.parse(init.body as string)).toEqual(body); + }); + + it('merges customHeaders last, allowing overrides', async () => { + fetchMock.mockResolvedValue(jsonResponse({ output: { choices: [] } })); + const transport = new FetchDashScopeTransport( + createTestConfig({ + customHeaders: { 'User-Agent': 'custom-agent', 'X-Extra': 'yes' }, + }), + createTestCliConfig(), + ); + await transport.postJson(testRequest(), { + signal: new AbortController().signal, + }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['User-Agent']).toBe('custom-agent'); + expect(headers['X-Extra']).toBe('yes'); + }); + + it('returns the parsed JSON payload on 2xx', async () => { + const payload = { + output: { choices: [{ finish_reason: 'stop' }] }, + request_id: 'req-ok', + }; + fetchMock.mockResolvedValue(jsonResponse(payload)); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const result = await transport.postJson(testRequest(), { + signal: new AbortController().signal, + }); + expect(result).toEqual(payload); + }); + + it('reads apiKey at request time, not construction time', async () => { + fetchMock.mockImplementation(() => + Promise.resolve(jsonResponse({ output: { choices: [] } })), + ); + const config = createTestConfig({ apiKey: 'first-key' }); + const transport = new FetchDashScopeTransport( + config, + createTestCliConfig(), + ); + await transport.postJson(testRequest(), { + signal: new AbortController().signal, + }); + config.apiKey = 'second-key'; + await transport.postJson(testRequest(), { + signal: new AbortController().signal, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstHeaders = (fetchMock.mock.calls[0] as [string, RequestInit])[1] + .headers as Record; + const secondHeaders = ( + fetchMock.mock.calls[1] as [string, RequestInit] + )[1].headers as Record; + expect(firstHeaders['Authorization']).toBe('Bearer first-key'); + expect(secondHeaders['Authorization']).toBe('Bearer second-key'); + }); + + it('throws DashScopeApiError with status/code for a JSON error body', async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + code: 'InvalidParameter', + message: '<400> InternalError.Algo.InvalidParameter: bad', + request_id: 'req-json', + }, + { status: 400 }, + ), + ); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const error = await transport + .postJson(testRequest(), { signal: new AbortController().signal }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(DashScopeApiError); + expect((error as DashScopeApiError).status).toBe(400); + expect((error as DashScopeApiError).code).toBe('InvalidParameter'); + expect((error as DashScopeApiError).requestId).toBe('req-json'); + }); + + it('throws DashScopeApiError with status/code for an SSE-framed error body', async () => { + fetchMock.mockResolvedValue( + sseResponse(ERROR_FRAME_400, { status: 400 }), + ); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const error = await transport + .postJson(testRequest(), { signal: new AbortController().signal }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(DashScopeApiError); + expect((error as DashScopeApiError).status).toBe(400); + expect((error as DashScopeApiError).code).toBe('InvalidParameter'); + expect((error as DashScopeApiError).requestId).toBe('req-1'); + }); + + it('rejects with an abort error when the caller signal is aborted', async () => { + const controller = new AbortController(); + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + const err = new Error('Aborted'); + err.name = 'AbortError'; + reject(err); + }); + }), + ); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const promise = transport.postJson(testRequest(), { + signal: controller.signal, + }); + controller.abort(); + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('redacts proxy credentials from fetch failures', async () => { + fetchMock.mockRejectedValue( + new Error( + 'connect ECONNREFUSED http://proxy-user:proxy-pass@proxy.local:8080', + ), + ); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + + const error = await transport + .postJson(testRequest(), { signal: new AbortController().signal }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + 'http://@proxy.local:8080', + ); + expect((error as Error).message).not.toContain('proxy-user'); + expect((error as Error).message).not.toContain('proxy-pass'); + }); + + it('classifies a configured request timeout as retryable ETIMEDOUT', async () => { + vi.useFakeTimers(); + try { + fetchMock.mockImplementation( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + const transport = new FetchDashScopeTransport( + createTestConfig({ timeout: 50 }), + createTestCliConfig(), + ); + const captured = transport + .postJson(testRequest(), { + signal: new AbortController().signal, + }) + .catch((e: unknown) => e); + + await vi.advanceTimersByTimeAsync(50); + const error = await captured; + + expect(error).toMatchObject({ code: 'ETIMEDOUT' }); + expect(error).not.toMatchObject({ name: 'AbortError' }); + expect(classifyRetryError(error).diagnosis).toBe('retryable'); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('postSse', () => { + it('posts with the X-DashScope-SSE header', async () => { + fetchMock.mockResolvedValue(sseResponse('')); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['X-DashScope-SSE']).toBe('enable'); + }); + + it('round-trips frames decoded from the mocked SSE body', async () => { + const raw = [ + 'id:1', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"Hi"}}]},"usage":{},"request_id":"r1"}', + '', + 'id:2', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":[{"text":"OK"}]}}]},"usage":{},"request_id":"r1"}', + '', + ].join('\n'); + fetchMock.mockResolvedValue(sseResponse(raw)); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + const collected = []; + for await (const frame of frames) { + collected.push(frame); + } + expect(collected).toHaveLength(2); + expect(collected[0]?.event).toBe('result'); + expect(collected[0]?.httpStatus).toBe(200); + expect(JSON.parse(collected[1]?.data ?? '{}')).toMatchObject({ + output: { choices: [{ finish_reason: 'stop' }] }, + }); + }); + + it('throws DashScopeApiError for a JSON error body on a streaming request', async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { + code: 'InvalidParameter', + message: '<400> bad', + request_id: 'req-json-stream', + }, + { status: 400 }, + ), + ); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const error = await transport + .postSse(testRequest(), { signal: new AbortController().signal }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(DashScopeApiError); + expect((error as DashScopeApiError).status).toBe(400); + }); + + it('throws DashScopeApiError for an SSE-framed error body on a streaming request', async () => { + fetchMock.mockResolvedValue( + sseResponse(ERROR_FRAME_400, { status: 400 }), + ); + const transport = new FetchDashScopeTransport( + createTestConfig(), + createTestCliConfig(), + ); + const error = await transport + .postSse(testRequest(), { signal: new AbortController().signal }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(DashScopeApiError); + expect((error as DashScopeApiError).status).toBe(400); + expect((error as DashScopeApiError).code).toBe('InvalidParameter'); + }); + + describe('stream guards', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it('throws StreamInactivityTimeoutError when the stream goes silent past the idle timeout', async () => { + fetchMock.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve( + new Response( + neverEndingSignalAwareStream(init.signal ?? undefined), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + const transport = new FetchDashScopeTransport( + createTestConfig({ streamIdleTimeoutMs: 50 }), + createTestCliConfig(), + ); + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + const captured = frames.next().catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(50); + const result = await captured; + expect(result).toBeInstanceOf(StreamInactivityTimeoutError); + }); + + it('throws StreamLifetimeExceededError when the stream exceeds its lifetime cap', async () => { + fetchMock.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve( + new Response( + neverEndingSignalAwareStream(init.signal ?? undefined), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + const transport = new FetchDashScopeTransport( + createTestConfig({ + streamIdleTimeoutMs: 0, + streamMaxLifetimeMs: 80, + }), + createTestCliConfig(), + ); + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + const captured = frames.next().catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(80); + const result = await captured; + expect(result).toBeInstanceOf(StreamLifetimeExceededError); + }); + + it('does not charge consumer pauses against the stream lifetime cap', async () => { + const raw = [ + 'id:1', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[{"text":"first"}]}}]},"usage":{},"request_id":"r1"}', + '', + 'id:2', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":[{"text":"second"}]}}]},"usage":{},"request_id":"r1"}', + '', + ].join('\n'); + fetchMock.mockResolvedValue(sseResponse(raw)); + const transport = new FetchDashScopeTransport( + createTestConfig({ + streamIdleTimeoutMs: 0, + streamMaxLifetimeMs: 50, + }), + createTestCliConfig(), + ); + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + + const first = await frames.next(); + expect(first.value?.data).toContain('"text":"first"'); + + await vi.advanceTimersByTimeAsync(100); + + const terminal = await frames.next(); + expect(terminal.value?.data).toContain('"finish_reason":"stop"'); + + await vi.advanceTimersByTimeAsync(100); + + await expect(frames.next()).resolves.toMatchObject({ done: true }); + }); + + it('treats QWEN_STREAM_IDLE_TIMEOUT_MS=0 as disabling the idle guard, not falling back to the default', async () => { + // The settings-field `streamIdleTimeoutMs: 0` already disables the + // guard; this pins the env knob to the same "0 disables" semantics — + // the exact contract `StreamInactivityTimeoutError`'s own message + // tells users to rely on ("or 0 to disable it"). + vi.stubEnv(QWEN_STREAM_IDLE_TIMEOUT_MS_ENV, '0'); + fetchMock.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve( + new Response( + neverEndingSignalAwareStream(init.signal ?? undefined), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + const transport = new FetchDashScopeTransport( + // No streamIdleTimeoutMs config → env applies. Lifetime disabled too + // so only the idle guard's env resolution is under test. + createTestConfig({ streamMaxLifetimeMs: 0 }), + createTestCliConfig(), + ); + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + let settled = false; + frames.next().then( + () => (settled = true), + () => (settled = true), + ); + // Well past the default idle timeout — must NOT trip if `0` truly + // disabled the guard instead of silently falling back to the default. + await vi.advanceTimersByTimeAsync( + DEFAULT_STREAM_IDLE_TIMEOUT_MS + 60000, + ); + expect(settled).toBe(false); + }); + + it('rejects an oversized QWEN_STREAM_IDLE_TIMEOUT_MS env value and falls back to the default instead of clamping', async () => { + // Node's setTimeout silently compresses delays above 2^31-1ms to ~1ms, + // so using an oversized value verbatim would abort the stream almost + // immediately. Asserting no trip before the default — and a trip AT + // the default — proves the value was rejected, not used verbatim. + vi.stubEnv(QWEN_STREAM_IDLE_TIMEOUT_MS_ENV, '9999999999999'); + fetchMock.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve( + new Response( + neverEndingSignalAwareStream(init.signal ?? undefined), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + const transport = new FetchDashScopeTransport( + createTestConfig({ streamMaxLifetimeMs: 0 }), + createTestCliConfig(), + ); + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + let settled = false; + let result: unknown; + const consume = frames.next().then( + (r) => { + settled = true; + result = r; + }, + (e: unknown) => { + settled = true; + result = e; + }, + ); + await vi.advanceTimersByTimeAsync(DEFAULT_STREAM_IDLE_TIMEOUT_MS - 1); + expect(settled).toBe(false); // not before the default → not used verbatim + await vi.advanceTimersByTimeAsync(1); + await consume; + expect(settled).toBe(true); // trips at the default + expect(result).toBeInstanceOf(StreamInactivityTimeoutError); + }); + }); + + // Uses REAL timers deliberately: `AbortSignal.timeout` is backed by a + // native (non-JS) timer that vitest's fake-timer install does not + // intercept, so a fake-timer test cannot actually exercise the request + // timeout / stream-body race this guards against. + it('does not kill a healthy stream once headers have arrived, even past the request timeout', async () => { + const encoder = new TextEncoder(); + const frame1 = + [ + 'id:1', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"null","message":{"role":"assistant","content":[],"reasoning_content":"Hi"}}]},"usage":{},"request_id":"r1"}', + '', + ].join('\n') + '\n'; + const frame2 = + [ + 'id:2', + 'event:result', + ':HTTP_STATUS/200', + 'data:{"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":[{"text":"OK"}]}}]},"usage":{},"request_id":"r1"}', + '', + ].join('\n') + '\n'; + + fetchMock.mockImplementation((_url: string, init: RequestInit) => + Promise.resolve( + new Response( + dripFedSignalAwareStream(init.signal ?? undefined, [ + { delayMs: 10, data: encoder.encode(frame1) }, + // This chunk arrives well past `timeout` below — a healthy, + // actively-flowing stream must not be killed for that. + { delayMs: 150, data: encoder.encode(frame2) }, + ]), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ), + ), + ); + + const transport = new FetchDashScopeTransport( + createTestConfig({ + timeout: 30, + streamIdleTimeoutMs: 10_000, + streamMaxLifetimeMs: 10_000, + }), + createTestCliConfig(), + ); + + const frames = await transport.postSse(testRequest(), { + signal: new AbortController().signal, + }); + + const collected: Array<{ data: string }> = []; + for await (const frame of frames) { + collected.push(frame); + } + + expect(collected).toHaveLength(2); + expect(collected[1]?.data).toContain('"finish_reason":"stop"'); + }); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/transport.ts b/packages/core/src/core/dashscopeContentGenerator/transport.ts new file mode 100644 index 00000000000..63b8db63c74 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/transport.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ContentGeneratorConfig } from '../contentGenerator.js'; +import type { Config } from '../../config/config.js'; +import type { DashScopeRequest, DashScopeResponsePayload } from './types.js'; +import { parseDashScopeSse, type DashScopeSseFrame } from './sse.js'; +import { toDashScopeApiError } from './errors.js'; +import { resolveDashScopeGenerationEndpoint } from './endpoints.js'; +import { + buildRuntimeFetchOptions, + redactProxyError, +} from '../../utils/runtimeFetchOptions.js'; +import { + resolveRequestTimeout, + DEFAULT_STREAM_IDLE_TIMEOUT_MS, + DEFAULT_STREAM_MAX_LIFETIME_MS, + QWEN_STREAM_IDLE_TIMEOUT_MS_ENV, + QWEN_STREAM_MAX_LIFETIME_MS_ENV, +} from '../openaiContentGenerator/constants.js'; +import { + resolveStreamGuardMs, + StreamInactivityTimeoutError, + StreamLifetimeExceededError, +} from '../openaiContentGenerator/pipeline.js'; + +export interface DashScopeRequestOptions { + signal: AbortSignal; +} + +export interface DashScopeTransport { + postJson( + body: DashScopeRequest, + opts: DashScopeRequestOptions, + ): Promise; + postSse( + body: DashScopeRequest, + opts: DashScopeRequestOptions, + ): Promise>; +} + +/** + * The only I/O seam for the native DashScope provider: builds headers, applies + * the shared proxy/timeout/runtime-fetch configuration, and wraps streaming + * responses with inactivity/lifetime guards. `contentGeneratorConfig.apiKey` + * is re-read on every request (never cached) since it may be refreshed. + */ +export class FetchDashScopeTransport implements DashScopeTransport { + constructor( + private readonly contentGeneratorConfig: ContentGeneratorConfig, + private readonly cliConfig: Config, + ) {} + + private buildHeaders(streaming: boolean): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const headers: Record = { + Authorization: `Bearer ${this.contentGeneratorConfig.apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': `QwenCode/${version} (${process.platform}; ${process.arch})`, + }; + if (streaming) { + headers['X-DashScope-SSE'] = 'enable'; + } + const { customHeaders } = this.contentGeneratorConfig; + if (customHeaders) { + for (const [key, value] of Object.entries(customHeaders)) { + headers[key] = value; + } + } + return headers; + } + + /** + * Issues the fetch and returns both the response and a `clearRequestTimeout` + * callback the caller must invoke once the request timeout's job — bounding + * connect + first response, matching the OpenAI/Anthropic SDK clients — is + * done for its use case. It is intentionally NOT cleared automatically here: + * `postJson` needs the timeout to keep bounding its `response.text()` read + * (mirroring the previous behavior for non-streaming calls), while `postSse` + * must clear it the moment headers arrive so a healthy, actively-flowing SSE + * body is never killed by `DEFAULT_TIMEOUT` — stream lifetime is the idle/ + * lifetime guards' job (see `withStreamGuards`), not the request timeout's. + */ + private async request( + body: DashScopeRequest, + opts: DashScopeRequestOptions, + streaming: boolean, + extraSignal?: AbortSignal, + ): Promise<{ + response: Response; + clearRequestTimeout: () => void; + normalizeRequestError: (error: unknown) => unknown; + }> { + const url = resolveDashScopeGenerationEndpoint( + this.contentGeneratorConfig.baseUrl, + ); + const headers = this.buildHeaders(streaming); + const timeoutMs = resolveRequestTimeout( + this.contentGeneratorConfig.timeout, + ); + const timeoutController = new AbortController(); + const timeoutError = Object.assign( + new Error(`DashScope request timed out after ${timeoutMs}ms.`), + { code: 'ETIMEDOUT' as const }, + ); + const timeoutTimer = setTimeout(() => { + timeoutController.abort(timeoutError); + }, timeoutMs); + timeoutTimer.unref?.(); + const clearRequestTimeout = () => clearTimeout(timeoutTimer); + const normalizeRequestError = (error: unknown): unknown => + timeoutController.signal.aborted && !opts.signal.aborted + ? timeoutError + : redactProxyError(error); + + const signals = [opts.signal, timeoutController.signal]; + if (extraSignal) { + signals.push(extraSignal); + } + const signal = AbortSignal.any(signals); + + const rt = buildRuntimeFetchOptions('anthropic', this.cliConfig.getProxy()); + const doFetch: typeof fetch = rt?.fetch ?? fetch; + let response: Response; + try { + response = await doFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + ...(rt?.fetchOptions ?? {}), + }); + } catch (err) { + clearRequestTimeout(); + throw normalizeRequestError(err); + } + + if (!response.ok) { + let raw: string; + try { + raw = await response.text(); + } catch (err) { + clearRequestTimeout(); + throw normalizeRequestError(err); + } + clearRequestTimeout(); + throw toDashScopeApiError({ + status: response.status, + rawBody: raw, + headers: response.headers, + }); + } + return { response, clearRequestTimeout, normalizeRequestError }; + } + + async postJson( + body: DashScopeRequest, + opts: DashScopeRequestOptions, + ): Promise { + const { response, clearRequestTimeout, normalizeRequestError } = + await this.request(body, opts, false); + let raw: string; + try { + raw = await response.text(); + } catch (err) { + throw normalizeRequestError(err); + } finally { + clearRequestTimeout(); + } + try { + return JSON.parse(raw) as DashScopeResponsePayload; + } catch { + throw toDashScopeApiError({ status: response.status, rawBody: raw }); + } + } + + async postSse( + body: DashScopeRequest, + opts: DashScopeRequestOptions, + ): Promise> { + const guardController = new AbortController(); + const { response, clearRequestTimeout } = await this.request( + body, + opts, + true, + guardController.signal, + ); + // Headers have arrived: the request timeout's job is done. From here on, + // stream lifetime is bounded exclusively by the idle/lifetime guards + // below, not by DEFAULT_TIMEOUT. + clearRequestTimeout(); + if (!response.body) { + throw toDashScopeApiError({ + status: response.status, + rawBody: 'DashScope streaming response had no body.', + }); + } + return this.withStreamGuards(parseDashScopeSse(response.body), () => + guardController.abort(), + ); + } + + private async *withStreamGuards( + frames: AsyncGenerator, + abortRequest: () => void, + ): AsyncGenerator { + const idleMs = resolveStreamGuardMs( + this.contentGeneratorConfig.streamIdleTimeoutMs, + 'streamIdleTimeoutMs', + QWEN_STREAM_IDLE_TIMEOUT_MS_ENV, + DEFAULT_STREAM_IDLE_TIMEOUT_MS, + ); + const maxLifetimeMs = resolveStreamGuardMs( + this.contentGeneratorConfig.streamMaxLifetimeMs, + 'streamMaxLifetimeMs', + QWEN_STREAM_MAX_LIFETIME_MS_ENV, + DEFAULT_STREAM_MAX_LIFETIME_MS, + ); + + if (idleMs <= 0 && maxLifetimeMs <= 0) { + yield* frames; + return; + } + + const streamStartedAt = performance.now(); + let upstreamMs = 0; + let chunksReceived = 0; + let finishedNormally = false; + + try { + while (true) { + const remainingMs = + maxLifetimeMs > 0 + ? maxLifetimeMs - upstreamMs + : Number.POSITIVE_INFINITY; + if (remainingMs <= 0) { + abortRequest(); + throw new StreamLifetimeExceededError( + maxLifetimeMs, + chunksReceived, + performance.now() - streamStartedAt, + ); + } + const nextPromise = frames.next(); + const awaitedAt = performance.now(); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + const idleIn = idleMs > 0 ? idleMs : Number.POSITIVE_INFINITY; + timer = setTimeout( + () => { + abortRequest(); + reject( + remainingMs <= idleIn + ? new StreamLifetimeExceededError( + maxLifetimeMs, + chunksReceived, + performance.now() - streamStartedAt, + ) + : new StreamInactivityTimeoutError( + idleMs, + chunksReceived, + performance.now() - streamStartedAt, + ), + ); + }, + Math.max(Math.min(idleIn, remainingMs), 0), + ); + timer.unref?.(); + }); + + let result: IteratorResult; + try { + result = await Promise.race([nextPromise, timeout]); + } catch (err) { + // If a guard fired first, the orphaned frames.next() may later + // reject (e.g. once abortRequest() tears down the socket) — + // swallow that so it never surfaces as an unhandled rejection. + void Promise.resolve(nextPromise).catch(() => {}); + throw err; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + + if (result.done) { + finishedNormally = true; + return; + } + upstreamMs += performance.now() - awaitedAt; + chunksReceived += 1; + yield result.value; + } + } finally { + if (!finishedNormally) { + try { + await frames.return?.(undefined); + } catch { + // Best-effort cleanup — a rejection here must not mask the + // original guard/abort error propagating out of the generator. + } + } + } + } +} diff --git a/packages/core/src/core/dashscopeContentGenerator/types.ts b/packages/core/src/core/dashscopeContentGenerator/types.ts new file mode 100644 index 00000000000..c69b140a318 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/types.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface DashScopeCacheControl { + type: 'ephemeral'; +} + +export interface DashScopeContentBlock { + text?: string; + image?: string; + video?: string; + audio?: string; + file?: string; + cache_control?: DashScopeCacheControl; +} + +export interface DashScopeToolCall { + id: string; + index?: number; + type: 'function'; + function: { name?: string; arguments?: string }; +} + +export interface DashScopeMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content?: string | DashScopeContentBlock[]; + reasoning_content?: string; + tool_calls?: DashScopeToolCall[]; + tool_call_id?: string; +} + +export interface DashScopeTool { + type: 'function'; + function: { + name: string; + description?: string; + parameters?: Record; + }; + cache_control?: DashScopeCacheControl; +} + +export interface DashScopeRequest { + model: string; + input: { messages: DashScopeMessage[] }; + parameters: Record; +} + +export interface DashScopeUsage { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + prompt_tokens_details?: { + cached_tokens?: number; + cache_type?: string; + cache_creation_input_tokens?: number; + cache_creation?: { ephemeral_5m_input_tokens?: number }; + }; + input_tokens_details?: Record; + output_tokens_details?: { text_tokens?: number; reasoning_tokens?: number }; +} + +export interface DashScopeChoice { + index?: number; + finish_reason?: string | null; + message?: DashScopeMessage & { tool_calls?: DashScopeToolCall[] }; +} + +export interface DashScopeResponsePayload { + output?: { choices?: DashScopeChoice[] }; + usage?: DashScopeUsage; + request_id?: string; +} + +export interface DashScopeErrorEnvelope { + code?: string; + message?: string; + request_id?: string; +} diff --git a/packages/core/src/core/dashscopeContentGenerator/usage.test.ts b/packages/core/src/core/dashscopeContentGenerator/usage.test.ts new file mode 100644 index 00000000000..17a4ccc56c5 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/usage.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { buildDashScopeUsageMetadata } from './usage.js'; +import { getGenAiUsageProvenance } from '../../telemetry/gen-ai-usage.js'; +import type { DashScopeUsage } from './types.js'; + +describe('buildDashScopeUsageMetadata', () => { + it('returns undefined for undefined input', () => { + expect(buildDashScopeUsageMetadata(undefined)).toBeUndefined(); + }); + + it('does NOT sum cached_tokens into promptTokenCount (no-summing regression)', () => { + const usage: DashScopeUsage = { + input_tokens: 1581, + output_tokens: 9, + total_tokens: 1590, + prompt_tokens_details: { + cached_tokens: 1564, + cache_type: 'ephemeral', + cache_creation_input_tokens: 9, + cache_creation: { ephemeral_5m_input_tokens: 9 }, + }, + input_tokens_details: { text_tokens: 1581 }, + output_tokens_details: { text_tokens: 9, reasoning_tokens: 24 }, + }; + + const metadata = buildDashScopeUsageMetadata(usage); + + expect(metadata?.promptTokenCount).toBe(1581); + expect(metadata?.cachedContentTokenCount).toBe(1564); + expect(metadata?.candidatesTokenCount).toBe(9); + expect(metadata?.thoughtsTokenCount).toBe(24); + expect(metadata?.totalTokenCount).toBe(1590); + }); + + it('ignores output_tokens_details.text_tokens entirely', () => { + const metadata = buildDashScopeUsageMetadata({ + input_tokens: 100, + output_tokens: 50, + output_tokens_details: { text_tokens: 50, reasoning_tokens: 30 }, + }); + + expect(metadata?.thoughtsTokenCount).toBe(30); + expect(metadata?.candidatesTokenCount).toBe(50); + }); + + it('falls back to input + output when total_tokens is absent', () => { + const metadata = buildDashScopeUsageMetadata({ + input_tokens: 14, + output_tokens: 1, + }); + + expect(metadata?.totalTokenCount).toBe(15); + }); + + it('defaults every count to 0 when fields are missing', () => { + expect(buildDashScopeUsageMetadata({})).toEqual({ + promptTokenCount: 0, + cachedContentTokenCount: 0, + candidatesTokenCount: 0, + thoughtsTokenCount: 0, + totalTokenCount: 0, + }); + }); + + it('records provenance mirroring the Anthropic generator call site', () => { + const usage: DashScopeUsage = { + input_tokens: 1581, + output_tokens: 9, + prompt_tokens_details: { + cached_tokens: 1564, + cache_creation_input_tokens: 9, + }, + }; + + const metadata = buildDashScopeUsageMetadata(usage); + const provenance = getGenAiUsageProvenance(metadata); + + expect(provenance).toEqual({ + cachedInputTokensReported: true, + cacheCreationInputTokens: 9, + }); + }); + + it('reports cachedInputTokensReported: false when cached_tokens is absent', () => { + const metadata = buildDashScopeUsageMetadata({ + input_tokens: 14, + output_tokens: 1, + }); + const provenance = getGenAiUsageProvenance(metadata); + + expect(provenance).toEqual({ + cachedInputTokensReported: false, + cacheCreationInputTokens: undefined, + }); + }); +}); diff --git a/packages/core/src/core/dashscopeContentGenerator/usage.ts b/packages/core/src/core/dashscopeContentGenerator/usage.ts new file mode 100644 index 00000000000..cc2947ce3e6 --- /dev/null +++ b/packages/core/src/core/dashscopeContentGenerator/usage.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { GenerateContentResponseUsageMetadata } from '@google/genai'; +import { setGenAiUsageProvenance } from '../../telemetry/gen-ai-usage.js'; +import type { DashScopeUsage } from './types.js'; + +/** + * Normalizes DashScope-side token counts into Gemini's `usageMetadata` shape. + * + * Unlike Anthropic, DashScope's `input_tokens` ALREADY INCLUDES + * `cached_tokens` (OpenAI-style accounting) — summing them here would + * roughly double the reported prompt size. See api-contract.md §6. + */ +export function buildDashScopeUsageMetadata( + usage: DashScopeUsage | undefined, +): GenerateContentResponseUsageMetadata | undefined { + if (!usage) { + return undefined; + } + + const promptTokenCount = usage.input_tokens ?? 0; + const cachedContentTokenCount = + usage.prompt_tokens_details?.cached_tokens ?? 0; + const candidatesTokenCount = usage.output_tokens ?? 0; + const thoughtsTokenCount = usage.output_tokens_details?.reasoning_tokens ?? 0; + const totalTokenCount = + usage.total_tokens ?? promptTokenCount + candidatesTokenCount; + + const metadata: GenerateContentResponseUsageMetadata = { + promptTokenCount, + cachedContentTokenCount, + candidatesTokenCount, + thoughtsTokenCount, + totalTokenCount, + }; + + setGenAiUsageProvenance(metadata, { + cachedInputTokensReported: + usage.prompt_tokens_details?.cached_tokens !== undefined, + cacheCreationInputTokens: + usage.prompt_tokens_details?.cache_creation_input_tokens, + }); + + return metadata; +} diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts index 1378d34d75c..8c46bf41024 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -15,6 +15,7 @@ import type { Config } from '../../config/config.js'; import type { ContentGenerator } from '../contentGenerator.js'; import { AuthType } from '../contentGenerator.js'; import { LoggingContentGenerator } from './index.js'; +import { toDashScopeApiError } from '../dashscopeContentGenerator/errors.js'; import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; import { openaiRequestCaptureContext } from '../openaiContentGenerator/requestCaptureContext.js'; import { @@ -1205,10 +1206,12 @@ describe('LoggingContentGenerator', () => { }); it('logs errors with status code and request id, then rethrows', async () => { - const error = Object.assign(new Error('boom'), { + const error = toDashScopeApiError({ status: 429, - request_id: 'req-99', - type: 'rate_limit', + rawBody: JSON.stringify({ + message: 'boom', + request_id: 'req-99', + }), }); const wrapped = createWrappedGenerator( vi.fn().mockRejectedValue(error), @@ -1238,14 +1241,13 @@ describe('LoggingContentGenerator', () => { const [, errorEvent] = vi.mocked(logApiError).mock.calls[0]; expect(errorEvent.response_id).toBe('req-99'); expect(errorEvent.status_code).toBe(429); - expect(errorEvent.error_type).toBe('rate_limit'); + expect(errorEvent.error_type).toBe('DashScopeApiError'); expect(errorEvent.prompt_id).toBe('prompt-2'); const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0] ?.value as { logInteraction: ReturnType }; const [, , loggedError] = openaiLoggerInstance.logInteraction.mock.calls[0]; - expect(loggedError).toBeInstanceOf(Error); - expect((loggedError as Error).message).toBe('boom'); + expect(loggedError).toBe(error); const spanRecord = getGenerateContentSpanRecord(); expect(spanRecord.statuses).toEqual([ diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index a7aaf90244b..973460ca20c 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -228,8 +228,12 @@ function clampProviderOutputBudgetKeys( * `ContentGeneratorConfig` field (programmatic, wins — including `0` to * disable) > the env deployment knob > the built-in default. A malformed env * value is ignored (with a `console.warn`) rather than failing the request. + * + * Exported so the native DashScope transport can share the exact same + * precedence, disable, and ceiling semantics for its idle/lifetime guards + * instead of re-implementing (and potentially diverging from) this logic. */ -function resolveStreamGuardMs( +export function resolveStreamGuardMs( fromConfig: number | undefined, configLabel: string, envName: string, diff --git a/packages/core/src/core/reasoning-effort.test.ts b/packages/core/src/core/reasoning-effort.test.ts index 3f820146785..dfb3504e1c3 100644 --- a/packages/core/src/core/reasoning-effort.test.ts +++ b/packages/core/src/core/reasoning-effort.test.ts @@ -10,6 +10,7 @@ import { REASONING_EFFORT_TIERS, applyReasoningEffort, clampReasoningEffort, + getSupportedReasoningEffortTiers, normalizeReasoningEffort, type ReasoningEffort, } from './reasoning-effort.js'; @@ -26,6 +27,26 @@ describe('REASONING_EFFORT_TIERS', () => { }); }); +describe('getSupportedReasoningEffortTiers', () => { + it('returns only documented tiers for native qwen3.8-max', () => { + expect( + getSupportedReasoningEffortTiers('dashscope', 'qwen3.8-max'), + ).toEqual(['low', 'medium', 'xhigh']); + }); + + it('does not constrain other providers or editable native models', () => { + expect(getSupportedReasoningEffortTiers('openai', 'qwen3.8-max')).toEqual( + REASONING_EFFORT_TIERS, + ); + expect( + getSupportedReasoningEffortTiers('dashscope', 'custom-model'), + ).toEqual(REASONING_EFFORT_TIERS); + expect( + getSupportedReasoningEffortTiers('dashscope', 'qwen3.8-max-preview'), + ).toEqual(REASONING_EFFORT_TIERS); + }); +}); + describe('normalizeReasoningEffort', () => { it('accepts canonical tiers case-insensitively', () => { expect(normalizeReasoningEffort('LOW')).toBe('low'); diff --git a/packages/core/src/core/reasoning-effort.ts b/packages/core/src/core/reasoning-effort.ts index aac10131782..c66a07b75a9 100644 --- a/packages/core/src/core/reasoning-effort.ts +++ b/packages/core/src/core/reasoning-effort.ts @@ -27,6 +27,21 @@ export const REASONING_EFFORT_TIERS: readonly ReasoningEffort[] = [ 'max', ] as const; +const QWEN_3_8_MAX_REASONING_EFFORT_TIERS: readonly ReasoningEffort[] = [ + 'low', + 'medium', + 'xhigh', +] as const; + +export function getSupportedReasoningEffortTiers( + authType?: string, + model?: string, +): readonly ReasoningEffort[] { + return authType === 'dashscope' && model?.toLowerCase() === 'qwen3.8-max' + ? QWEN_3_8_MAX_REASONING_EFFORT_TIERS + : REASONING_EFFORT_TIERS; +} + /** * Numeric strength used when clamping a requested tier down to what a model * supports. Gaps are intentional so future intermediate tiers (e.g. a diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 4f55526cdfd..941844ba358 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -95,11 +95,17 @@ export const AUTH_ENV_MAPPINGS = { baseUrl: [], model: [], }, + dashscope: { + apiKey: ['DASHSCOPE_API_KEY'], + baseUrl: ['DASHSCOPE_BASE_URL'], + model: ['DASHSCOPE_MODEL'], + }, } as const satisfies Record; export const DEFAULT_MODELS = { openai: MAINLINE_CODER_MODEL, 'qwen-oauth': DEFAULT_QWEN_MODEL, + dashscope: 'qwen3.8-max', } as Partial>; /** diff --git a/packages/core/src/models/modelConfigErrors.ts b/packages/core/src/models/modelConfigErrors.ts index e2d86445c90..5ffbb364bf9 100644 --- a/packages/core/src/models/modelConfigErrors.ts +++ b/packages/core/src/models/modelConfigErrors.ts @@ -14,6 +14,8 @@ export function getDefaultApiKeyEnvVar(authType: string | undefined): string { return 'GEMINI_API_KEY'; case 'vertex-ai': return 'GOOGLE_API_KEY'; + case 'dashscope': + return 'DASHSCOPE_API_KEY'; default: return 'API_KEY'; } @@ -29,6 +31,8 @@ export function getDefaultModelEnvVar(authType: string | undefined): string { return 'GEMINI_MODEL'; case 'vertex-ai': return 'GOOGLE_MODEL'; + case 'dashscope': + return 'DASHSCOPE_MODEL'; default: return 'MODEL'; } diff --git a/packages/core/src/providers/__tests__/presets/alibaba-native.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-native.test.ts new file mode 100644 index 00000000000..94130988a5e --- /dev/null +++ b/packages/core/src/providers/__tests__/presets/alibaba-native.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AuthType } from '../../../core/contentGenerator.js'; +import { alibabaNativeProvider } from '../../presets/alibaba-native.js'; +import { alibabaStandardProvider } from '../../presets/alibaba-standard.js'; +import { + buildInstallPlan, + getDefaultModelIds, + providerMatchesCredentials, + resolveBaseUrl, +} from '../../provider-config.js'; + +describe('alibabaNativeProvider', () => { + it('has correct provider config', () => { + expect(alibabaNativeProvider).toMatchObject({ + id: 'alibabaNative', + label: 'Native DashScope API', + protocol: AuthType.USE_DASHSCOPE, + envKey: 'DASHSCOPE_API_KEY', + modelsEditable: true, + modelNamePrefix: 'ModelStudio Native', + }); + }); + + it('offers multiple region endpoints', () => { + expect(Array.isArray(alibabaNativeProvider.baseUrl)).toBe(true); + const urls = (alibabaNativeProvider.baseUrl as Array<{ url: string }>).map( + (o) => o.url, + ); + expect(urls).toContain('https://dashscope-intl.aliyuncs.com/api/v1'); + expect(urls).toContain('https://dashscope.aliyuncs.com/api/v1'); + }); + + it('includes the qwen3.8-max default model', () => { + expect(getDefaultModelIds(alibabaNativeProvider)).toEqual(['qwen3.8-max']); + }); + + it('resolves baseUrl for known region', () => { + const url = resolveBaseUrl( + alibabaNativeProvider, + 'https://dashscope-intl.aliyuncs.com/api/v1', + ); + expect(url).toBe('https://dashscope-intl.aliyuncs.com/api/v1'); + }); + + it('creates an install plan with editable models', () => { + const plan = buildInstallPlan(alibabaNativeProvider, { + baseUrl: 'https://dashscope-intl.aliyuncs.com/api/v1', + apiKey: 'sk-native', + modelIds: ['qwen3.8-max', 'custom-model'], + }); + + expect(plan.providerId).toBe('alibabaNative'); + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'qwen3.8-max', + name: '[ModelStudio Native] qwen3.8-max', + }); + expect(models?.[1]).toMatchObject({ + id: 'custom-model', + name: '[ModelStudio Native] custom-model', + }); + }); + + it('matches credentials for all base URL options', () => { + const urls = (alibabaNativeProvider.baseUrl as Array<{ url: string }>).map( + (o) => o.url, + ); + for (const url of urls) { + expect( + providerMatchesCredentials( + alibabaNativeProvider, + url, + 'DASHSCOPE_API_KEY', + ), + ).toBe(true); + } + expect( + providerMatchesCredentials( + alibabaNativeProvider, + 'https://unknown.com', + 'DASHSCOPE_API_KEY', + ), + ).toBe(false); + }); + + /** + * alibabaNativeProvider and alibabaStandardProvider share + * `DASHSCOPE_API_KEY` and are disambiguated only by `modelNamePrefix` + * ('ModelStudio Native' vs 'ModelStudio Standard'). `resolveOwnsModel` + * relies on this prefix to decide which installed models belong to which + * provider when merging install plans — a collision here would make + * installing one provider silently delete the other's models. This test + * guards that regression directly, since no other test in the suite + * exercises both presets' `ownsModel` against each other's models. + */ + it('does not claim ownership of the sibling standard-provider models', () => { + const nativePlan = buildInstallPlan(alibabaNativeProvider, { + baseUrl: 'https://dashscope-intl.aliyuncs.com/api/v1', + apiKey: 'sk-shared', + modelIds: ['qwen3.8-max'], + }); + const standardPlan = buildInstallPlan(alibabaStandardProvider, { + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + apiKey: 'sk-shared', + modelIds: ['qwen3.7-max'], + }); + + const nativeOwnsModel = nativePlan.modelProviders?.[0]?.ownsModel; + const standardOwnsModel = standardPlan.modelProviders?.[0]?.ownsModel; + expect(nativeOwnsModel).toBeDefined(); + expect(standardOwnsModel).toBeDefined(); + + const standardModel = standardPlan.modelProviders?.[0]?.models?.[0]; + const nativeModel = nativePlan.modelProviders?.[0]?.models?.[0]; + expect(standardModel).toBeDefined(); + expect(nativeModel).toBeDefined(); + + // The native provider's ownsModel must reject the standard provider's + // model even though both share envKey === 'DASHSCOPE_API_KEY'. + expect(nativeOwnsModel?.(standardModel!)).toBe(false); + // ...and vice versa. + expect(standardOwnsModel?.(nativeModel!)).toBe(false); + + // Each provider still owns its own model. + expect(nativeOwnsModel?.(nativeModel!)).toBe(true); + expect(standardOwnsModel?.(standardModel!)).toBe(true); + }); +}); diff --git a/packages/core/src/providers/all-providers.ts b/packages/core/src/providers/all-providers.ts index aa5aad3dba6..d56aa992a74 100644 --- a/packages/core/src/providers/all-providers.ts +++ b/packages/core/src/providers/all-providers.ts @@ -12,6 +12,7 @@ import type { ProviderConfig } from './types.js'; import { codingPlanProvider } from './presets/alibaba-coding-plan.js'; import { tokenPlanProvider } from './presets/alibaba-token-plan.js'; import { alibabaStandardProvider } from './presets/alibaba-standard.js'; +import { alibabaNativeProvider } from './presets/alibaba-native.js'; import { openRouterProvider } from './presets/openrouter.js'; import { requestyProvider } from './presets/requesty.js'; import { deepseekProvider } from './presets/deepseek.js'; @@ -27,6 +28,7 @@ export { codingPlanProvider, tokenPlanProvider, alibabaStandardProvider, + alibabaNativeProvider, openRouterProvider, requestyProvider, deepseekProvider, @@ -54,6 +56,7 @@ export const ALL_PROVIDERS: readonly ProviderConfig[] = [ codingPlanProvider, tokenPlanProvider, alibabaStandardProvider, + alibabaNativeProvider, deepseekProvider, grokProvider, minimaxProvider, diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index fbe784bae52..9d46b764d61 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -39,6 +39,7 @@ export { ALL_PROVIDERS, ALIBABA_PROVIDERS, alibabaStandardProvider, + alibabaNativeProvider, codingPlanProvider, CUSTOM_API_KEY_ENV_PREFIX, customProvider, diff --git a/packages/core/src/providers/presets/alibaba-native.ts b/packages/core/src/providers/presets/alibaba-native.ts new file mode 100644 index 00000000000..aa89cd985df --- /dev/null +++ b/packages/core/src/providers/presets/alibaba-native.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; + +/** + * `modelNamePrefix: 'ModelStudio Native'` MUST differ from + * `alibabaStandardProvider`'s `'ModelStudio Standard'` — both share + * `DASHSCOPE_API_KEY`, and `resolveOwnsModel` (provider-config.ts) + * disambiguates ownership by envKey + name prefix; a collision would let + * installing this preset delete a user's existing compat-mode models. + * + * Deliberately NOT set: `enableThinking` (qwen3.8-max thinks by default; the + * flag would inject a redundant `enable_thinking` extra_body knob that the + * native converter never emits), `thinkingMandatory` (`reasoning_effort: + * 'none'` is a verified working off-switch). Model metadata needs no new + * registry entries — `tokenLimits.ts` already gives `qwen3.x` models 1M + * context / 64k output, and `modalityDefaults.ts` already maps + * `qwen3.8-max` to image support. + */ +export const alibabaNativeProvider: ProviderConfig = { + id: 'alibabaNative', + label: 'Native DashScope API', + description: + 'Native DashScope API — explicit prompt caching and raw reasoning stream', + protocol: AuthType.USE_DASHSCOPE, + baseUrl: [ + { + id: 'sg-singapore', + label: 'Singapore', + url: 'https://dashscope-intl.aliyuncs.com/api/v1', + }, + { + id: 'cn-beijing', + label: 'China (Beijing)', + url: 'https://dashscope.aliyuncs.com/api/v1', + }, + { + id: 'us-virginia', + label: 'US (Virginia)', + url: 'https://dashscope-us.aliyuncs.com/api/v1', + }, + ], + envKey: 'DASHSCOPE_API_KEY', + models: [{ id: 'qwen3.8-max', contextWindowSize: 1_000_000 }], + modelsEditable: true, + modelNamePrefix: 'ModelStudio Native', + uiGroup: 'alibaba', + uiLabels: { + flowTitle: 'Alibaba ModelStudio (Native API)', + baseUrlStepTitle: 'Region', + }, +}; diff --git a/packages/core/src/providers/provider-config.ts b/packages/core/src/providers/provider-config.ts index 88577e24b77..b6057bc8eda 100644 --- a/packages/core/src/providers/provider-config.ts +++ b/packages/core/src/providers/provider-config.ts @@ -308,6 +308,7 @@ export function computeModelListVersion(models: ProviderModelConfig[]): string { const DEFAULT_BASE_URLS: Partial> = { [AuthType.USE_OPENAI]: 'https://api.openai.com/v1', [AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1', + [AuthType.USE_DASHSCOPE]: 'https://dashscope-intl.aliyuncs.com/api/v1', [AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com', }; diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 59b9c51e0e5..8f35081b701 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -2358,7 +2358,11 @@ describe('ChatCompressionService.compress cache sharing', () => { expect(slimSpy).not.toHaveBeenCalled(); }); - it.each([AuthType.QWEN_OAUTH, AuthType.USE_OPENAI])( + it.each([ + AuthType.QWEN_OAUTH, + AuthType.USE_OPENAI, + AuthType.USE_DASHSCOPE, + ])( 'uses cache sharing for DashScope through %s', async (authType) => { const { chat, config, generateText } = makeFixture({ authType }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 9a37b9b3e19..c985cee65fc 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -333,6 +333,7 @@ function supportsCompressionCacheSharing(config: Config): boolean { } if (provider.enableCacheControl === false) return false; if (provider.authType === AuthType.USE_ANTHROPIC) return true; + if (provider.authType === AuthType.USE_DASHSCOPE) return true; return supportsOpenAIPrefixCaching(provider); } diff --git a/packages/core/src/telemetry/gen-ai-content.ts b/packages/core/src/telemetry/gen-ai-content.ts index 75b44ab7557..df349db077c 100644 --- a/packages/core/src/telemetry/gen-ai-content.ts +++ b/packages/core/src/telemetry/gen-ai-content.ts @@ -292,6 +292,31 @@ function openAiPart(value: unknown): PartWithRole | undefined { return part ? { part } : undefined; } +function dashScopePart(value: unknown): PartWithRole | undefined { + if (typeof value === 'string') { + return { part: { type: 'text', content: value } }; + } + const item = record(value); + if (!item) return undefined; + const text = string(item['text']); + if (text !== undefined) { + return { part: { type: 'text', content: text } }; + } + for (const [key, contentModality] of [ + ['image', 'image'], + ['video', 'video'], + ['audio', 'audio'], + ['file', 'document'], + ] as const) { + const uri = string(item[key]); + if (!uri) continue; + const blob = dataUriBlob(uri, contentModality, null); + if (isDataUri(uri) && !blob) return undefined; + return { part: blob ?? uriPart(uri, contentModality) }; + } + return undefined; +} + function anthropicPart(value: unknown): PartWithRole | undefined { if (typeof value === 'string') { return { part: { type: 'text', content: value } }; @@ -916,6 +941,24 @@ export function extractOpenAiContent(request: object): GenAiContentAttributes { }; } +export function extractDashScopeContent( + request: object, +): GenAiContentAttributes { + const value = request as Record; + const input = record(value['input']); + const parameters = record(value['parameters']); + return { + inputMessages: + input && Object.hasOwn(input, 'messages') + ? messages(input['messages'], dashScopePart, (role) => role) + : undefined, + toolDefinitions: + parameters && Object.hasOwn(parameters, 'tools') + ? openAiTools(parameters['tools']) + : undefined, + }; +} + export function extractAnthropicContent( request: object, ): GenAiContentAttributes { diff --git a/packages/core/src/telemetry/gen-ai-exchange.test.ts b/packages/core/src/telemetry/gen-ai-exchange.test.ts index 0b7c79b45f4..1fb67ad8122 100644 --- a/packages/core/src/telemetry/gen-ai-exchange.test.ts +++ b/packages/core/src/telemetry/gen-ai-exchange.test.ts @@ -18,6 +18,8 @@ import { reportAnthropicFollowingRequest, reportAnthropicRequest, reportAnthropicResponse, + reportDashScopeRequest, + reportGeminiResponse, reportOpenAiChunk, reportOpenAiRequest, reportOpenAiResponse, @@ -120,6 +122,77 @@ describe('GenAI exchange observer', () => { ]); }); + it('records native DashScope request content and converted response content', () => { + const target = span(); + const observed = exchange(target); + const attempt = reportDashScopeRequest( + { + model: 'qwen3.8-max', + input: { + messages: [ + { role: 'system', content: [{ text: 'be concise' }] }, + { role: 'user', content: [{ text: 'hello' }] }, + ], + }, + parameters: { + temperature: 0.2, + tools: [ + { + type: 'function', + function: { + name: 'lookup', + parameters: { type: 'object' }, + }, + }, + ], + }, + }, + observed.context, + ); + reportGeminiResponse(attempt, { + candidates: [ + { + index: 0, + content: { role: 'model', parts: [{ text: 'answer' }] }, + finishReason: 'STOP', + }, + ], + }); + + expect(observed.controller.finalize(true)).toEqual(['STOP']); + expect(target.attributes['gen_ai.request.temperature']).toBe(0.2); + expect( + JSON.parse(target.attributes['gen_ai.input.messages'] as string), + ).toEqual([ + { + role: 'system', + parts: [{ type: 'text', content: 'be concise' }], + }, + { + role: 'user', + parts: [{ type: 'text', content: 'hello' }], + }, + ]); + expect( + JSON.parse(target.attributes['gen_ai.tool.definitions'] as string), + ).toEqual([ + { + type: 'function', + name: 'lookup', + parameters: { type: 'object' }, + }, + ]); + expect( + JSON.parse(target.attributes['gen_ai.output.messages'] as string), + ).toEqual([ + { + role: 'assistant', + parts: [{ type: 'text', content: 'answer' }], + finish_reason: 'STOP', + }, + ]); + }); + it('keeps the first request snapshot and latest response attempt', () => { const target = span(); const observed = exchange(target); diff --git a/packages/core/src/telemetry/gen-ai-provider.test.ts b/packages/core/src/telemetry/gen-ai-provider.test.ts index bd8c94bfe44..9bc4a0a08bd 100644 --- a/packages/core/src/telemetry/gen-ai-provider.test.ts +++ b/packages/core/src/telemetry/gen-ai-provider.test.ts @@ -22,6 +22,12 @@ describe('GenAI provider resolution', () => { ).toBe('dashscope'); }); + it('identifies env-only native DashScope configuration', () => { + expect(resolveGenAiProviderName({ authType: 'dashscope' })).toBe( + 'dashscope', + ); + }); + it.each([ ['https://dashscope.aliyuncs.com/compatible-mode/v1', 'dashscope'], ['https://DASHSCOPE-INTL.ALIYUNCS.COM/v1/', 'dashscope'], @@ -143,6 +149,7 @@ describe('GenAI provider resolution', () => { it.each([ ['openai', 'openai'], ['anthropic', 'anthropic'], + ['dashscope', 'dashscope'], ['gemini', 'gcp.gemini'], ['vertex-ai', 'gcp.vertex_ai'], ] as const)( diff --git a/packages/core/src/telemetry/gen-ai-provider.ts b/packages/core/src/telemetry/gen-ai-provider.ts index e699d8dbee4..8755c1e3476 100644 --- a/packages/core/src/telemetry/gen-ai-provider.ts +++ b/packages/core/src/telemetry/gen-ai-provider.ts @@ -11,7 +11,8 @@ export type GenAiAuthType = | 'qwen-oauth' | 'gemini' | 'vertex-ai' - | 'anthropic'; + | 'anthropic' + | 'dashscope'; interface ProviderConfig { authType?: GenAiAuthType; @@ -124,6 +125,8 @@ export function resolveGenAiProviderName( switch (config.authType) { case 'anthropic': return 'anthropic'; + case 'dashscope': + return 'dashscope'; case 'gemini': return 'gcp.gemini'; case 'vertex-ai': diff --git a/packages/core/src/telemetry/gen-ai-request.test.ts b/packages/core/src/telemetry/gen-ai-request.test.ts index 54ee77a0262..23bc5d4d6fb 100644 --- a/packages/core/src/telemetry/gen-ai-request.test.ts +++ b/packages/core/src/telemetry/gen-ai-request.test.ts @@ -9,6 +9,7 @@ import { ROOT_CONTEXT, type Attributes, type Span } from '@opentelemetry/api'; import { createGenAiRequestObserverContext, extractAnthropicRequestAttributes, + extractDashScopeRequestAttributes, extractGeminiRequestAttributes, extractOpenAiRequestAttributes, reportOpenAiRequest, @@ -142,6 +143,29 @@ describe('GenAI request attribute extraction', () => { }); }); + it('extracts fields from the final DashScope parameters', () => { + expect( + extractDashScopeRequestAttributes({ + model: 'qwen3.8-max', + parameters: { + max_tokens: 1024, + temperature: 0.2, + top_p: 0.8, + frequency_penalty: -0.1, + presence_penalty: 0.3, + stop: ['done'], + }, + }), + ).toEqual({ + 'gen_ai.request.max_tokens': 1024, + 'gen_ai.request.temperature': 0.2, + 'gen_ai.request.top_p': 0.8, + 'gen_ai.request.frequency_penalty': -0.1, + 'gen_ai.request.presence_penalty': 0.3, + 'gen_ai.request.stop_sequences': ['done'], + }); + }); + it('extracts fields from the final Gemini config', () => { expect( extractGeminiRequestAttributes({ diff --git a/packages/core/src/telemetry/gen-ai-request.ts b/packages/core/src/telemetry/gen-ai-request.ts index 571c44be618..60dd49d4c16 100644 --- a/packages/core/src/telemetry/gen-ai-request.ts +++ b/packages/core/src/telemetry/gen-ai-request.ts @@ -14,6 +14,7 @@ import { } from '@opentelemetry/api'; import { extractAnthropicContent, + extractDashScopeContent, extractGeminiContent, extractOpenAiContent, GenAiOutputAccumulator, @@ -160,6 +161,18 @@ export function extractAnthropicRequestAttributes(request: object): Attributes { return attributes; } +export function extractDashScopeRequestAttributes(request: object): Attributes { + const parameters = ownValue(request as RequestRecord, 'parameters'); + if ( + typeof parameters !== 'object' || + parameters === null || + Array.isArray(parameters) + ) { + return {}; + } + return extractOpenAiRequestAttributes(parameters); +} + export function extractGeminiRequestAttributes(request: object): Attributes { const record = request as RequestRecord; const config = ownValue(record, 'config'); @@ -489,6 +502,18 @@ export function reportAnthropicRequest( ); } +export function reportDashScopeRequest( + request: object, + requestContext?: Context, +): GenAiAttemptHandle | undefined { + return reportRequest( + request, + extractDashScopeRequestAttributes, + extractDashScopeContent, + requestContext, + ); +} + export function reportAnthropicFollowingRequest( request: object, previousAttempt: GenAiAttemptHandle | undefined, diff --git a/packages/sdk-python/src/qwen_code_sdk/types.py b/packages/sdk-python/src/qwen_code_sdk/types.py index 615ff1d460e..2673ab5393b 100644 --- a/packages/sdk-python/src/qwen_code_sdk/types.py +++ b/packages/sdk-python/src/qwen_code_sdk/types.py @@ -22,6 +22,7 @@ "qwen-oauth", "gemini", "vertex-ai", + "dashscope", ] Effort: TypeAlias = Literal["low", "medium", "high", "xhigh", "max"] diff --git a/packages/sdk-python/src/qwen_code_sdk/validation.py b/packages/sdk-python/src/qwen_code_sdk/validation.py index 61ced1976c0..04934ffe8e0 100644 --- a/packages/sdk-python/src/qwen_code_sdk/validation.py +++ b/packages/sdk-python/src/qwen_code_sdk/validation.py @@ -13,7 +13,14 @@ ) _VALID_PERMISSION_MODES = {"default", "plan", "auto-edit", "yolo"} -_VALID_AUTH_TYPES = {"openai", "anthropic", "qwen-oauth", "gemini", "vertex-ai"} +_VALID_AUTH_TYPES = { + "openai", + "anthropic", + "qwen-oauth", + "gemini", + "vertex-ai", + "dashscope", +} _VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"} @@ -93,7 +100,8 @@ def validate_query_options(options: QueryOptions) -> None: if options.auth_type and options.auth_type not in _VALID_AUTH_TYPES: raise ValidationError( f"Invalid auth_type: {options.auth_type!r}. " - "Expected one of: openai, anthropic, qwen-oauth, gemini, vertex-ai." + "Expected one of: openai, anthropic, qwen-oauth, gemini, vertex-ai, " + "dashscope." ) if options.effort and options.effort not in _VALID_EFFORTS: diff --git a/packages/sdk-python/tests/unit/test_validation.py b/packages/sdk-python/tests/unit/test_validation.py index 783e8e1bee9..7d956a6aa7e 100644 --- a/packages/sdk-python/tests/unit/test_validation.py +++ b/packages/sdk-python/tests/unit/test_validation.py @@ -86,6 +86,10 @@ def test_rejects_invalid_auth_type() -> None: validate_query_options(QueryOptions.from_mapping({"auth_type": "custom"})) +def test_accepts_dashscope_auth_type() -> None: + validate_query_options(QueryOptions(auth_type="dashscope")) + + def test_from_mapping_rejects_non_callable_can_use_tool() -> None: with pytest.raises(TypeError, match="can_use_tool must be callable"): QueryOptions.from_mapping({"can_use_tool": "bad"}) diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 97b765fbb32..54e3b1af0f3 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -241,7 +241,8 @@ export type AuthType = | 'anthropic' | 'qwen-oauth' | 'gemini' - | 'vertex-ai'; + | 'vertex-ai' + | 'dashscope'; /** * TODO: Align with `ToolCallConfirmationDetails` diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 20064a8dd85..3ee9c7f8ac2 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -222,7 +222,14 @@ export const QueryOptionsSchema = z excludeTools: z.array(z.string()).optional(), allowedTools: z.array(z.string()).optional(), authType: z - .enum(['openai', 'anthropic', 'qwen-oauth', 'gemini', 'vertex-ai']) + .enum([ + 'openai', + 'anthropic', + 'qwen-oauth', + 'gemini', + 'vertex-ai', + 'dashscope', + ]) .optional(), agents: z .array( diff --git a/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts b/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts index c5aa71ca171..53f40979000 100644 --- a/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts +++ b/packages/sdk-typescript/test/unit/queryOptionsSchema.test.ts @@ -11,6 +11,11 @@ describe('QueryOptionsSchema', () => { expect(result.success).toBe(true); }); + it('accepts DashScope authentication', () => { + const result = QueryOptionsSchema.safeParse({ authType: 'dashscope' }); + expect(result.success).toBe(true); + }); + it('accepts fallbackModel with up to 3 models', () => { const result = QueryOptionsSchema.safeParse({ fallbackModel: ['a', 'b', 'c'], diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 02e47a81148..4bcde09d52b 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -45,7 +45,7 @@ "additionalProperties": true }, "providerProtocol": { - "description": "Maps a custom modelProviders provider id to the SDK protocol that routes its requests (e.g. {\"idealab\": \"openai\"}). Lets a custom provider id reuse a built-in protocol. Built-in provider ids (openai, gemini, anthropic, vertex-ai, qwen-oauth) are routed automatically and need no entry.", + "description": "Maps a custom modelProviders provider id to the SDK protocol that routes its requests (e.g. {\"idealab\": \"openai\"}). Lets a custom provider id reuse a built-in protocol. Built-in provider ids (openai, gemini, anthropic, vertex-ai, qwen-oauth, dashscope) are routed automatically and need no entry.", "type": "object", "additionalProperties": true },