# ADR-0027: Qwen (Alibaba) built-in provider — OpenAI-compat, not Anthropic-compat **Status:** Accepted — 2026-08-12 ## Context Alibaba's Qwen models (`qwen3.8-max`, `qwen3.7-plus`, the `qwen3-vl-*` vision variants, …) are served from Model Studio / DashScope, and mort wants them as a first-class failover tier with a dedicated `QWEN_API_KEY` — the same ergonomics ADR-0026 gave Kimi. Unlike Kimi, Model Studio exposes the same models over **two** protocols: | | OpenAI-compatible | Anthropic-compatible | |---|---|---| | Base URL | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope-intl.aliyuncs.com/apps/anthropic` | | Endpoints | full Chat Completions surface | `/v1/messages` only (no `/v1/models`) | | Purpose | the documented developer API | a shim, documented around hosting Claude Code | So the question this ADR answers is not "which client do we reuse" but "which of Alibaba's two wire protocols does the built-in speak". ## Decision **The `qwen` built-in and the `qwen://` DSN scheme speak OpenAI-compat**, over `provider/openai` — no new package, mirroring ADR-0026 (kimi) and ADR-0015 (llama-swap chat). Default base URL is the international host; the China host (`dashscope.aliyuncs.com/compatible-mode/v1`) and workspace-scoped regional hosts are reachable with a `qwen://` DSN. Credential handling is copied from kimi verbatim, because both of its rules are load-bearing: `WithAPIKey` is passed unconditionally (even empty) so an unset `QWEN_API_KEY` can never fall through to `openai.New`'s `OPENAI_API_KEY` default, and `WithAPIKeyName("QWEN_API_KEY")` makes the synthetic-401 hint name the variable the operator actually has to set. The kimi and qwen DSN factories were identical, so they now share one `openaiCompatScheme` helper — the next OpenAI-compat built-in gets the credential and key-hint rules by construction rather than by copy. ### Why not the Anthropic-compatible endpoint Every concrete difference favors OpenAI-compat *for this codebase*: - **Reasoning survives the trip.** Model Studio takes `reasoning_effort` as a top-level field on the OpenAI surface, which `provider/openai` already sends — `llm.WithReasoningEffort` works on qwen with zero qwen-specific code (`TestQwenReasoningEffortReachesWire` asserts it on the wire). Down the anthropic client it would be dropped in silence: `provider/anthropic` deliberately ignores `Request.ReasoningEffort`, because first-party Claude has no such knob. - **Structured output would regress.** `provider/anthropic` implements `Request.Schema` with the first-party GA `output_config.format` mechanism. Alibaba's shim does not implement it; a compat endpoint that ignores an unknown field returns unconstrained prose while still reporting success. The OpenAI path sends `response_format: json_schema`, which Model Studio supports natively on the Max/Plus families. - **Cache accounting already lands.** Model Studio's implicit prefix cache reports hits in `usage.prompt_tokens_details.cached_tokens`, which the openai client already maps to `llm.Usage.CacheReadTokens`. The anthropic client reads `cache_read_input_tokens`, a field the shim has no reason to emit. - **Thinking content is discarded on the anthropic path anyway.** `provider/anthropic` skips `thinking` blocks in both the buffered and streaming decoders, so the shim's headline feature — first-class `thinking: {type: "enabled", budget_tokens: N}` — buys majordomo nothing today. - **Smaller blast radius.** The anthropic client has no `WithAPIKeyName` option, so a keyless qwen would tell the operator to set `ANTHROPIC_API_KEY`; fixing that means changing the first-party Anthropic client to serve a third-party shim. - **It is the less-exercised surface.** The Anthropic endpoint is documented as Messages-only, with a temperature range that differs from Anthropic's own ([0, 2) vs [0.0, 1.0]) — i.e. it is Qwen semantics wearing an Anthropic envelope, not an Anthropic-equivalent target. The one thing the Anthropic surface offers that OpenAI-compat does not is explicit `cache_control` breakpoints reached through `Request.PromptCache`. That is not a reason to route Qwen through it: Model Studio's implicit cache is automatic and already metered, and if explicit breakpoints ever matter they belong in `provider/openai` (Model Studio accepts `cache_control` on content blocks there too), where every OpenAI-compat target would get them. ## Consequences - `qwen/` is first-class in Parse, chains, aliases, and health/failover with no consumer wiring; model ids pass through verbatim (no catalog). - Chat, streaming, tools, structured output, reasoning effort, and cached-token accounting all ride the openai client and inherit its fixes. - Image *inputs* work at the client level, but only the `qwen-vl-*` / `qwen3-vl-*` models accept them (matrix footnote ⁴; ³ is kimi's). - Two model-side quirks are Alibaba's, not majordomo's, and are left to the caller rather than papered over: thinking is **on by default** on some models (e.g. `qwen3.7-plus`), and Qwen3 *open-source* models require streaming when thinking is enabled — a buffered `Generate` against one of those needs a model that supports non-streaming thinking (the Max/Plus families do). - If a future consumer genuinely needs the Anthropic surface, it is reachable today without library changes: `LLM_QWEN_ANTHROPIC=anthropic://token@dashscope-intl.aliyuncs.com/apps/anthropic` — with the reasoning/structured-output caveats above. - Second third-party built-in after kimi. The ADR-0026 bar still holds: a named consumer needs it in-config. `RegisterProvider`/`LLM_*` remain the path for everything else.