feat(qwen): Alibaba Qwen built-in over Model Studio's OpenAI-compatible mode
Adds the `qwen` built-in provider and the `qwen://` DSN scheme, keyed by QWEN_API_KEY and defaulting to Model Studio's international host. Like kimi (ADR-0026) it is `provider/openai` pointed elsewhere — no new client. Model Studio serves the same models over two protocols, so the real decision was which wire format to speak. ADR-0027 records why it is the OpenAI one: down the anthropic client `ReasoningEffort` is ignored by design, structured output rides the first-party `output_config.format` mechanism the shim does not implement, and cached-token accounting reads Anthropic-only usage fields. Each of those fails silently rather than loudly, which is what makes the choice worth writing down. The shim stays reachable ad hoc via an `anthropic://` DSN. The kimi and qwen DSN factories were byte-identical, so they now share one `openaiCompatScheme` helper: the "credential comes from the DSN token, and the missing-key hint names LLM_<NAME>" rules hold by construction instead of by copy. Tests are hermetic and break-checked (all six fail on a deliberate mutation), including the reverse credential leak — a visible QWEN_API_KEY must not authenticate the openai built-in — and reasoning_effort asserted on the wire body, which is the ADR's load-bearing claim. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# 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/<model>` 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 ³, shared with kimi).
|
||||
- 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://[email protected]/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.
|
||||
Reference in New Issue
Block a user