Skip to content

Commit babe027

Browse files
authored
feat: add qwen provider (#12)
1 parent cb63cc6 commit babe027

7 files changed

Lines changed: 238 additions & 7 deletions

File tree

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# llm_bridge
22

3-
> **Open source by Santander AI Lab.** A tiny, vendor-neutral **LLM client library** — one interface for **OpenAI, DeepSeek, AWS Bedrock and Google Gemini** (or bring your own AI backend).
3+
> **Open source by Santander AI Lab.** A tiny, vendor-neutral **LLM client library** — one interface for **OpenAI, DeepSeek, Alibaba Qwen, AWS Bedrock and Google Gemini** (or bring your own AI backend).
44
55
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
66
[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
@@ -15,7 +15,8 @@ Part of [**Santander AI Open Source**](https://github.com/SantanderAI) — open
1515

1616
A tiny, **vendor-neutral wrapper for any LLM backend**. One small interface,
1717
pluggable providers. Write your application against `LLMClient` once and switch
18-
between OpenAI, DeepSeek, AWS Bedrock, Google Gemini, a local server, or your own
18+
between OpenAI, DeepSeek, Alibaba Qwen, AWS Bedrock, Google Gemini, a local
19+
server, or your own
1920
internal backend — without touching your code.
2021

2122
- **Canonical interface + thin SDK adapters.** One contract (`LLMClient`) with
@@ -34,6 +35,7 @@ internal backend — without touching your code.
3435
pip install llm-bridge # core only (no vendor SDKs)
3536
pip install "llm-bridge[openai]" # + OpenAI SDK
3637
pip install "llm-bridge[deepseek]" # + OpenAI SDK for DeepSeek
38+
pip install "llm-bridge[qwen]" # + OpenAI SDK for Qwen/DashScope
3739
pip install "llm-bridge[aws]" # + AWS Bedrock (boto3)
3840
pip install "llm-bridge[google]" # + Google Gemini (google-genai)
3941
pip install "llm-bridge[all]" # everything
@@ -51,6 +53,7 @@ print(llm.complete("Hello!").content)
5153
# Switch provider by changing one dict — your code stays the same.
5254
llm = create_llm({"provider": "openai", "model": "gpt-4o-mini"}) # needs [openai] + OPENAI_API_KEY
5355
llm = create_llm({"provider": "deepseek", "model": "deepseek-v4-pro"}) # needs [deepseek] + DEEPSEEK_API_KEY
56+
llm = create_llm({"provider": "qwen", "model": "qwen-plus"}) # needs [qwen] + DASHSCOPE_API_KEY
5457
llm = create_llm({"provider": "bedrock", "model": "<bedrock-model-id>"}) # needs [aws] + AWS creds
5558
llm = create_llm({"provider": "google", "model": "gemini-2.5-flash"}) # needs [google] + GOOGLE_API_KEY
5659

@@ -85,11 +88,13 @@ print(llm.complete("Hi").content)
8588
| Bring your own | `callable` | none |
8689
| OpenAI (and OpenAI-compatible) | `openai` | `[openai]` |
8790
| DeepSeek | `deepseek` | `[deepseek]` |
91+
| Alibaba Qwen | `qwen` | `[qwen]` |
8892
| AWS Bedrock (Converse) | `bedrock`, `aws` | `[aws]` |
8993
| Google Gemini | `google`, `gemini` | `[google]` |
9094

9195
Credentials are read from environment variables (`OPENAI_API_KEY`,
92-
`DEEPSEEK_API_KEY`, `GOOGLE_API_KEY`/`GEMINI_API_KEY`, standard AWS credential chain). Never
96+
`DEEPSEEK_API_KEY`, `DASHSCOPE_API_KEY`, `GOOGLE_API_KEY`/`GEMINI_API_KEY`,
97+
standard AWS credential chain). Never
9398
hardcode secrets.
9499

95100
The `openai` provider also targets any **OpenAI-compatible** endpoint (vLLM,
@@ -100,6 +105,11 @@ The `deepseek` provider uses DeepSeek's OpenAI-compatible API via the OpenAI
100105
SDK, defaults to `https://api.deepseek.com`, and accepts `base_url` or
101106
`DEEPSEEK_BASE_URL` for compatible endpoints.
102107

108+
The `qwen` provider uses Alibaba Model Studio/DashScope's OpenAI-compatible API
109+
via the OpenAI SDK. It defaults to
110+
`https://dashscope-intl.aliyuncs.com/compatible-mode/v1` and accepts `base_url`
111+
or `DASHSCOPE_BASE_URL` for other regions or workspaces.
112+
103113
## The interface
104114

105115
```python
@@ -132,13 +142,13 @@ Implement `LLMClient`, expose `build(config) -> LLMClient`, and register it in
132142

133143
See [`examples/`](examples): `mock_example.py`, `callable_example.py`,
134144
`openai_example.py`, `deepseek_example.py`, `bedrock_example.py`,
135-
`google_example.py`.
145+
`qwen_example.py`, `bedrock_example.py`, `google_example.py`.
136146

137147
## Requirements
138148

139149
- Python 3.9+
140150
- No required runtime dependencies for the core (`mock`, `callable`).
141-
- Optional vendor SDKs are installed on demand via extras (`[openai]`, `[aws]`, `[google]`, `[all]`).
151+
- Optional vendor SDKs are installed on demand via extras (`[openai]`, `[deepseek]`, `[qwen]`, `[aws]`, `[google]`, `[all]`).
142152

143153
## Contributing
144154

examples/qwen_example.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright (c) 2026 Santander Group
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Alibaba Qwen provider.
4+
5+
Requires:
6+
pip install "llm-bridge[qwen]"
7+
export DASHSCOPE_API_KEY=...
8+
9+
Run:
10+
python examples/qwen_example.py
11+
"""
12+
13+
import os
14+
15+
from llm_bridge import create_llm
16+
17+
18+
def main() -> None:
19+
if not os.environ.get("DASHSCOPE_API_KEY"):
20+
print("Set DASHSCOPE_API_KEY to run this example.")
21+
return
22+
23+
try:
24+
llm = create_llm(
25+
{
26+
"provider": "qwen",
27+
"model": os.environ.get("QWEN_MODEL", "qwen-plus"),
28+
}
29+
)
30+
except ImportError as exc:
31+
print(exc)
32+
return
33+
34+
resp = llm.complete("Say hello in one short sentence.", system="You are friendly and concise.")
35+
print(resp.content)
36+
print("tokens:", resp.total_tokens)
37+
38+
39+
if __name__ == "__main__":
40+
main()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = []
3737
[project.optional-dependencies]
3838
openai = ["openai>=2.44.0"]
3939
deepseek = ["openai>=2.44.0"]
40+
qwen = ["openai>=2.44.0"]
4041
aws = ["boto3>=1.43.38"]
4142
google = ["google-genai>=2.10.0"]
4243
all = ["openai>=2.44.0", "boto3>=1.43.38", "google-genai>=2.10.0"]

src/llm_bridge/providers/qwen.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright (c) 2026 Santander Group
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Alibaba Qwen provider using DashScope's OpenAI-compatible API.
4+
5+
Optional dependency — requires ``pip install llm-bridge[qwen]``.
6+
7+
Configuration (config keys, with environment fallbacks):
8+
model (required) e.g. "qwen-plus"
9+
api_key DASHSCOPE_API_KEY
10+
base_url DASHSCOPE_BASE_URL (optional; defaults to DashScope international)
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import os
16+
import time
17+
from typing import Any, Dict, List, Optional, cast
18+
19+
from llm_bridge.base import LLMClient, LLMResponse, Message
20+
21+
DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
22+
23+
24+
class QwenClient(LLMClient):
25+
"""Chat client backed by Alibaba Qwen through DashScope."""
26+
27+
def __init__(
28+
self,
29+
model: str,
30+
api_key: Optional[str] = None,
31+
base_url: Optional[str] = None,
32+
):
33+
if not model:
34+
raise ValueError("qwen provider requires 'model'.")
35+
self._model = model
36+
37+
try:
38+
from openai import OpenAI
39+
except ImportError as exc: # pragma: no cover
40+
raise ImportError(
41+
"The 'qwen' provider requires the openai SDK. "
42+
"Install it with: pip install llm-bridge[qwen]"
43+
) from exc
44+
45+
self._client = OpenAI(
46+
api_key=api_key or os.environ.get("DASHSCOPE_API_KEY"),
47+
base_url=base_url or os.environ.get("DASHSCOPE_BASE_URL") or DEFAULT_BASE_URL,
48+
)
49+
50+
@property
51+
def model(self) -> str:
52+
return self._model
53+
54+
@property
55+
def provider(self) -> str:
56+
return "qwen"
57+
58+
def chat(
59+
self,
60+
messages: List[Message],
61+
*,
62+
temperature: float = 0.7,
63+
max_tokens: int = 1024,
64+
**kwargs: Any,
65+
) -> LLMResponse:
66+
start = time.perf_counter() * 1000
67+
resp = self._client.chat.completions.create(
68+
model=self._model,
69+
messages=cast(Any, messages),
70+
temperature=temperature,
71+
max_tokens=max_tokens,
72+
**kwargs,
73+
)
74+
latency = time.perf_counter() * 1000 - start
75+
76+
choice = resp.choices[0]
77+
usage = getattr(resp, "usage", None)
78+
return LLMResponse(
79+
content=choice.message.content or "",
80+
model=getattr(resp, "model", self._model),
81+
prompt_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0,
82+
completion_tokens=getattr(usage, "completion_tokens", 0) if usage else 0,
83+
finish_reason=choice.finish_reason or "stop",
84+
latency_ms=latency,
85+
raw=resp,
86+
)
87+
88+
89+
def build(config: Dict[str, Any]) -> QwenClient:
90+
"""Build a :class:`QwenClient` from a config dict."""
91+
model = config.get("model")
92+
if not model:
93+
raise ValueError("qwen provider requires 'model'.")
94+
return QwenClient(
95+
model=model,
96+
api_key=config.get("api_key"),
97+
base_url=config.get("base_url"),
98+
)

src/llm_bridge/registry.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
Dependency-free providers (``mock``, ``callable``) are registered eagerly.
66
Providers that wrap an official vendor SDK (``openai``, ``bedrock``,
7-
``google``, ``deepseek``) are registered with lazy builders, so importing
7+
``google``, ``deepseek``, ``qwen``) are registered with lazy builders, so importing
88
``llm_bridge`` never pulls in a vendor SDK.
99
"""
1010

@@ -91,6 +91,11 @@ def _deepseek(cfg: Dict[str, Any]) -> LLMClient:
9191

9292
return build(cfg)
9393

94+
def _qwen(cfg: Dict[str, Any]) -> LLMClient:
95+
from llm_bridge.providers.qwen import build
96+
97+
return build(cfg)
98+
9499
def _bedrock(cfg: Dict[str, Any]) -> LLMClient:
95100
from llm_bridge.providers.bedrock import build
96101

@@ -103,6 +108,7 @@ def _google(cfg: Dict[str, Any]) -> LLMClient:
103108

104109
register_provider("openai", _openai)
105110
register_provider("deepseek", _deepseek)
111+
register_provider("qwen", _qwen)
106112
register_provider("bedrock", _bedrock)
107113
register_provider("aws", _bedrock) # alias
108114
register_provider("google", _google)

tests/test_cloud_providers.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,79 @@ def test_deepseek_base_url_override(fake_deepseek_openai):
151151
assert fake_deepseek_openai["init"]["base_url"] == "https://example.test/deepseek"
152152

153153

154+
# --------------------------------------------------------------------------- #
155+
# Alibaba Qwen
156+
# --------------------------------------------------------------------------- #
157+
@pytest.fixture
158+
def fake_qwen_openai(monkeypatch):
159+
captured: dict[str, Any] = {}
160+
161+
class _Msg:
162+
content = "qwen-reply"
163+
164+
class _Choice:
165+
message = _Msg()
166+
finish_reason = "stop"
167+
168+
class _Usage:
169+
prompt_tokens = 9
170+
completion_tokens = 4
171+
172+
class _Resp:
173+
model = "qwen-plus"
174+
choices = [_Choice()]
175+
usage = _Usage()
176+
177+
class _Completions:
178+
def create(self, **kwargs):
179+
captured.update(kwargs)
180+
return _Resp()
181+
182+
class _Chat:
183+
completions = _Completions()
184+
185+
class OpenAI:
186+
def __init__(self, **kwargs):
187+
captured["init"] = kwargs
188+
self.chat = _Chat()
189+
190+
mod = types.ModuleType("openai")
191+
mod.OpenAI = OpenAI
192+
monkeypatch.setitem(sys.modules, "openai", mod)
193+
return captured
194+
195+
196+
def test_qwen_chat_maps_response(fake_qwen_openai):
197+
llm = create_llm({"provider": "qwen", "model": "qwen-plus", "api_key": "dashscope-key"})
198+
assert llm.provider == "qwen"
199+
resp = llm.chat(MESSAGES, temperature=0.1, max_tokens=64, top_p=0.8)
200+
assert resp.content == "qwen-reply"
201+
assert resp.model == "qwen-plus"
202+
assert resp.prompt_tokens == 9
203+
assert resp.completion_tokens == 4
204+
assert resp.total_tokens == 13
205+
assert fake_qwen_openai["init"]["api_key"] == "dashscope-key"
206+
assert (
207+
fake_qwen_openai["init"]["base_url"]
208+
== "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
209+
)
210+
assert fake_qwen_openai["model"] == "qwen-plus"
211+
assert fake_qwen_openai["messages"] == MESSAGES
212+
assert fake_qwen_openai["top_p"] == 0.8
213+
214+
215+
def test_qwen_base_url_override(fake_qwen_openai):
216+
create_llm(
217+
{
218+
"provider": "qwen",
219+
"model": "qwen-plus",
220+
"api_key": "x",
221+
"base_url": "https://example.test/qwen",
222+
}
223+
)
224+
assert fake_qwen_openai["init"]["base_url"] == "https://example.test/qwen"
225+
226+
154227
# --------------------------------------------------------------------------- #
155228
# AWS Bedrock
156229
# --------------------------------------------------------------------------- #

tests/test_registry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def test_builtins_registered():
1414
"callable",
1515
"openai",
1616
"deepseek",
17+
"qwen",
1718
"bedrock",
1819
"aws",
1920
"google",
@@ -37,7 +38,9 @@ def test_overrides_apply():
3738
assert llm.model == "custom"
3839

3940

40-
@pytest.mark.parametrize("provider", ["openai", "deepseek", "bedrock", "aws", "google", "gemini"])
41+
@pytest.mark.parametrize(
42+
"provider", ["openai", "deepseek", "qwen", "bedrock", "aws", "google", "gemini"]
43+
)
4144
def test_cloud_provider_validates_model_before_sdk(provider):
4245
# build() validates required fields before importing any optional SDK,
4346
# so this raises ValueError regardless of whether the SDK is installed.

0 commit comments

Comments
 (0)